diff --git a/deliverable/bayesian_inference_beamer.tex b/deliverable/bayesian_inference_beamer.tex new file mode 100644 index 000000000..25c650e96 --- /dev/null +++ b/deliverable/bayesian_inference_beamer.tex @@ -0,0 +1,390 @@ +\documentclass[aspectratio=169]{beamer} +\usetheme{Madrid} +\usecolortheme{default} + +\usepackage{amsmath,amssymb} +\usepackage{bm} +\usepackage{booktabs} +\usepackage{tikz} +\usetikzlibrary{arrows.meta,positioning,calc,fit,shapes.geometric} + +\title{Bayesian Inference} +\subtitle{A concise introduction for graduate students in quantitative fields} +\author{Generated self-contained Beamer presentation} +\date{\today} + +\newcommand{\E}{\mathbb{E}} +\newcommand{\Var}{\mathrm{Var}} +\newcommand{\Prb}{\mathbb{P}} +\newcommand{\Normal}{\mathcal{N}} +\newcommand{\BetaD}{\mathrm{Beta}} +\newcommand{\Binom}{\mathrm{Binomial}} +\newcommand{\Bern}{\mathrm{Bernoulli}} +\newcommand{\iid}{\stackrel{\mathrm{iid}}{\sim}} + +\begin{document} + +\begin{frame} + \titlepage +\end{frame} + +\begin{frame}{Learning goals} +\begin{itemize} + \item Interpret Bayesian inference as coherent updating of uncertainty. + \item Distinguish the roles of the prior, likelihood, posterior, and predictive distribution. + \item Work through two conjugate examples: Beta--Binomial and Normal--Normal. + \item Understand how Bayesian intervals, prediction, computation, and model checking differ from frequentist analogs. +\end{itemize} +\vspace{0.5em} +\begin{block}{Core idea} +Unknown quantities are treated as random variables, and observed data update beliefs through Bayes' rule. +\end{block} +\end{frame} + +\begin{frame}{Why Bayesian inference?} +\begin{columns}[T,onlytextwidth] +\column{0.57\textwidth} +\begin{itemize} + \item Quantifies uncertainty directly about parameters of interest. + \item Naturally combines prior knowledge with new evidence. + \item Produces full distributions, not just point estimates. + \item Handles prediction and sequential learning in a unified framework. +\end{itemize} + +\column{0.4\textwidth} +\centering +\begin{tikzpicture}[>=Latex, node distance=1.3cm] + \tikzstyle{box}=[draw, rounded corners, align=center, minimum width=2.8cm, minimum height=0.9cm, fill=blue!8] + \node[box] (prior) {Prior belief\\$p(\theta)$}; + \node[box, below=of prior, fill=green!8] (data) {Observed data\\$y$}; + \node[box, right=1.0cm of $(prior)!0.5!(data)$, fill=orange!12] (post) {Updated belief\\$p(\theta\mid y)$}; + \draw[->, thick] (prior.east) -- ++(0.45,0) |- (post.west); + \draw[->, thick] (data.east) -- ++(0.45,0) |- (post.west); +\end{tikzpicture} +\end{columns} +\end{frame} + +\begin{frame}{Bayes' theorem} +\begin{block}{Posterior distribution} +\[ + p(\theta\mid y)=\frac{p(y\mid\theta)\,p(\theta)}{p(y)}, + \qquad + p(y)=\int p(y\mid\theta)p(\theta)\,d\theta. +\] +\end{block} + +\begin{columns}[T,onlytextwidth] +\column{0.55\textwidth} +\begin{itemize} + \item $p(\theta)$: \alert{prior} encodes beliefs before data. + \item $p(y\mid\theta)$: \alert{likelihood} measures compatibility of $\theta$ with the data. + \item $p(y)$: \alert{evidence} normalizes the posterior. + \item $p(\theta\mid y)$: \alert{posterior} combines prior information and data. +\end{itemize} + +\column{0.42\textwidth} +\begin{block}{Working proportionality} +In practice we often use +\[ + p(\theta\mid y)\propto p(y\mid\theta)p(\theta), +\] +and ignore $p(y)$ until normalization or sampling. +\end{block} +\end{columns} +\end{frame} + +\begin{frame}{The Bayesian workflow} +\centering +\begin{tikzpicture}[>=Latex, node distance=1.35cm] + \tikzstyle{stage}=[draw, rounded corners, align=center, minimum width=2.2cm, minimum height=1.0cm, fill=blue!7] + \node[stage] (model) {Specify\\model}; + \node[stage, right=of model] (prior) {Choose\\prior}; + \node[stage, right=of prior] (update) {Update with\\data}; + \node[stage, right=of update] (summ) {Summarize\\posterior}; + \node[stage, below=1.3cm of update, fill=green!10] (check) {Check fit \\\& predict}; + + \draw[->, thick] (model) -- (prior); + \draw[->, thick] (prior) -- (update); + \draw[->, thick] (update) -- (summ); + \draw[->, thick] (summ) |- (check); + \draw[->, thick] (check.west) -| (model.south); +\end{tikzpicture} + +\vspace{0.8em} +\begin{itemize} + \item Bayesian analysis is iterative: model building and model checking form a loop. + \item Posterior predictive checks often reveal misfit even when parameter estimates look reasonable. +\end{itemize} +\end{frame} + +\begin{frame}{Example 1: Beta--Binomial model} +Suppose $y$ successes are observed in $n$ Bernoulli trials with success probability $\theta$. +\[ + y\mid\theta \sim \Binom(n,\theta), + \qquad + p(y\mid\theta)\propto \theta^y(1-\theta)^{n-y}. +\] +Choose a Beta prior: +\[ + \theta \sim \BetaD(\alpha,\beta), + \qquad + p(\theta)\propto \theta^{\alpha-1}(1-\theta)^{\beta-1}. +\] + +\begin{block}{Conjugacy} +The posterior is in the same family as the prior, which makes updating algebraically simple. +\end{block} +\end{frame} + +\begin{frame}{Closed-form update and interpretation} +Combining likelihood and prior gives +\[ + p(\theta\mid y)\propto \theta^{y+\alpha-1}(1-\theta)^{n-y+\beta-1}, +\] +so that +\[ + \theta\mid y \sim \BetaD(\alpha+y,\beta+n-y). +\] + +\begin{columns}[T,onlytextwidth] +\column{0.52\textwidth} +\begin{block}{Posterior mean} +\[ + \E[\theta\mid y]=\frac{\alpha+y}{\alpha+\beta+n}. +\] +It is a weighted average of the prior mean $\alpha/(\alpha+\beta)$ and the sample proportion $y/n$. +\end{block} + +\column{0.44\textwidth} +\centering +\vspace{-0.9em} +\begin{tikzpicture}[>=Latex, node distance=0.58cm, scale=0.8, transform shape] + \tikzstyle{mini}=[draw, rounded corners, align=center, minimum width=2.45cm, minimum height=0.62cm] + \node[mini, fill=blue!8] (prior) {Prior pseudo-counts\\$\alpha-1,\ \beta-1$}; + \node[mini, below=of prior, fill=green!8] (data) {Observed counts\\$y,\ n-y$}; + \node[mini, below=of data, fill=orange!12] (post) {Posterior counts\\$\alpha+y,\ \beta+n-y$}; + \draw[->, thick] (prior) -- (data); + \draw[->, thick] (data) -- (post); +\end{tikzpicture} +\end{columns} +\end{frame} + +\begin{frame}{Numerical update example} +Assume a prior centered at $0.50$ with moderate strength: +\[ + \theta \sim \BetaD(4,4). +\] +Observe $y=16$ successes in $n=20$ trials. Then +\[ + \theta\mid y \sim \BetaD(20,8). +\] + +\begin{columns}[T,onlytextwidth] +\column{0.55\textwidth} +\begin{itemize} + \item Prior mean: $4/(4+4)=0.50$. + \item Sample proportion: $16/20=0.80$. + \item Posterior mean: $20/28\approx 0.714$. + \item The posterior shrinks the raw sample proportion toward the prior mean. +\end{itemize} + +\column{0.4\textwidth} +\begin{block}{Interpretation} +The prior acts like extra observations, so Bayesian estimates often stabilize noisy small-sample problems. +\end{block} +\end{columns} +\end{frame} + +\begin{frame}{Example 2: Normal mean with known variance} +Suppose +\[ + y_i \mid \theta \iid \Normal(\theta,\sigma^2), + \qquad + \theta \sim \Normal(\mu_0,\tau_0^2), +\] +with known sampling variance $\sigma^2$. + +\begin{block}{Posterior distribution} +\[ + \theta\mid y \sim \Normal(\mu_n,\tau_n^2), +\] +where +\[ + \tau_n^2=\left(\frac{1}{\tau_0^2}+\frac{n}{\sigma^2}\right)^{-1}, + \qquad + \mu_n=\tau_n^2\left(\frac{\mu_0}{\tau_0^2}+\frac{n\bar y}{\sigma^2}\right). +\] +\end{block} +The posterior mean is a precision-weighted average of the prior mean and sample mean. +\end{frame} + +\begin{frame}{Posterior summaries and interval estimates} +Once we have $p(\theta\mid y)$, common summaries include: +\[ + \text{posterior mean }\E[\theta\mid y], + \qquad + \text{MAP }\arg\max_{\theta} p(\theta\mid y), + \qquad + \text{posterior variance }\Var(\theta\mid y). +\] + +\begin{columns}[T,onlytextwidth] +\column{0.5\textwidth} +\begin{block}{Credible interval} +A $95\%$ credible interval $[a,b]$ satisfies +\[ + \Prb(\theta\in[a,b]\mid y)=0.95. +\] +This is a probability statement about the parameter given the observed data. +\end{block} + +\column{0.47\textwidth} +\begin{block}{Frequentist confidence interval} +A $95\%$ confidence interval is a procedure whose long-run coverage is $95\%$ over repeated samples. +\end{block} +\end{columns} +\end{frame} + +\begin{frame}{Posterior predictive distribution} +Prediction integrates over parameter uncertainty: +\[ + p(\tilde y\mid y)=\int p(\tilde y\mid\theta)p(\theta\mid y)\,d\theta. +\] + +\begin{columns}[T,onlytextwidth] +\column{0.56\textwidth} +\begin{itemize} + \item This is crucial: predictions should reflect uncertainty in both noise and parameters. + \item In the Beta--Binomial model, the predictive probability of success on the next trial is + \[ + \Prb(\tilde y=1\mid y)=\E[\theta\mid y]=\frac{\alpha+y}{\alpha+\beta+n}. + \] + \item Posterior predictive checks compare replicated data $\tilde y$ with observed data $y$. +\end{itemize} + +\column{0.38\textwidth} +\centering +\begin{tikzpicture}[>=Latex, node distance=1.0cm] + \tikzstyle{box}=[draw, rounded corners, align=center, minimum width=2.6cm, minimum height=0.85cm] + \node[box, fill=orange!12] (post) {Posterior\\$p(\theta\mid y)$}; + \node[box, below=of post, fill=green!10] (pred) {Predict new data\\$p(\tilde y\mid y)$}; + \draw[->, thick] (post) -- (pred); +\end{tikzpicture} +\end{columns} +\end{frame} + +\begin{frame}{Hierarchical models and partial pooling} +Bayesian models scale naturally to grouped data. For groups $j=1,\dots,J$: +\[ + y_{ij}\mid\theta_j \sim p(y_{ij}\mid\theta_j), + \qquad + \theta_j\mid\mu,\tau^2 \sim \Normal(\mu,\tau^2). +\] + +\begin{columns}[T,onlytextwidth] +\column{0.52\textwidth} +\begin{itemize} + \item Group-specific parameters borrow strength from one another. + \item Small groups are shrunk more strongly toward the population mean. + \item This often improves estimation and prediction relative to no pooling or complete pooling. +\end{itemize} + +\column{0.42\textwidth} +\centering +\begin{tikzpicture}[>=Latex] + \node[draw, circle, fill=blue!8] (mu) at (0,1.8) {$\mu,\tau^2$}; + \node[draw, circle, fill=green!10] (t1) at (-1.4,0.5) {$\theta_1$}; + \node[draw, circle, fill=green!10] (t2) at (0,0.5) {$\theta_2$}; + \node[draw, circle, fill=green!10] (t3) at (1.4,0.5) {$\theta_J$}; + \node[draw, circle, fill=orange!10] (y1) at (-1.4,-0.8) {$y_{i1}$}; + \node[draw, circle, fill=orange!10] (y2) at (0,-0.8) {$y_{i2}$}; + \node[draw, circle, fill=orange!10] (y3) at (1.4,-0.8) {$y_{iJ}$}; + \draw[->, thick] (mu) -- (t1); + \draw[->, thick] (mu) -- (t2); + \draw[->, thick] (mu) -- (t3); + \draw[->, thick] (t1) -- (y1); + \draw[->, thick] (t2) -- (y2); + \draw[->, thick] (t3) -- (y3); + \draw[rounded corners] (-2.1,-1.35) rectangle (2.1,0.95); + \node at (1.75,-1.15) {$j=1,\dots,J$}; +\end{tikzpicture} +\end{columns} +\end{frame} + +\begin{frame}{When closed forms fail: computation} +Many useful posteriors are not analytically tractable. + +\begin{block}{Common computational strategies} +\begin{itemize} + \item \textbf{MCMC}: constructs a Markov chain whose stationary distribution is the posterior. + \item \textbf{Hamiltonian Monte Carlo}: efficient for high-dimensional continuous parameters. + \item \textbf{Variational inference}: turns inference into optimization for faster approximations. +\end{itemize} +\end{block} + +\begin{block}{Diagnostics matter} +Check convergence, effective sample size, Monte Carlo standard errors, and sensitivity to priors. +\end{block} +\end{frame} + +\begin{frame}{Model checking and sensitivity analysis} +\begin{columns}[T,onlytextwidth] +\column{0.58\textwidth} +\begin{itemize} + \item \textbf{Posterior predictive checks}: compare observed summaries $T(y)$ to replicated summaries $T(\tilde y)$. + \item \textbf{Residual structure}: look for patterns left unexplained by the model. + \item \textbf{Prior sensitivity}: ask whether substantive conclusions change under reasonable alternative priors. + \item \textbf{Decision relevance}: assess whether posterior uncertainty is small enough for the scientific or policy question. +\end{itemize} + +\column{0.37\textwidth} +\centering +\begin{tikzpicture}[>=Latex, node distance=0.95cm] + \tikzstyle{box}=[draw, rounded corners, align=center, minimum width=2.6cm, minimum height=0.85cm] + \node[box, fill=blue!8] (fit) {Fit model}; + \node[box, below=of fit, fill=green!8] (rep) {Simulate\\replicated data}; + \node[box, below=of rep, fill=orange!12] (cmp) {Compare\\$y$ and $\tilde y$}; + \node[box, below=of cmp, fill=red!10] (rev) {Revise if needed}; + \draw[->, thick] (fit) -- (rep); + \draw[->, thick] (rep) -- (cmp); + \draw[->, thick] (cmp) -- (rev); + \draw[->, thick] (rev.west) -| ++(-1.0,0) |- (fit.west); +\end{tikzpicture} +\end{columns} +\end{frame} + +\begin{frame}{Bayesian linear regression in one line} +For the Gaussian linear model +\[ + \bm y\mid\bm\beta,\sigma^2 \sim \Normal(X\bm\beta,\sigma^2 I), +\] +with Gaussian prior $\bm\beta\sim\Normal(\bm\beta_0,V_0)$, the posterior is also Gaussian: +\[ + V_n=(V_0^{-1}+X^TX/\sigma^2)^{-1}, + \qquad + \bm\beta_n=V_n\left(V_0^{-1}\bm\beta_0+X^T\bm y/\sigma^2\right). +\] + +\begin{block}{Why this matters} +This reveals a general pattern: regularization methods such as ridge regression can often be interpreted as Bayesian estimation with specific priors. +\end{block} +\end{frame} + +\begin{frame}{Takeaways} +\begin{enumerate} + \item Bayesian inference updates uncertainty via + \[ + \text{posterior} \propto \text{likelihood} \times \text{prior}. + \] + \item Conjugate models build intuition; modern computation handles richer models. + \item Credible intervals and predictive distributions are direct, interpretable posterior summaries. + \item Good Bayesian practice includes prior choice, computation, model checking, and sensitivity analysis. +\end{enumerate} + +\vspace{0.5em} +\begin{block}{Final message} +Bayesian inference is not only a formula; it is a workflow for learning from data under uncertainty. +\end{block} +\end{frame} + +\end{document} diff --git a/deliverable/fourier_transform_explained.svg b/deliverable/fourier_transform_explained.svg new file mode 100644 index 000000000..158bf88e4 --- /dev/null +++ b/deliverable/fourier_transform_explained.svg @@ -0,0 +1,110 @@ + +Visualization of the Fourier Transform +An infographic explaining how a signal in time can be represented as a sum of sine waves and converted into a frequency spectrum using the Fourier transform. + + + + + +Fourier Transform: turning a signal into its frequencies + + The Fourier transform asks: “Which sine waves are hidden inside this signal, and how strong are they?” + It converts a waveform from the time domain into a frequency-domain spectrum. + + +Key idea: +x(t) → decompose into sinusoids → F(f) +F(f) = ∫ x(t)e^(-i2πft) dt +Same information, different viewpoint + + + + +1 +Start with a time signal + + A measured signal changes over time. + Here it contains slow and fast oscillations mixed together. + + + + + + + + + +time +amplitude + +Observed waveform x(t) + + Complicated in time, but often simple in frequency. + The transform reveals the hidden ingredients. + + +2 +Compare against sine waves + + The transform checks many frequencies. + Strong matches mean those frequencies are present. + + + +Low frequency +large slow swings + + +Medium frequency +faster oscillation + + +High frequency +fine detail / ripples +If a basis wave lines up with the signal, its coefficient is large. + + So the waveform is re-expressed as: + signal = (low-frequency part) + (medium part) + (high-frequency part) + ... + + +3 +Plot the spectrum + + Instead of showing change over time, + the spectrum shows energy at each frequency. + + + + + + + + + +frequency +magnitude + +1 Hz +0.82 + +3 Hz +0.42 + +6 Hz +0.20 +Peaks mark the dominant frequencies. + + That is why Fourier methods help with filtering, + compression, audio analysis, communications, + imaging, and solving differential equations. + + + +analyze +summarize + +Time domain = “what happens when?” • Frequency domain = “what frequencies are present?” + +Why it matters +find patterns hidden in waves + \ No newline at end of file diff --git a/deliverable/gitvault/README.md b/deliverable/gitvault/README.md new file mode 100644 index 000000000..ca97438be --- /dev/null +++ b/deliverable/gitvault/README.md @@ -0,0 +1,25 @@ +# GitVault + +GitVault is a GitHub-inspired platform built as a single deployable FastAPI application with SQLite-backed collaboration features and real Git repository operations. + +## Run locally + +```bash +uv sync +uv run uvicorn gitvault.app:app --reload +``` + +Then open `http://127.0.0.1:8000`. + +## Core capabilities in this build + +- User registration, persistent session tracking, TOTP-based 2FA, personal access tokens, SSH/GPG key management +- Repository creation, visibility, archive/delete, forking +- File browser, README rendering, raw view, blame, file history, compare view, ZIP download +- Branches, tags, commit history, simple web editor/commit flow, releases +- Issues, pull requests, review requests, inline suggested-change comments, merge strategies, protected branches, CODEOWNERS-aware review checks, and queued auto-merge +- Discussions, projects, wiki, notifications, profiles, orgs, admin dashboard +- Workflow YAML parsing and simulated Actions runs/logs +- Search, trending, explore, stars, watches, contributors, language and dependency insights +- Pages preview, package registry records, marketplace directory, sponsorship tiers, REST/GraphQL-style JSON endpoints +- Repository webhooks with event filtering plus recorded deliveries for push/issues/pull_request/release/discussion events diff --git a/deliverable/gitvault/gitvault/__init__.py b/deliverable/gitvault/gitvault/__init__.py new file mode 100644 index 000000000..07c5de9e2 --- /dev/null +++ b/deliverable/gitvault/gitvault/__init__.py @@ -0,0 +1,2 @@ +__all__ = ["__version__"] +__version__ = "0.1.0" diff --git a/deliverable/gitvault/gitvault/app.py b/deliverable/gitvault/gitvault/app.py new file mode 100644 index 000000000..c436b7fdb --- /dev/null +++ b/deliverable/gitvault/gitvault/app.py @@ -0,0 +1,1596 @@ +from __future__ import annotations + +import base64 +import hashlib +import hmac +import html +import json +import os +import secrets +import shutil +import tempfile +import time +from pathlib import Path +from typing import Any +from urllib.parse import quote + +from fastapi import FastAPI, Form, HTTPException, Request +from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, PlainTextResponse, RedirectResponse +from fastapi.staticfiles import StaticFiles +from markdown import markdown +from starlette.middleware.sessions import SessionMiddleware + +from gitvault.database import Database +from gitvault.gitops import GitService + + +def hash_password(password: str, salt: str | None = None) -> str: + salt = salt or secrets.token_hex(16) + digest = hashlib.pbkdf2_hmac("sha256", password.encode(), salt.encode(), 120_000).hex() + return f"{salt}${digest}" + + +def verify_password(password: str, stored: str) -> bool: + salt, digest = stored.split("$", 1) + candidate = hash_password(password, salt).split("$", 1)[1] + return hmac.compare_digest(candidate, digest) + + +def generate_totp_secret() -> str: + return base64.b32encode(secrets.token_bytes(10)).decode().rstrip("=") + + +def totp_code(secret: str, counter: int | None = None) -> str: + normalized = secret.upper() + padding = "=" * ((8 - len(normalized) % 8) % 8) + key = base64.b32decode(normalized + padding) + counter = int(time.time()) // 30 if counter is None else counter + digest = hmac.new(key, counter.to_bytes(8, "big"), hashlib.sha1).digest() + offset = digest[-1] & 0x0F + chunk = digest[offset : offset + 4] + code = (int.from_bytes(chunk, "big") & 0x7FFFFFFF) % 1_000_000 + return f"{code:06d}" + + +def verify_totp(secret: str, code: str) -> bool: + counter = int(time.time()) // 30 + return any(hmac.compare_digest(totp_code(secret, counter + delta), code.strip()) for delta in (-1, 0, 1)) + + +def hash_api_token(token: str) -> str: + return hashlib.sha256(token.encode()).hexdigest() + + +def esc(value: Any) -> str: + return html.escape(str(value if value is not None else "")) + + +def md(value: str) -> str: + return markdown(value or "", extensions=["fenced_code", "tables"]) + + +def nav_link(label: str, href: str) -> str: + return f'{esc(label)}' + + +def render_page(title: str, body: str, user: dict[str, Any] | None = None, flash: str = "") -> HTMLResponse: + user_links = ( + f"@{esc(user['username'])}" + f"{nav_link('Notifications', '/notifications')}" + f"{nav_link('Profile', f'/u/{user['username']}')}" + f"{nav_link('Security', '/settings/security')}" + f"{nav_link('New Repo', '/repos/new')}" + f"{nav_link('New Org', '/orgs/new')}" + f"{nav_link('Logout', '/logout')}" + if user + else f"{nav_link('Register', '/register')}{nav_link('Login', '/login')}" + ) + flash_html = f"
{esc(flash)}
" if flash else "" + markup = f""" + + + + + + {esc(title)} · GitVault + + + + +
+
GitVault
+ +
+
+ {flash_html} + {body} +
+ + + """ + return HTMLResponse(markup) + + +def create_app(data_dir: Path | None = None, testing: bool = False) -> FastAPI: + data_root = Path(data_dir or Path.cwd() / ".gitvault_data") + data_root.mkdir(parents=True, exist_ok=True) + db = Database(data_root / "gitvault.db") + git = GitService(data_root / "repos") + archives = data_root / "archives" + archives.mkdir(exist_ok=True) + + app = FastAPI(title="GitVault") + app.add_middleware(SessionMiddleware, secret_key="gitvault-secret-key", same_site="lax") + app.mount("/static", StaticFiles(directory=Path(__file__).parent / "static"), name="static") + app.state.db = db + app.state.git = git + app.state.data_root = data_root + app.state.testing = testing + + def current_user(request: Request) -> dict[str, Any] | None: + user_id = request.session.get("user_id") + session_token = request.session.get("session_token") + if session_token: + session = db.get_user_session(session_token) + if not session or session.get("revoked_at"): + request.session.clear() + return None + db.touch_user_session(session_token) + return db.get_user(int(user_id)) if user_id else None + + def begin_session(request: Request, user: dict[str, Any]) -> None: + session_token = secrets.token_urlsafe(24) + request.session["user_id"] = user["id"] + request.session["session_token"] = session_token + request.session.pop("pending_2fa_user_id", None) + db.create_user_session(user["id"], session_token, request.headers.get("user-agent", "browser")) + + def current_token_bundle(request: Request) -> tuple[dict[str, Any] | None, dict[str, Any] | None]: + authorization = request.headers.get("authorization", "") + if not authorization.lower().startswith("bearer "): + return None, None + raw_token = authorization.split(" ", 1)[1].strip() + token_record = db.get_api_token_by_hash(hash_api_token(raw_token)) + if not token_record: + return None, None + db.touch_api_token(token_record["id"]) + return db.get_user(token_record["user_id"]), token_record + + def token_allows_repo(token_record: dict[str, Any] | None, repo: dict[str, Any], required_scope: str = "repo:read") -> bool: + if not token_record: + return False + scopes = token_record.get("scopes", []) + if required_scope not in scopes and "repo" not in scopes and "*" not in scopes: + return False + repo_scope = (token_record.get("repo_scope") or "").strip() + return not repo_scope or repo_scope == f"{repo['owner_slug']}/{repo['slug']}" + + def require_user(request: Request) -> dict[str, Any]: + user = current_user(request) + if not user: + raise HTTPException(status_code=401, detail="Login required") + return user + + def can_view_repo(user: dict[str, Any] | None, repo: dict[str, Any]) -> bool: + if repo["visibility"] == "public": + return True + return bool(user and (user["username"] == repo["owner_slug"] or user["is_admin"])) + + def emit_webhook(repo: dict[str, Any], event_name: str, payload: dict[str, Any]) -> None: + for webhook in db.list_repo_webhooks(repo["id"]): + events = webhook.get("events", []) + if "*" in events or event_name in events: + db.create_webhook_delivery(webhook["id"], event_name, "recorded", payload) + + def repo_path(repo: dict[str, Any]) -> Path: + return git.repo_path(repo["owner_slug"], repo["slug"]) + + def repo_header(repo: dict[str, Any], current_tab: str = "code") -> str: + base = f"/{repo['owner_slug']}/{repo['slug']}" + tabs = [ + ("Code", base), + ("Issues", f"{base}/issues"), + ("Pull Requests", f"{base}/pulls"), + ("Discussions", f"{base}/discussions"), + ("Projects", f"{base}/projects"), + ("Wiki", f"{base}/wiki"), + ("Actions", f"{base}/actions"), + ("Packages", f"{base}/packages"), + ("Pages", f"{base}/pages"), + ("Insights", f"{base}/insights"), + ("Settings", f"{base}/settings"), + ] + actions = """ +
+
+
+
+
+ """.replace("{base}", base) + return f""" +
+
+

{esc(repo['owner_slug'])}/{esc(repo['slug'])}

+

{esc(repo['description'])}

+
+ {esc(repo['visibility'])} + {'archived' if repo['archived'] else 'active'} + {repo['stars_count']} stars + {repo['watchers_count']} watchers + {repo['forks_count']} forks +
+
{''.join(f"{esc(topic)}" for topic in repo.get('topics', []))}
+
+ {actions} +
+ + """ + + def list_to_cards(items: list[str]) -> str: + if not items: + return "

None yet.

" + return "" + + def record_repo_notification(repo: dict[str, Any], kind: str, message: str, url: str) -> None: + if repo["owner_type"] == "user": + owner = db.get_user(repo["owner_id"]) + if owner: + db.add_notification(owner["id"], kind, message, url) + + def trigger_actions(repo: dict[str, Any], event_name: str) -> None: + workflows = git.parse_workflows(repo_path(repo)) + if not workflows: + db.create_action_run(repo["id"], "GitVault Checks", event_name, "completed", f"Workflow: GitVault Checks\nEvent: {event_name}\nStep: baseline repository checks") + return + for workflow in workflows: + logs = [f"Workflow: {workflow['name']}", f"Event: {event_name}"] + for job in workflow["jobs"]: + logs.append(f"Job: {job}") + for step in workflow["steps"]: + logs.append(f"Step: {step}") + db.create_action_run(repo["id"], workflow["name"], event_name, "completed", "\n".join(logs)) + + def pr_gate_state(repo: dict[str, Any], pr: dict[str, Any], reviews: list[dict[str, Any]] | None = None) -> tuple[list[str], set[str]]: + reviews = reviews if reviews is not None else db.get_reviews(pr["id"]) + gate_message: list[str] = [] + rule = db.get_branch_rule(repo["id"], pr["target_branch"]) + if git.merge_conflicts(repo_path(repo), pr["source_branch"], pr["target_branch"]): + gate_message.append("merge conflict detected") + if rule and rule["require_reviews"] and not any(review["state"] == "APPROVED" for review in reviews): + gate_message.append("required review missing") + if rule and rule["required_status_checks"] and not db.list_action_runs(repo["id"]): + gate_message.append("status checks missing") + + required_codeowners: set[str] = set() + if rule and rule["codeowners_required"]: + required_codeowners = { + owner + for owner in git.required_codeowners( + repo_path(repo), + pr["target_branch"], + pr["source_branch"], + pr["target_branch"], + ) + if db.get_user_by_username(owner) + } + approved_usernames = { + reviewer["username"] + for review in reviews + if review["state"] == "APPROVED" + for reviewer in [db.get_user(review["author_id"])] + if reviewer + } + if required_codeowners and not (approved_usernames & required_codeowners): + gate_message.append("CODEOWNERS approval missing") + return gate_message, required_codeowners + + def complete_merge(repo: dict[str, Any], pr: dict[str, Any], strategy: str) -> None: + git.merge_pr(repo_path(repo), pr["source_branch"], pr["target_branch"], strategy) + db.merge_pr(pr["id"], strategy) + if pr["linked_issue_number"]: + db.execute( + "UPDATE issues SET state = 'closed', updated_at = ? WHERE repo_id = ? AND number = ?", + (db.fetchone("SELECT CURRENT_TIMESTAMP as now")["now"], repo["id"], pr["linked_issue_number"]), + ) + trigger_actions(repo, "merge_group") + emit_webhook( + repo, + "pull_request", + {"repository": f"{repo['owner_slug']}/{repo['slug']}", "action": "closed", "merged": True, "number": pr["number"], "strategy": strategy}, + ) + + def process_auto_merge_queue(repo: dict[str, Any]) -> None: + for queued_pr in db.list_auto_merge_prs(repo["id"]): + gate_message, _ = pr_gate_state(repo, queued_pr) + if gate_message: + break + strategy = queued_pr.get("merge_strategy") or "merge" + complete_merge(repo, queued_pr, strategy) + + @app.get("/", response_class=HTMLResponse) + def home(request: Request) -> HTMLResponse: + user = current_user(request) + repos = db.list_repos("public")[:8] + cards = "".join( + f"

{esc(r['owner_slug'])}/{esc(r['slug'])}

{esc(r['description'])}

{esc(r['visibility'])} · {r['stars_count']}★
" + for r in repos + ) or "

No repositories yet.

" + body = f""" +
+
+

GitVault

+

A GitHub-inspired code collaboration platform with real Git repositories, issues, pull requests, Actions, packages, pages, orgs, search, admin, and more.

+ +
+
+

Platform coverage

+ +
+
+
+

Trending repositories

+
{cards}
+
+ """ + return render_page("Home", body, user) + + @app.get("/register", response_class=HTMLResponse) + def register_form(request: Request) -> HTMLResponse: + body = """ +

Create account

+
+ + + + +
+ """ + return render_page("Register", body, current_user(request)) + + @app.post("/register") + def register(request: Request, email: str = Form(...), username: str = Form(...), password: str = Form(...)): + if db.get_user_by_email(email) or db.get_user_by_username(username): + return render_page("Register", "

Account already exists.

", None) + db.create_user(email, username, hash_password(password)) + return RedirectResponse("/login", status_code=303) + + @app.get("/login", response_class=HTMLResponse) + def login_form(request: Request) -> HTMLResponse: + body = """ +

Sign in

+
+ + + +
+ """ + return render_page("Login", body, current_user(request)) + + @app.post("/login") + def login(request: Request, email: str = Form(...), password: str = Form(...)): + user = db.get_user_by_email(email) + if not user or not verify_password(password, user["password_hash"]): + return render_page("Login", "

Invalid credentials.

", None) + security = db.get_user_security(user["id"]) + if security.get("two_factor_enabled"): + request.session.clear() + request.session["pending_2fa_user_id"] = user["id"] + return RedirectResponse("/login/2fa", status_code=303) + begin_session(request, user) + return RedirectResponse("/", status_code=303) + + @app.get("/login/2fa", response_class=HTMLResponse) + def login_2fa_form(request: Request) -> HTMLResponse: + pending_id = request.session.get("pending_2fa_user_id") + if not pending_id: + return RedirectResponse("/login", status_code=303) + body = """ +

Two-factor authentication

+
+ + +
+ """ + return render_page("Two-factor", body, None) + + @app.post("/login/2fa") + def login_2fa_verify(request: Request, code: str = Form(...)): + pending_id = request.session.get("pending_2fa_user_id") + if not pending_id: + raise HTTPException(status_code=400, detail="No pending two-factor login") + user = db.get_user(int(pending_id)) + security = db.get_user_security(user["id"]) + if not security.get("totp_secret") or not verify_totp(security["totp_secret"], code): + return render_page("Two-factor", "

Invalid authentication code.

", None) + begin_session(request, user) + return RedirectResponse("/", status_code=303) + + @app.get("/logout") + def logout(request: Request): + session_token = request.session.get("session_token") + if session_token: + db.revoke_user_session(session_token) + request.session.clear() + return RedirectResponse("/", status_code=303) + + def render_security_settings(user: dict[str, Any], request: Request, flash: str = "") -> HTMLResponse: + security = db.get_user_security(user["id"]) + sessions = db.list_user_sessions(user["id"]) + tokens = db.list_api_tokens(user["id"]) + ssh_keys = db.list_user_keys(user["id"], "ssh") + gpg_keys = db.list_user_keys(user["id"], "gpg") + current_session = request.session.get("session_token", "") + secret_block = ( + f"

TOTP secret: {esc(security['totp_secret'])}

" + if security.get("totp_secret") + else "

Two-factor is not enabled yet.

" + ) + body = f""" +

Security settings

+
+
+

Two-factor authentication

+

Status: {'enabled' if security.get('two_factor_enabled') else 'disabled'}

+ {secret_block} +
+ +
+
+
+

Personal access tokens

+
+ + + + +
+ {list_to_cards([f"{esc(token['name'])} · {esc(token['token_prefix'])} · scopes={', '.join(token.get('scopes', [])) or 'none'}" for token in tokens])} +
+
+

SSH keys

+
+ + + + +
+ {list_to_cards([f"{esc(item['title'])} · {esc(item['public_key'][:48])}..." for item in ssh_keys])} +
+
+

GPG keys

+
+ + + + +
+ {list_to_cards([f"{esc(item['title'])} · {esc(item['public_key'][:48])}..." for item in gpg_keys])} +
+
+
+

Active sessions

+ {list_to_cards([f"{'current session' if session['session_token'] == current_session else 'browser session'} · {esc(session['created_at'])} · last seen {esc(session['last_seen_at'])}
" for session in sessions])} +
+ """ + return render_page("Security", body, user, flash) + + @app.get("/settings/security", response_class=HTMLResponse) + def security_settings_page(request: Request) -> HTMLResponse: + user = require_user(request) + return render_security_settings(user, request) + + @app.post("/settings/security/totp/enable") + def enable_totp_route(request: Request): + user = require_user(request) + secret = generate_totp_secret() + db.enable_totp(user["id"], secret) + return render_security_settings(user, request, flash=f"TOTP enabled. Save this secret: {secret}") + + @app.post("/settings/tokens") + def create_token_route( + request: Request, + name: str = Form(...), + scopes: str = Form("repo:read"), + repo_scope: str = Form(""), + ) -> JSONResponse: + user = require_user(request) + raw_token = f"gv_{secrets.token_urlsafe(24)}" + scope_list = [item.strip() for item in scopes.split(",") if item.strip()] + db.create_api_token(user["id"], name, raw_token[:12], hash_api_token(raw_token), scope_list, repo_scope.strip()) + return JSONResponse({"token": raw_token, "name": name, "scopes": scope_list, "repo_scope": repo_scope.strip()}) + + @app.post("/settings/keys") + def create_key_route(request: Request, key_type: str = Form(...), title: str = Form(...), public_key: str = Form(...)): + user = require_user(request) + db.create_user_key(user["id"], key_type, title, public_key) + return RedirectResponse("/settings/security", status_code=303) + + @app.post("/settings/sessions/{session_token}/revoke") + def revoke_session_route(request: Request, session_token: str): + user = require_user(request) + session = db.get_user_session(session_token) + if not session or session["user_id"] != user["id"]: + raise HTTPException(status_code=404) + db.revoke_user_session(session_token) + if request.session.get("session_token") == session_token: + request.session.clear() + return RedirectResponse("/login", status_code=303) + return RedirectResponse("/settings/security", status_code=303) + + @app.get("/repos/new", response_class=HTMLResponse) + def new_repo_form(request: Request) -> HTMLResponse: + user = require_user(request) + orgs = db.list_orgs_for_user(user["id"]) + options = "".join(f"" for o in orgs) + body = f""" +

Create repository

+
+ + + + + + + + + +
+ """ + return render_page("New repo", body, user) + + @app.post("/repos/new") + def create_repo( + request: Request, + name: str = Form(...), + description: str = Form(""), + visibility: str = Form("public"), + owner_slug: str = Form(""), + readme_template: str = Form("default"), + license_template: str = Form(""), + gitignore_template: str = Form("default"), + topics: str = Form(""), + ): + user = require_user(request) + owner_slug = owner_slug or user["username"] + org = db.get_org(owner_slug) + owner_type = "org" if org else "user" + owner_id = org["id"] if org else user["id"] + topics_list = [item.strip() for item in topics.split(",") if item.strip()] + repo_id = db.create_repo(owner_slug, owner_type, owner_id, name, description, visibility, topics_list) + git.init_repo(owner_slug, name, description, readme_template, license_template, gitignore_template) + repo = db.get_repo_by_id(repo_id) + db.record_traffic(repo_id, "repo_created") + return RedirectResponse(f"/{repo['owner_slug']}/{repo['slug']}", status_code=303) + + @app.post("/{owner_slug}/{repo_slug}/star") + def star_repo(request: Request, owner_slug: str, repo_slug: str): + user = require_user(request) + repo = db.get_repo(owner_slug, repo_slug) + db.execute("INSERT OR IGNORE INTO stars (user_id, repo_id) VALUES (?, ?)", (user["id"], repo["id"])) + db.execute("UPDATE repositories SET stars_count = (SELECT COUNT(*) FROM stars WHERE repo_id = ?) WHERE id = ?", (repo["id"], repo["id"])) + return RedirectResponse(f"/{owner_slug}/{repo_slug}", status_code=303) + + @app.post("/{owner_slug}/{repo_slug}/watch") + def watch_repo(request: Request, owner_slug: str, repo_slug: str): + user = require_user(request) + repo = db.get_repo(owner_slug, repo_slug) + db.execute("INSERT OR IGNORE INTO watches (user_id, repo_id) VALUES (?, ?)", (user["id"], repo["id"])) + db.execute("UPDATE repositories SET watchers_count = (SELECT COUNT(*) FROM watches WHERE repo_id = ?) WHERE id = ?", (repo["id"], repo["id"])) + return RedirectResponse(f"/{owner_slug}/{repo_slug}", status_code=303) + + @app.post("/{owner_slug}/{repo_slug}/fork") + def fork_repo(request: Request, owner_slug: str, repo_slug: str): + user = require_user(request) + source_repo = db.get_repo(owner_slug, repo_slug) + fork_name = repo_slug if user["username"] != owner_slug else f"{repo_slug}-fork" + fork_id = db.create_repo(user["username"], "user", user["id"], fork_name, f"Fork of {owner_slug}/{repo_slug}", source_repo["visibility"], source_repo.get("topics", [])) + git.fork_repo(repo_path(source_repo), git.repo_path(user["username"], fork_name)) + db.execute("UPDATE repositories SET forks_count = forks_count + 1 WHERE id = ?", (source_repo["id"],)) + return RedirectResponse(f"/{user['username']}/{fork_name}", status_code=303) + + @app.get("/explore", response_class=HTMLResponse) + def explore(request: Request) -> HTMLResponse: + user = current_user(request) + repos = db.list_repos() + cards = "".join( + f"

{esc(r['owner_slug'])}/{esc(r['slug'])}

{esc(r['description'])}

{r['visibility']} · updated {esc(r['updated_at'])}
" + for r in repos + ) or "

No repositories found.

" + return render_page("Explore", f"

Explore repositories

{cards}
", user) + + @app.get("/trending", response_class=HTMLResponse) + def trending(request: Request) -> HTMLResponse: + user = current_user(request) + repos = sorted(db.list_repos("public"), key=lambda item: (item["stars_count"], item["watchers_count"]), reverse=True) + cards = "".join( + f"

{esc(r['owner_slug'])}/{esc(r['slug'])}

{esc(r['description'])}

{r['stars_count']} stars · {r['watchers_count']} watchers
" + for r in repos + ) or "

No public repositories yet.

" + return render_page("Trending", f"

Trending

{cards}
", user) + + @app.get("/u/{username}", response_class=HTMLResponse) + def profile(request: Request, username: str) -> HTMLResponse: + user = current_user(request) + profile_user = db.get_user_by_username(username) + if not profile_user: + raise HTTPException(404) + repos = db.list_owner_repos(username) + body = f""" +
+
+

@{esc(username)}

+

{esc(profile_user['bio']) or 'Open source builder on GitVault.'}

+
+ {esc(profile_user['location']) or 'Unknown location'} + {len(repos)} repos + {', '.join(profile_user.get('achievements', []))} +
+
+
+
+

Pinned repositories

+
{''.join(f"" for r in repos[:6])}
+
+ """ + return render_page(f"@{username}", body, user) + + @app.get("/orgs/new", response_class=HTMLResponse) + def org_form(request: Request) -> HTMLResponse: + user = require_user(request) + body = """ +

Create organization

+
+ + + + +
+ """ + return render_page("New organization", body, user) + + @app.post("/orgs/new") + def create_org(request: Request, name: str = Form(...), slug: str = Form(...), description: str = Form("")): + user = require_user(request) + db.create_org(user["id"], name, slug, description) + return RedirectResponse(f"/orgs/{slug}", status_code=303) + + @app.get("/orgs/{slug}", response_class=HTMLResponse) + def org_page(request: Request, slug: str) -> HTMLResponse: + user = current_user(request) + org = db.get_org(slug) + if not org: + raise HTTPException(404) + repos = db.list_owner_repos(slug) + teams = db.fetchall("SELECT * FROM teams WHERE org_id = ?", (org["id"],)) + body = f""" +
+
+

{esc(org['name'])}

+

{esc(org['description'])}

+
{len(repos)} repos{len(teams)} teamsBilling: Team
+
+
+
+
+

Repositories

+
{''.join(f"" for r in repos) or '

No repos yet.

'}
+
+
+

Teams

+ {list_to_cards([f"{team['name']} · {team['permission']}" for team in teams])} +
+
+ """ + return render_page(org["name"], body, user) + + @app.get("/{owner_slug}/{repo_slug}", response_class=HTMLResponse) + def repo_home(request: Request, owner_slug: str, repo_slug: str) -> HTMLResponse: + user = current_user(request) + repo = db.get_repo(owner_slug, repo_slug) + if not repo or not can_view_repo(user, repo): + raise HTTPException(404) + db.record_traffic(repo["id"], "view") + path = repo_path(repo) + entries = git.get_tree(path, repo["default_branch"]) if path.exists() else [] + readme = "" + try: + readme = git.get_file(path, repo["default_branch"], "README.md") + except Exception: + readme = "" + history = git.commit_history(path, limit=10) + body = repo_header(repo, "code") + f""" +
+ +
+ Branches + Tags + Download ZIP + Compare +
+
+
+
+

Files

+ +
+
+

Recent commits

+ +
+
+
+

README

+
{md(readme)}
+
+ """ + return render_page(f"{owner_slug}/{repo_slug}", body, user) + + @app.get("/{owner_slug}/{repo_slug}/tree/{ref}/{tree_path:path}", response_class=HTMLResponse) + def tree_view(request: Request, owner_slug: str, repo_slug: str, ref: str, tree_path: str) -> HTMLResponse: + user = current_user(request) + repo = db.get_repo(owner_slug, repo_slug) + if not repo or not can_view_repo(user, repo): + raise HTTPException(404) + entries = git.get_tree(repo_path(repo), ref, tree_path) + body = repo_header(repo, "code") + f"

Tree: {esc(tree_path)}

" + return render_page(f"{repo_slug} tree", body, user) + + @app.get("/{owner_slug}/{repo_slug}/blob/{ref}/{file_path:path}", response_class=HTMLResponse) + def blob_view(request: Request, owner_slug: str, repo_slug: str, ref: str, file_path: str) -> HTMLResponse: + user = current_user(request) + repo = db.get_repo(owner_slug, repo_slug) + if not repo or not can_view_repo(user, repo): + raise HTTPException(404) + content = git.get_file(repo_path(repo), ref, file_path) + is_markdown = file_path.endswith(".md") + preview = md(content) if is_markdown else f"
{esc(content)}
" + body = repo_header(repo, "code") + f""" +
+ +
+ Raw + History + Blame + Edit +
+
+
{preview}
+ """ + return render_page(file_path, body, user) + + @app.get("/{owner_slug}/{repo_slug}/raw/{ref}/{file_path:path}") + def raw_view(request: Request, owner_slug: str, repo_slug: str, ref: str, file_path: str): + user = current_user(request) + repo = db.get_repo(owner_slug, repo_slug) + if not repo or not can_view_repo(user, repo): + raise HTTPException(404) + return PlainTextResponse(git.get_file(repo_path(repo), ref, file_path)) + + @app.get("/{owner_slug}/{repo_slug}/history/{ref}/{file_path:path}", response_class=HTMLResponse) + def file_history(request: Request, owner_slug: str, repo_slug: str, ref: str, file_path: str) -> HTMLResponse: + user = current_user(request) + repo = db.get_repo(owner_slug, repo_slug) + history = git.history_for_path(repo_path(repo), ref, file_path) + body = repo_header(repo, "code") + f"

History for {esc(file_path)}

" + return render_page(f"History · {file_path}", body, user) + + @app.get("/{owner_slug}/{repo_slug}/blame/{ref}/{file_path:path}", response_class=HTMLResponse) + def blame_view(request: Request, owner_slug: str, repo_slug: str, ref: str, file_path: str) -> HTMLResponse: + user = current_user(request) + repo = db.get_repo(owner_slug, repo_slug) + blame_lines = git.blame(repo_path(repo), ref, file_path) + body = repo_header(repo, "code") + f"

Blame · {esc(file_path)}

{esc(chr(10).join(blame_lines))}
" + return render_page(f"Blame · {file_path}", body, user) + + @app.get("/{owner_slug}/{repo_slug}/edit/{ref}/{file_path:path}", response_class=HTMLResponse) + def edit_form(request: Request, owner_slug: str, repo_slug: str, ref: str, file_path: str) -> HTMLResponse: + user = require_user(request) + repo = db.get_repo(owner_slug, repo_slug) + try: + content = git.get_file(repo_path(repo), ref, file_path) + except Exception: + content = "" + body = repo_header(repo, "code") + f""" +

Edit {esc(file_path)} on {esc(ref)}

+
+ + + +
+
Copilot equivalent: Inline AI suggestions endpoint available at /api/ai/suggest.
+ """ + return render_page(f"Edit {file_path}", body, user) + + @app.post("/{owner_slug}/{repo_slug}/edit/{ref}/{file_path:path}") + def edit_file( + request: Request, + owner_slug: str, + repo_slug: str, + ref: str, + file_path: str, + content: str = Form(...), + message: str = Form(...), + ): + user = require_user(request) + repo = db.get_repo(owner_slug, repo_slug) + git.commit_file(repo_path(repo), ref, file_path, content, message, user["username"], user["email"]) + db.record_traffic(repo["id"], "commit") + trigger_actions(repo, "push") + db.add_notification(user["id"], "commit", f"Committed to {owner_slug}/{repo_slug}: {message}", f"/{owner_slug}/{repo_slug}/blob/{ref}/{file_path}") + emit_webhook( + repo, + "push", + {"repository": f"{owner_slug}/{repo_slug}", "ref": ref, "file_path": file_path, "message": message}, + ) + return RedirectResponse(f"/{owner_slug}/{repo_slug}/blob/{ref}/{file_path}", status_code=303) + + @app.get("/{owner_slug}/{repo_slug}/download.zip") + def download_zip(request: Request, owner_slug: str, repo_slug: str): + user = current_user(request) + repo = db.get_repo(owner_slug, repo_slug) + if not repo or not can_view_repo(user, repo): + raise HTTPException(404) + archive = git.archive_zip(repo_path(repo), archives / f"{owner_slug}-{repo_slug}.zip") + return FileResponse(archive, filename=f"{repo_slug}.zip") + + @app.get("/{owner_slug}/{repo_slug}/branches", response_class=HTMLResponse) + def branches_page(request: Request, owner_slug: str, repo_slug: str) -> HTMLResponse: + user = current_user(request) + repo = db.get_repo(owner_slug, repo_slug) + branches = git.list_branches(repo_path(repo)) + rules = db.list_branch_rules(repo["id"]) + body = repo_header(repo, "code") + f""" +
+
+

Branches

+ {list_to_cards([f"{esc(branch)}" for branch in branches])} +
+
+

Create branch

+
+ + + +
+

Protection rules

+ {list_to_cards([f"{rule['branch_name']} · reviews={rule['require_reviews']} · codeowners={rule['codeowners_required']} · checks={rule['required_status_checks']}" for rule in rules])} +
+
+ """ + return render_page("Branches", body, user) + + @app.post("/{owner_slug}/{repo_slug}/branches/create") + def branch_create(request: Request, owner_slug: str, repo_slug: str, branch_name: str = Form(...), from_ref: str = Form("main")): + require_user(request) + repo = db.get_repo(owner_slug, repo_slug) + git.create_branch(repo_path(repo), branch_name, from_ref) + return RedirectResponse(f"/{owner_slug}/{repo_slug}/branches", status_code=303) + + @app.get("/{owner_slug}/{repo_slug}/tags", response_class=HTMLResponse) + def tags_page(request: Request, owner_slug: str, repo_slug: str) -> HTMLResponse: + user = current_user(request) + repo = db.get_repo(owner_slug, repo_slug) + tags = git.list_tags(repo_path(repo)) + releases = db.list_releases(repo["id"]) + body = repo_header(repo, "code") + f""" +
+
+

Tags

+ {list_to_cards(tags)} +
+ + + +
+
+
+

Releases

+ {list_to_cards([f"{rel['tag_name']} · {rel['title']}" for rel in releases])} +
+ + + + +
+
+
+ """ + return render_page("Tags & Releases", body, user) + + @app.post("/{owner_slug}/{repo_slug}/tags/create") + def create_tag(request: Request, owner_slug: str, repo_slug: str, tag_name: str = Form(...), message: str = Form("Release")): + require_user(request) + repo = db.get_repo(owner_slug, repo_slug) + git.create_tag(repo_path(repo), tag_name, message) + return RedirectResponse(f"/{owner_slug}/{repo_slug}/tags", status_code=303) + + @app.post("/{owner_slug}/{repo_slug}/releases/new") + def create_release(request: Request, owner_slug: str, repo_slug: str, tag_name: str = Form(...), title: str = Form(...), notes: str = Form("")): + require_user(request) + repo = db.get_repo(owner_slug, repo_slug) + if tag_name not in git.list_tags(repo_path(repo)): + git.create_tag(repo_path(repo), tag_name, title) + db.create_release(repo["id"], tag_name, title, notes, False) + emit_webhook(repo, "release", {"repository": f"{owner_slug}/{repo_slug}", "tag_name": tag_name, "title": title}) + return RedirectResponse(f"/{owner_slug}/{repo_slug}/tags", status_code=303) + + @app.get("/{owner_slug}/{repo_slug}/compare", response_class=HTMLResponse) + def compare_page(request: Request, owner_slug: str, repo_slug: str, base: str = "main", head: str = "main") -> HTMLResponse: + user = current_user(request) + repo = db.get_repo(owner_slug, repo_slug) + diff = git.compare(repo_path(repo), base, head) if base != head else "No diff yet." + body = repo_header(repo, "code") + f""" +

Compare branches / tags / commits

+
+ + + +
+
{esc(diff)}
+ """ + return render_page("Compare", body, user) + + @app.get("/{owner_slug}/{repo_slug}/issues", response_class=HTMLResponse) + def issues_page(request: Request, owner_slug: str, repo_slug: str) -> HTMLResponse: + user = current_user(request) + repo = db.get_repo(owner_slug, repo_slug) + issues = db.list_issues(repo["id"]) + body = repo_header(repo, "issues") + f""" +
New issue
+ + """ + return render_page("Issues", body, user) + + @app.get("/{owner_slug}/{repo_slug}/issues/new", response_class=HTMLResponse) + def issue_form(request: Request, owner_slug: str, repo_slug: str) -> HTMLResponse: + user = require_user(request) + repo = db.get_repo(owner_slug, repo_slug) + body = repo_header(repo, "issues") + """ +

Open issue

+
+ + + + +
+ """ + return render_page("New issue", body, user) + + @app.post("/{owner_slug}/{repo_slug}/issues/new") + def issue_create(request: Request, owner_slug: str, repo_slug: str, title: str = Form(...), body: str = Form(""), labels: str = Form("")): + user = require_user(request) + repo = db.get_repo(owner_slug, repo_slug) + issue_id = db.create_issue(repo["id"], user["id"], title, body, [item.strip() for item in labels.split(",") if item.strip()]) + issue = db.fetchone("SELECT * FROM issues WHERE id = ?", (issue_id,)) + record_repo_notification(repo, "issue", f"New issue in {owner_slug}/{repo_slug}: {title}", f"/{owner_slug}/{repo_slug}/issues/{issue['number']}") + emit_webhook( + repo, + "issues", + {"repository": f"{owner_slug}/{repo_slug}", "action": "opened", "number": issue["number"], "title": title}, + ) + return RedirectResponse(f"/{owner_slug}/{repo_slug}/issues/{issue['number']}", status_code=303) + + @app.get("/{owner_slug}/{repo_slug}/issues/{number}", response_class=HTMLResponse) + def issue_detail(request: Request, owner_slug: str, repo_slug: str, number: int) -> HTMLResponse: + user = current_user(request) + repo = db.get_repo(owner_slug, repo_slug) + issue = db.get_issue(repo["id"], number) + body = repo_header(repo, "issues") + f""" +
+

#{issue['number']} {esc(issue['title'])}

+

State: {esc(issue['state'])}

+
{md(issue['body'])}
+

Labels: {', '.join(issue.get('labels', []))}

+
+ """ + return render_page(issue["title"], body, user) + + @app.get("/{owner_slug}/{repo_slug}/pulls", response_class=HTMLResponse) + def pulls_page(request: Request, owner_slug: str, repo_slug: str) -> HTMLResponse: + user = current_user(request) + repo = db.get_repo(owner_slug, repo_slug) + prs = db.list_prs(repo["id"]) + body = repo_header(repo, "pull") + f""" +
New pull request
+ + """ + return render_page("Pull requests", body, user) + + @app.get("/{owner_slug}/{repo_slug}/pulls/new", response_class=HTMLResponse) + def pr_form(request: Request, owner_slug: str, repo_slug: str) -> HTMLResponse: + user = require_user(request) + repo = db.get_repo(owner_slug, repo_slug) + branches = git.list_branches(repo_path(repo)) + options = "".join(f"" for branch in branches) + body = repo_header(repo, "pull") + f""" +

Open pull request

+
+ + + + + + + +
+ """ + return render_page("New PR", body, user) + + @app.post("/{owner_slug}/{repo_slug}/pulls/new") + def pr_create( + request: Request, + owner_slug: str, + repo_slug: str, + title: str = Form(...), + body: str = Form(""), + source_branch: str = Form(...), + target_branch: str = Form(...), + linked_issue_number: str = Form(""), + draft: str = Form("false"), + ): + user = require_user(request) + repo = db.get_repo(owner_slug, repo_slug) + pr_id = db.create_pull_request( + repo["id"], + user["id"], + title, + body, + source_branch, + target_branch, + int(linked_issue_number) if linked_issue_number.strip() else None, + draft.lower() == "true", + ) + pr = db.fetchone("SELECT * FROM pull_requests WHERE id = ?", (pr_id,)) + trigger_actions(repo, "pull_request") + emit_webhook( + repo, + "pull_request", + {"repository": f"{owner_slug}/{repo_slug}", "action": "opened", "number": pr["number"], "title": title}, + ) + return RedirectResponse(f"/{owner_slug}/{repo_slug}/pulls/{pr['number']}", status_code=303) + + @app.get("/{owner_slug}/{repo_slug}/pulls/{number}", response_class=HTMLResponse) + def pr_detail(request: Request, owner_slug: str, repo_slug: str, number: int) -> HTMLResponse: + user = current_user(request) + repo = db.get_repo(owner_slug, repo_slug) + pr = db.get_pr(repo["id"], number) + diff = git.compare(repo_path(repo), pr["target_branch"], pr["source_branch"]) + reviews = db.get_reviews(pr["id"]) + review_comments = db.list_review_comments(pr["id"]) + gate_message, required_codeowners = pr_gate_state(repo, pr, reviews) + requested_reviewers = pr.get("reviewers", []) + queue_status = ( + f"Queued for auto-merge (position {pr['merge_queue_position']}, strategy {pr['merge_strategy'] or 'merge'})" + if pr.get("auto_merge") + else "Auto-merge disabled" + ) + body = repo_header(repo, "pull") + f""" +
+

#{pr['number']} {esc(pr['title'])}

+

Status: {esc(pr['state'])} · {'draft' if pr['draft'] else 'ready'} · {esc(pr['source_branch'])} → {esc(pr['target_branch'])}

+

Requested reviewers: {esc(', '.join(requested_reviewers) if requested_reviewers else 'none')}

+

Auto-merge: {esc(queue_status)}

+

Merge strategy: {esc(pr.get('merge_strategy') or 'merge')}

+
{md(pr['body'])}
+

Linked issue: {esc(pr['linked_issue_number'])}

+

Merge gate: {esc(', '.join(gate_message) if gate_message else 'ready to merge')}

+

Required CODEOWNERS: {esc(', '.join(sorted(required_codeowners)) if required_codeowners else 'none')}

+
+
+
+

Review diff

+
{esc(diff or 'No diff')}
+

Inline comments

+ {list_to_cards([ + f"{esc(comment['author_username'])} · {esc(comment['file_path'])}:{comment['line_number']}
{esc(comment['body'])}" + + (f"
Suggested change\\n{esc(comment['suggested_change'])}
" if comment.get('suggested_change') else "") + for comment in review_comments + ])} +
+ + + + + +
+
+
+

Reviews

+ {list_to_cards([f"{review['state']} · {esc(review['body'])}" for review in reviews])} +
+ + +
+
+ + + +
+
+ + +
+
+ + +
+
+
+ """ + return render_page(pr["title"], body, user) + + @app.post("/{owner_slug}/{repo_slug}/pulls/{number}/review-requests") + def request_pr_reviewers(request: Request, owner_slug: str, repo_slug: str, number: int, reviewers: str = Form("")): + user = require_user(request) + repo = db.get_repo(owner_slug, repo_slug) + pr = db.get_pr(repo["id"], number) + requested = [item.strip().lstrip("@") for item in reviewers.split(",") if item.strip()] + db.set_pr_reviewers(pr["id"], requested) + for username in requested: + reviewer = db.get_user_by_username(username) + if reviewer: + db.add_notification( + reviewer["id"], + "review_request", + f"Review request for {owner_slug}/{repo_slug} PR #{number} from @{user['username']}", + f"/{owner_slug}/{repo_slug}/pulls/{number}", + ) + return RedirectResponse(f"/{owner_slug}/{repo_slug}/pulls/{number}", status_code=303) + + @app.post("/{owner_slug}/{repo_slug}/pulls/{number}/comments") + def add_pr_comment( + request: Request, + owner_slug: str, + repo_slug: str, + number: int, + file_path: str = Form(...), + line_number: int = Form(1), + body: str = Form(""), + suggested_change: str = Form(""), + ): + user = require_user(request) + repo = db.get_repo(owner_slug, repo_slug) + pr = db.get_pr(repo["id"], number) + db.add_review_comment(pr["id"], user["id"], file_path, int(line_number), body, suggested_change) + return RedirectResponse(f"/{owner_slug}/{repo_slug}/pulls/{number}", status_code=303) + + @app.post("/{owner_slug}/{repo_slug}/pulls/{number}/reviews") + def add_review(request: Request, owner_slug: str, repo_slug: str, number: int, state: str = Form(...), body: str = Form("")): + user = require_user(request) + repo = db.get_repo(owner_slug, repo_slug) + pr = db.get_pr(repo["id"], number) + db.add_review(pr["id"], user["id"], state, body) + process_auto_merge_queue(repo) + return RedirectResponse(f"/{owner_slug}/{repo_slug}/pulls/{number}", status_code=303) + + @app.post("/{owner_slug}/{repo_slug}/pulls/{number}/auto-merge") + def enable_auto_merge(request: Request, owner_slug: str, repo_slug: str, number: int, strategy: str = Form("merge")): + require_user(request) + repo = db.get_repo(owner_slug, repo_slug) + pr = db.get_pr(repo["id"], number) + queue_position = pr["merge_queue_position"] or db.next_merge_queue_position(repo["id"]) + db.set_pr_auto_merge(pr["id"], True, strategy, queue_position) + process_auto_merge_queue(repo) + return RedirectResponse(f"/{owner_slug}/{repo_slug}/pulls/{number}", status_code=303) + + @app.post("/{owner_slug}/{repo_slug}/pulls/{number}/merge") + def merge_pr(request: Request, owner_slug: str, repo_slug: str, number: int, strategy: str = Form("merge")): + require_user(request) + repo = db.get_repo(owner_slug, repo_slug) + pr = db.get_pr(repo["id"], number) + reviews = db.get_reviews(pr["id"]) + gate_message, _ = pr_gate_state(repo, pr, reviews) + if gate_message: + raise HTTPException(status_code=400, detail=", ".join(gate_message)) + complete_merge(repo, pr, strategy) + return RedirectResponse(f"/{owner_slug}/{repo_slug}/pulls/{number}", status_code=303) + + @app.get("/{owner_slug}/{repo_slug}/discussions", response_class=HTMLResponse) + def discussions_page(request: Request, owner_slug: str, repo_slug: str) -> HTMLResponse: + user = current_user(request) + repo = db.get_repo(owner_slug, repo_slug) + discussions = db.list_discussions(repo["id"]) + body = repo_header(repo, "discussion") + f""" +
New discussion
+ + """ + return render_page("Discussions", body, user) + + @app.get("/{owner_slug}/{repo_slug}/discussions/new", response_class=HTMLResponse) + def discussion_form(request: Request, owner_slug: str, repo_slug: str) -> HTMLResponse: + user = require_user(request) + repo = db.get_repo(owner_slug, repo_slug) + body = repo_header(repo, "discussion") + """ +

Start discussion

+
+ + + + +
+ """ + return render_page("New discussion", body, user) + + @app.post("/{owner_slug}/{repo_slug}/discussions/new") + def create_discussion(request: Request, owner_slug: str, repo_slug: str, category: str = Form(...), title: str = Form(...), body: str = Form("")): + user = require_user(request) + repo = db.get_repo(owner_slug, repo_slug) + db.create_discussion(repo["id"], user["id"], category, title, body) + emit_webhook(repo, "discussion", {"repository": f"{owner_slug}/{repo_slug}", "category": category, "title": title}) + return RedirectResponse(f"/{owner_slug}/{repo_slug}/discussions", status_code=303) + + @app.get("/{owner_slug}/{repo_slug}/projects", response_class=HTMLResponse) + def projects_page(request: Request, owner_slug: str, repo_slug: str) -> HTMLResponse: + user = current_user(request) + repo = db.get_repo(owner_slug, repo_slug) + projects = db.list_projects(repo["id"]) + body = repo_header(repo, "project") + f""" +
New project
+
{''.join(f"

{esc(project['name'])}

{esc(project['description'])}

{esc(project['view_type'])}
" for project in projects)}
+ """ + return render_page("Projects", body, user) + + @app.get("/{owner_slug}/{repo_slug}/projects/new", response_class=HTMLResponse) + def project_form(request: Request, owner_slug: str, repo_slug: str) -> HTMLResponse: + user = require_user(request) + repo = db.get_repo(owner_slug, repo_slug) + body = repo_header(repo, "project") + """ +
+ + + + +
+ """ + return render_page("New project", body, user) + + @app.post("/{owner_slug}/{repo_slug}/projects/new") + def create_project(request: Request, owner_slug: str, repo_slug: str, name: str = Form(...), description: str = Form(""), view_type: str = Form("board")): + require_user(request) + repo = db.get_repo(owner_slug, repo_slug) + db.create_project(repo["id"], name, description, view_type) + return RedirectResponse(f"/{owner_slug}/{repo_slug}/projects", status_code=303) + + @app.get("/{owner_slug}/{repo_slug}/wiki", response_class=HTMLResponse) + def wiki_page(request: Request, owner_slug: str, repo_slug: str) -> HTMLResponse: + user = current_user(request) + repo = db.get_repo(owner_slug, repo_slug) + pages = db.list_wiki_pages(repo["id"]) + current = pages[0] if pages else None + content = md(current["content"]) if current else "

No wiki pages yet.

" + revisions = db.get_wiki_revisions(current["id"]) if current else [] + body = repo_header(repo, "wiki") + f""" +
New page
+
+
+

Pages

+ {list_to_cards([f"{esc(page['title'])}" for page in pages])} +
+
+ {content} +
+

History

+ {list_to_cards([esc(rev['created_at']) for rev in revisions])} +
+
+ """ + return render_page("Wiki", body, user) + + @app.get("/{owner_slug}/{repo_slug}/wiki/{slug}", response_class=HTMLResponse) + def wiki_detail(request: Request, owner_slug: str, repo_slug: str, slug: str) -> HTMLResponse: + user = current_user(request) + repo = db.get_repo(owner_slug, repo_slug) + page = db.get_wiki_page(repo["id"], slug) + revisions = db.get_wiki_revisions(page["id"]) if page else [] + body = repo_header(repo, "wiki") + f"
{md(page['content'])}

Sidebar

{esc(page['sidebar'])}

History

{list_to_cards([esc(rev['created_at']) for rev in revisions])}" + return render_page(page["title"], body, user) + + @app.get("/{owner_slug}/{repo_slug}/wiki/new", response_class=HTMLResponse) + def wiki_form(request: Request, owner_slug: str, repo_slug: str) -> HTMLResponse: + user = require_user(request) + repo = db.get_repo(owner_slug, repo_slug) + body = repo_header(repo, "wiki") + """ +
+ + + + +
+ """ + return render_page("New wiki page", body, user) + + @app.post("/{owner_slug}/{repo_slug}/wiki/new") + def create_wiki(request: Request, owner_slug: str, repo_slug: str, title: str = Form(...), content: str = Form(""), sidebar: str = Form("")): + user = require_user(request) + repo = db.get_repo(owner_slug, repo_slug) + db.create_wiki_page(repo["id"], title, content, sidebar, user["id"]) + return RedirectResponse(f"/{owner_slug}/{repo_slug}/wiki", status_code=303) + + @app.get("/{owner_slug}/{repo_slug}/actions", response_class=HTMLResponse) + def actions_page(request: Request, owner_slug: str, repo_slug: str) -> HTMLResponse: + user = current_user(request) + repo = db.get_repo(owner_slug, repo_slug) + workflows = git.parse_workflows(repo_path(repo)) + runs = db.list_action_runs(repo["id"]) + body = repo_header(repo, "action") + f""" +
+
+

Workflows

+ {list_to_cards([f"{workflow['name']} · triggers={workflow['on']} · jobs={', '.join(workflow['jobs'])}" for workflow in workflows])} +
+
+

Runs

+ {list_to_cards([f"{run['workflow_name']} · {run['event_name']} · {run['status']}
{esc(run['logs'])}
" for run in runs])} +
+
+
+ """ + return render_page("Actions", body, user) + + @app.post("/{owner_slug}/{repo_slug}/actions/dispatch") + def actions_dispatch(request: Request, owner_slug: str, repo_slug: str): + require_user(request) + repo = db.get_repo(owner_slug, repo_slug) + trigger_actions(repo, "workflow_dispatch") + process_auto_merge_queue(repo) + return RedirectResponse(f"/{owner_slug}/{repo_slug}/actions", status_code=303) + + @app.get("/{owner_slug}/{repo_slug}/packages", response_class=HTMLResponse) + def packages_page(request: Request, owner_slug: str, repo_slug: str) -> HTMLResponse: + user = current_user(request) + repo = db.get_repo(owner_slug, repo_slug) + packages = db.list_packages(repo["id"]) + if not packages: + db.upsert_package(repo["id"], "npm", repo_slug, "0.1.0", repo["visibility"], {"source": "seed"}) + packages = db.list_packages(repo["id"]) + body = repo_header(repo, "package") + f"

Packages

{list_to_cards([f"{p['ecosystem']} · {p['name']}@{p['version']} · {p['visibility']}" for p in packages])}" + return render_page("Packages", body, user) + + @app.get("/{owner_slug}/{repo_slug}/pages", response_class=HTMLResponse) + def pages_page(request: Request, owner_slug: str, repo_slug: str) -> HTMLResponse: + user = current_user(request) + repo = db.get_repo(owner_slug, repo_slug) + content = git.pages_content(repo_path(repo)) + rendered = md(content) if content.endswith("\n") or content.startswith("#") else f"
{esc(content)}
" + body = repo_header(repo, "page") + f""" +
+

Pages site preview

+

Source: branch {esc(repo['default_branch'])} or /docs folder · Custom domains and HTTPS can be configured via DNS in a production deployment.

+
{rendered}
+
+ """ + return render_page("Pages", body, user) + + @app.get("/{owner_slug}/{repo_slug}/insights", response_class=HTMLResponse) + def insights_page(request: Request, owner_slug: str, repo_slug: str) -> HTMLResponse: + user = current_user(request) + repo = db.get_repo(owner_slug, repo_slug) + insights = git.insights(repo_path(repo)) + traffic = db.get_traffic(repo["id"]) + body = repo_header(repo, "insight") + f""" +
+

Language breakdown

{list_to_cards([f"{lang}: {count} bytes" for lang, count in insights['languages'].items()])}
+

Dependency graph

{list_to_cards(insights['dependencies'])}
+

Contributor graph

{list_to_cards(insights['contributors'])}
+

Traffic analytics

{list_to_cards([f"{row['event_type']}: {row['count']}" for row in traffic])}
+

Network graph

{list_to_cards(git.list_branches(repo_path(repo)) + git.list_tags(repo_path(repo)))}
+

Compare refs

Open compare
+
+ """ + return render_page("Insights", body, user) + + @app.get("/{owner_slug}/{repo_slug}/settings", response_class=HTMLResponse) + def repo_settings(request: Request, owner_slug: str, repo_slug: str) -> HTMLResponse: + user = require_user(request) + repo = db.get_repo(owner_slug, repo_slug) + webhooks = db.list_repo_webhooks(repo["id"]) + body = repo_header(repo, "setting") + f""" +
+
+

Repository settings

+
+ + + +
+

Webhooks

+
+ + + + +
+ {list_to_cards([f"{esc(hook['target_url'])} · events={', '.join(hook.get('events', []))}" for hook in webhooks])} +
+
+

Danger zone

+
+
+
+ """ + return render_page("Settings", body, user) + + @app.post("/{owner_slug}/{repo_slug}/settings/state") + def repo_state_change(request: Request, owner_slug: str, repo_slug: str, visibility: str = Form(...), archived: int = Form(...)): + require_user(request) + repo = db.get_repo(owner_slug, repo_slug) + db.update_repo_state(repo["id"], archived=int(archived), visibility=visibility) + return RedirectResponse(f"/{owner_slug}/{repo_slug}/settings", status_code=303) + + @app.post("/{owner_slug}/{repo_slug}/settings/webhooks") + def create_repo_webhook( + request: Request, + owner_slug: str, + repo_slug: str, + target_url: str = Form(...), + events: str = Form("push"), + secret: str = Form(""), + ): + require_user(request) + repo = db.get_repo(owner_slug, repo_slug) + event_list = [item.strip() for item in events.split(",") if item.strip()] + db.create_repo_webhook(repo["id"], target_url, secret, event_list) + return RedirectResponse(f"/{owner_slug}/{repo_slug}/settings", status_code=303) + + @app.post("/{owner_slug}/{repo_slug}/delete") + def repo_delete(request: Request, owner_slug: str, repo_slug: str): + require_user(request) + repo = db.get_repo(owner_slug, repo_slug) + git.delete_repo(repo_path(repo)) + db.delete_repo(repo["id"]) + return RedirectResponse("/explore", status_code=303) + + @app.get("/search", response_class=HTMLResponse) + def search_page(request: Request, q: str = "") -> HTMLResponse: + user = current_user(request) + if not q: + body = "

Search

" + return render_page("Search", body, user) + results = db.search(q) + code_hits = [] + for repo in db.list_repos(): + path = repo_path(repo) + for file in path.rglob("*"): + if file.is_file() and ".git" not in file.parts: + try: + if q.lower() in file.read_text(encoding="utf-8", errors="ignore").lower(): + code_hits.append(f"{repo['owner_slug']}/{repo['slug']}: {file.relative_to(path)}") + except Exception: + continue + body = f""" +

Search results for {esc(q)}

+
+
+

Repositories

{list_to_cards([f"{esc(r['owner_slug'])}/{esc(r['slug'])}" for r in results['repositories']])}
+

Issues

{list_to_cards([f"{item['title']}" for item in results['issues']])}
+

Pull requests

{list_to_cards([f"{item['title']}" for item in results['pull_requests']])}
+

Discussions

{list_to_cards([f"{item['title']}" for item in results['discussions']])}
+

Wiki pages

{list_to_cards([f"{item['title']}" for item in results['wiki_pages']])}
+

Code search

{list_to_cards(code_hits[:25])}
+
+ """ + return render_page("Search", body, user) + + @app.get("/notifications", response_class=HTMLResponse) + def notifications_page(request: Request) -> HTMLResponse: + user = current_user(request) + items = db.list_notifications(user["id"] if user else None) + body = f"

Notifications

{list_to_cards([f"{item['kind']} · {esc(item['message'])}" for item in items])}" + return render_page("Notifications", body, user) + + @app.get("/marketplace", response_class=HTMLResponse) + def marketplace_page(request: Request) -> HTMLResponse: + user = current_user(request) + apps = db.list_marketplace_apps() + body = f"

Marketplace

{''.join(f"

{esc(app['name'])}

{esc(app['description'])}

{esc(app['kind'])}
Install
" for app in apps)}
" + return render_page("Marketplace", body, user) + + @app.get("/sponsors", response_class=HTMLResponse) + def sponsors_page(request: Request) -> HTMLResponse: + user = current_user(request) + tiers = db.list_sponsorship_tiers(user["username"] if user else "") if user else [] + if user and not tiers: + db.create_sponsorship_tier(user["username"], "Supporter", 500, "Back the roadmap") + tiers = db.list_sponsorship_tiers(user["username"]) + body = f"

Sponsors

User and organization sponsorship tiers with payment-ready records.

{list_to_cards([f"{tier['name']} · ${tier['amount_cents']/100:.2f} · {esc(tier['perks'])}" for tier in tiers])}" + return render_page("Sponsors", body, user) + + @app.get("/admin", response_class=HTMLResponse) + def admin_page(request: Request) -> HTMLResponse: + user = current_user(request) + stats = db.stats() + audit = db.list_audit_logs() + body = f""" +

Admin dashboard

+
+

System health

+

Counts

{list_to_cards([f"{k}: {v}" for k, v in stats.items()])}
+

Users

{list_to_cards([esc(user_item['username']) for user_item in db.list_users()])}
+

Audit logs

{list_to_cards([f"{log['action']} → {esc(log['target'])}" for log in audit])}
+
+ """ + return render_page("Admin", body, user) + + @app.get("/api/repos") + def api_repos(request: Request) -> JSONResponse: + session_user = current_user(request) + token_user, token_record = current_token_bundle(request) + viewer = session_user or token_user + repos = [] + for repo in db.list_repos(): + if can_view_repo(viewer, repo) or token_allows_repo(token_record, repo): + repos.append(repo) + return JSONResponse(repos) + + @app.get("/api/repos/{owner_slug}/{repo_slug}") + def api_repo(request: Request, owner_slug: str, repo_slug: str) -> JSONResponse: + repo = db.get_repo(owner_slug, repo_slug) + if not repo: + raise HTTPException(404) + session_user = current_user(request) + token_user, token_record = current_token_bundle(request) + if not can_view_repo(session_user or token_user, repo) and not token_allows_repo(token_record, repo): + raise HTTPException(status_code=401, detail="Repository access denied") + return JSONResponse(repo) + + @app.get("/api/repos/{owner_slug}/{repo_slug}/webhooks/deliveries") + def api_repo_webhook_deliveries(request: Request, owner_slug: str, repo_slug: str) -> JSONResponse: + repo = db.get_repo(owner_slug, repo_slug) + if not repo: + raise HTTPException(404) + session_user = current_user(request) + token_user, token_record = current_token_bundle(request) + if not can_view_repo(session_user or token_user, repo) and not token_allows_repo(token_record, repo): + raise HTTPException(status_code=401, detail="Repository access denied") + return JSONResponse({"deliveries": db.list_webhook_deliveries(repo["id"])}) + + @app.get("/api/graphql") + def graphql_stub(owner: str = "", repo: str = "") -> JSONResponse: + target = db.get_repo(owner, repo) if owner and repo else None + return JSONResponse({"data": {"repository": target, "viewer": None}}) + + @app.post("/api/ai/suggest") + async def ai_suggest(request: Request) -> JSONResponse: + payload = await request.json() + code = payload.get("code", "") + suggestion = "# Suggested improvement\n" + code + ("\n# TODO: add tests" if "TODO" not in code else "") + return JSONResponse({"suggestion": suggestion, "provider": os.getenv("LLM_PROVIDER", "mock")}) + + @app.get("/api/webhooks") + def webhooks_info() -> JSONResponse: + return JSONResponse({"supported_events": ["push", "pull_request", "issues", "release", "discussion"], "filtering": True}) + + @app.get("/healthz") + def health() -> JSONResponse: + return JSONResponse({"status": "ok"}) + + return app + + +app = create_app() diff --git a/deliverable/gitvault/gitvault/database.py b/deliverable/gitvault/gitvault/database.py new file mode 100644 index 000000000..3dbfb727a --- /dev/null +++ b/deliverable/gitvault/gitvault/database.py @@ -0,0 +1,976 @@ +from __future__ import annotations + +import json +import sqlite3 +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + + +def utcnow() -> str: + return datetime.now(timezone.utc).isoformat() + + +SCHEMA = """ +PRAGMA foreign_keys = ON; + +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT UNIQUE NOT NULL, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + bio TEXT DEFAULT '', + location TEXT DEFAULT '', + social_links_json TEXT DEFAULT '[]', + created_at TEXT NOT NULL, + is_admin INTEGER DEFAULT 1, + achievements_json TEXT DEFAULT '["Founder"]' +); + +CREATE TABLE IF NOT EXISTS organizations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + slug TEXT UNIQUE NOT NULL, + description TEXT DEFAULT '', + owner_user_id INTEGER NOT NULL, + created_at TEXT NOT NULL, + FOREIGN KEY(owner_user_id) REFERENCES users(id) +); + +CREATE TABLE IF NOT EXISTS org_members ( + org_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + role TEXT NOT NULL, + PRIMARY KEY (org_id, user_id), + FOREIGN KEY(org_id) REFERENCES organizations(id) ON DELETE CASCADE, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS teams ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + org_id INTEGER NOT NULL, + name TEXT NOT NULL, + permission TEXT NOT NULL, + FOREIGN KEY(org_id) REFERENCES organizations(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS repositories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + owner_slug TEXT NOT NULL, + owner_type TEXT NOT NULL, + owner_id INTEGER NOT NULL, + name TEXT NOT NULL, + slug TEXT NOT NULL, + description TEXT DEFAULT '', + visibility TEXT NOT NULL, + archived INTEGER DEFAULT 0, + default_branch TEXT DEFAULT 'main', + topics_json TEXT DEFAULT '[]', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + stars_count INTEGER DEFAULT 0, + watchers_count INTEGER DEFAULT 0, + forks_count INTEGER DEFAULT 0, + transfer_target_slug TEXT DEFAULT '', + UNIQUE(owner_slug, slug) +); + +CREATE TABLE IF NOT EXISTS repo_collaborators ( + repo_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + permission TEXT NOT NULL, + PRIMARY KEY (repo_id, user_id), + FOREIGN KEY(repo_id) REFERENCES repositories(id) ON DELETE CASCADE, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS branch_rules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + repo_id INTEGER NOT NULL, + branch_name TEXT NOT NULL, + require_reviews INTEGER DEFAULT 0, + codeowners_required INTEGER DEFAULT 0, + required_status_checks INTEGER DEFAULT 0, + FOREIGN KEY(repo_id) REFERENCES repositories(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS releases ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + repo_id INTEGER NOT NULL, + tag_name TEXT NOT NULL, + title TEXT NOT NULL, + notes TEXT DEFAULT '', + prerelease INTEGER DEFAULT 0, + created_at TEXT NOT NULL, + FOREIGN KEY(repo_id) REFERENCES repositories(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS issues ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + repo_id INTEGER NOT NULL, + number INTEGER NOT NULL, + title TEXT NOT NULL, + body TEXT DEFAULT '', + state TEXT NOT NULL, + locked INTEGER DEFAULT 0, + author_id INTEGER NOT NULL, + labels_json TEXT DEFAULT '[]', + assignees_json TEXT DEFAULT '[]', + milestone TEXT DEFAULT '', + pinned INTEGER DEFAULT 0, + linked_issue_number INTEGER, + reactions_json TEXT DEFAULT '{}', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(repo_id, number), + FOREIGN KEY(repo_id) REFERENCES repositories(id) ON DELETE CASCADE, + FOREIGN KEY(author_id) REFERENCES users(id) +); + +CREATE TABLE IF NOT EXISTS pull_requests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + repo_id INTEGER NOT NULL, + number INTEGER NOT NULL, + title TEXT NOT NULL, + body TEXT DEFAULT '', + state TEXT NOT NULL, + draft INTEGER DEFAULT 0, + source_branch TEXT NOT NULL, + target_branch TEXT NOT NULL, + author_id INTEGER NOT NULL, + review_state TEXT DEFAULT 'COMMENTED', + reviewers_json TEXT DEFAULT '[]', + linked_issue_number INTEGER, + auto_merge INTEGER DEFAULT 0, + merge_strategy TEXT DEFAULT '', + merge_queue_position INTEGER DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + merged_at TEXT DEFAULT '', + UNIQUE(repo_id, number), + FOREIGN KEY(repo_id) REFERENCES repositories(id) ON DELETE CASCADE, + FOREIGN KEY(author_id) REFERENCES users(id) +); + +CREATE TABLE IF NOT EXISTS reviews ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + pr_id INTEGER NOT NULL, + author_id INTEGER NOT NULL, + state TEXT NOT NULL, + body TEXT DEFAULT '', + created_at TEXT NOT NULL, + FOREIGN KEY(pr_id) REFERENCES pull_requests(id) ON DELETE CASCADE, + FOREIGN KEY(author_id) REFERENCES users(id) +); + +CREATE TABLE IF NOT EXISTS review_comments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + pr_id INTEGER NOT NULL, + author_id INTEGER NOT NULL, + file_path TEXT NOT NULL, + line_number INTEGER DEFAULT 1, + body TEXT DEFAULT '', + suggested_change TEXT DEFAULT '', + created_at TEXT NOT NULL, + FOREIGN KEY(pr_id) REFERENCES pull_requests(id) ON DELETE CASCADE, + FOREIGN KEY(author_id) REFERENCES users(id) +); + +CREATE TABLE IF NOT EXISTS discussions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + repo_id INTEGER NOT NULL, + number INTEGER NOT NULL, + category TEXT NOT NULL, + title TEXT NOT NULL, + body TEXT DEFAULT '', + state TEXT DEFAULT 'open', + author_id INTEGER NOT NULL, + answer_comment_id INTEGER, + pinned INTEGER DEFAULT 0, + locked INTEGER DEFAULT 0, + reactions_json TEXT DEFAULT '{}', + created_at TEXT NOT NULL, + UNIQUE(repo_id, number), + FOREIGN KEY(repo_id) REFERENCES repositories(id) ON DELETE CASCADE, + FOREIGN KEY(author_id) REFERENCES users(id) +); + +CREATE TABLE IF NOT EXISTS discussion_comments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + discussion_id INTEGER NOT NULL, + author_id INTEGER NOT NULL, + body TEXT NOT NULL, + is_answer INTEGER DEFAULT 0, + created_at TEXT NOT NULL, + FOREIGN KEY(discussion_id) REFERENCES discussions(id) ON DELETE CASCADE, + FOREIGN KEY(author_id) REFERENCES users(id) +); + +CREATE TABLE IF NOT EXISTS projects ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + repo_id INTEGER NOT NULL, + name TEXT NOT NULL, + description TEXT DEFAULT '', + view_type TEXT DEFAULT 'board', + custom_fields_json TEXT DEFAULT '["Status","Owner","Due Date"]', + created_at TEXT NOT NULL, + FOREIGN KEY(repo_id) REFERENCES repositories(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS project_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id INTEGER NOT NULL, + item_type TEXT NOT NULL, + item_id INTEGER NOT NULL, + status TEXT DEFAULT 'Todo', + field_values_json TEXT DEFAULT '{}', + position INTEGER DEFAULT 0, + FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS wiki_pages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + repo_id INTEGER NOT NULL, + slug TEXT NOT NULL, + title TEXT NOT NULL, + content TEXT DEFAULT '', + sidebar TEXT DEFAULT '', + updated_by INTEGER NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(repo_id, slug), + FOREIGN KEY(repo_id) REFERENCES repositories(id) ON DELETE CASCADE, + FOREIGN KEY(updated_by) REFERENCES users(id) +); + +CREATE TABLE IF NOT EXISTS wiki_revisions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + page_id INTEGER NOT NULL, + content TEXT NOT NULL, + updated_by INTEGER NOT NULL, + created_at TEXT NOT NULL, + FOREIGN KEY(page_id) REFERENCES wiki_pages(id) ON DELETE CASCADE, + FOREIGN KEY(updated_by) REFERENCES users(id) +); + +CREATE TABLE IF NOT EXISTS action_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + repo_id INTEGER NOT NULL, + workflow_name TEXT NOT NULL, + event_name TEXT NOT NULL, + status TEXT NOT NULL, + logs TEXT DEFAULT '', + created_at TEXT NOT NULL, + FOREIGN KEY(repo_id) REFERENCES repositories(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS packages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + repo_id INTEGER NOT NULL, + ecosystem TEXT NOT NULL, + name TEXT NOT NULL, + version TEXT NOT NULL, + visibility TEXT DEFAULT 'private', + metadata_json TEXT DEFAULT '{}', + created_at TEXT NOT NULL, + FOREIGN KEY(repo_id) REFERENCES repositories(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS stars ( + user_id INTEGER NOT NULL, + repo_id INTEGER NOT NULL, + PRIMARY KEY (user_id, repo_id), + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY(repo_id) REFERENCES repositories(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS watches ( + user_id INTEGER NOT NULL, + repo_id INTEGER NOT NULL, + PRIMARY KEY (user_id, repo_id), + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY(repo_id) REFERENCES repositories(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS followers ( + follower_id INTEGER NOT NULL, + following_id INTEGER NOT NULL, + PRIMARY KEY (follower_id, following_id), + FOREIGN KEY(follower_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY(following_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS notifications ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + kind TEXT NOT NULL, + message TEXT NOT NULL, + url TEXT DEFAULT '', + is_read INTEGER DEFAULT 0, + created_at TEXT NOT NULL, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS user_security ( + user_id INTEGER PRIMARY KEY, + two_factor_enabled INTEGER DEFAULT 0, + totp_secret TEXT DEFAULT '', + required_2fa INTEGER DEFAULT 0, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS user_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + session_token TEXT UNIQUE NOT NULL, + user_agent TEXT DEFAULT '', + created_at TEXT NOT NULL, + last_seen_at TEXT NOT NULL, + revoked_at TEXT DEFAULT '', + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS api_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + name TEXT NOT NULL, + token_prefix TEXT NOT NULL, + token_hash TEXT NOT NULL, + scopes_json TEXT DEFAULT '[]', + repo_scope TEXT DEFAULT '', + created_at TEXT NOT NULL, + last_used_at TEXT DEFAULT '', + revoked_at TEXT DEFAULT '', + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS user_keys ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + key_type TEXT NOT NULL, + title TEXT NOT NULL, + public_key TEXT NOT NULL, + created_at TEXT NOT NULL, + revoked_at TEXT DEFAULT '', + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS repo_webhooks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + repo_id INTEGER NOT NULL, + target_url TEXT NOT NULL, + secret TEXT DEFAULT '', + events_json TEXT DEFAULT '[]', + active INTEGER DEFAULT 1, + created_at TEXT NOT NULL, + FOREIGN KEY(repo_id) REFERENCES repositories(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS webhook_deliveries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + webhook_id INTEGER NOT NULL, + event_name TEXT NOT NULL, + status TEXT NOT NULL, + payload_json TEXT DEFAULT '{}', + created_at TEXT NOT NULL, + FOREIGN KEY(webhook_id) REFERENCES repo_webhooks(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS sponsorship_tiers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + owner_slug TEXT NOT NULL, + name TEXT NOT NULL, + amount_cents INTEGER NOT NULL, + perks TEXT DEFAULT '', + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS sponsorships ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sponsor_user_id INTEGER NOT NULL, + tier_id INTEGER NOT NULL, + created_at TEXT NOT NULL, + FOREIGN KEY(sponsor_user_id) REFERENCES users(id), + FOREIGN KEY(tier_id) REFERENCES sponsorship_tiers(id) +); + +CREATE TABLE IF NOT EXISTS marketplace_apps ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + kind TEXT NOT NULL, + description TEXT NOT NULL, + install_url TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS repo_traffic ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + repo_id INTEGER NOT NULL, + event_type TEXT NOT NULL, + created_at TEXT NOT NULL, + FOREIGN KEY(repo_id) REFERENCES repositories(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS audit_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + actor_user_id INTEGER, + action TEXT NOT NULL, + target TEXT NOT NULL, + metadata_json TEXT DEFAULT '{}', + created_at TEXT NOT NULL, + FOREIGN KEY(actor_user_id) REFERENCES users(id) +); +""" + + +def row_to_dict(row: sqlite3.Row | None) -> dict[str, Any] | None: + return dict(row) if row is not None else None + + +def decode_record(record: dict[str, Any] | None) -> dict[str, Any] | None: + if record is None: + return None + for key, value in list(record.items()): + if key.endswith("_json") and isinstance(value, str): + record[key[:-5]] = json.loads(value or "null") + return record + + +@dataclass +class Database: + path: Path + + def __post_init__(self) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + with self.connect() as conn: + conn.executescript(SCHEMA) + existing = conn.execute("SELECT COUNT(*) FROM marketplace_apps").fetchone()[0] + if existing == 0: + conn.executemany( + "INSERT INTO marketplace_apps (name, kind, description, install_url) VALUES (?, ?, ?, ?)", + [ + ("Super Linter", "Action", "Run lint checks on every push.", "#"), + ("DeployBot", "GitHub App", "Push deployments with environment approvals.", "#"), + ("Acme OAuth", "OAuth App", "Single sign-on for enterprise teams.", "#"), + ], + ) + + def connect(self) -> sqlite3.Connection: + conn = sqlite3.connect(self.path, check_same_thread=False) + conn.row_factory = sqlite3.Row + return conn + + def fetchone(self, query: str, params: tuple[Any, ...] = ()) -> dict[str, Any] | None: + with self.connect() as conn: + return decode_record(row_to_dict(conn.execute(query, params).fetchone())) + + def fetchall(self, query: str, params: tuple[Any, ...] = ()) -> list[dict[str, Any]]: + with self.connect() as conn: + return [decode_record(row_to_dict(row)) for row in conn.execute(query, params).fetchall()] + + def execute(self, query: str, params: tuple[Any, ...] = ()) -> int: + with self.connect() as conn: + cur = conn.execute(query, params) + conn.commit() + return int(cur.lastrowid or 0) + + def log(self, actor_user_id: int | None, action: str, target: str, metadata: dict[str, Any] | None = None) -> None: + self.execute( + "INSERT INTO audit_logs (actor_user_id, action, target, metadata_json, created_at) VALUES (?, ?, ?, ?, ?)", + (actor_user_id, action, target, json.dumps(metadata or {}), utcnow()), + ) + + def create_user(self, email: str, username: str, password_hash: str) -> int: + user_id = self.execute( + "INSERT INTO users (email, username, password_hash, created_at) VALUES (?, ?, ?, ?)", + (email, username, password_hash, utcnow()), + ) + self.log(user_id, "user.create", username) + return user_id + + def get_user_by_email(self, email: str) -> dict[str, Any] | None: + return self.fetchone("SELECT * FROM users WHERE email = ?", (email,)) + + def get_user_by_username(self, username: str) -> dict[str, Any] | None: + return self.fetchone("SELECT * FROM users WHERE username = ?", (username,)) + + def get_user(self, user_id: int) -> dict[str, Any] | None: + return self.fetchone("SELECT * FROM users WHERE id = ?", (user_id,)) + + def list_users(self) -> list[dict[str, Any]]: + return self.fetchall("SELECT * FROM users ORDER BY created_at DESC") + + def create_org(self, owner_user_id: int, name: str, slug: str, description: str) -> int: + org_id = self.execute( + "INSERT INTO organizations (name, slug, description, owner_user_id, created_at) VALUES (?, ?, ?, ?, ?)", + (name, slug, description, owner_user_id, utcnow()), + ) + self.execute( + "INSERT INTO org_members (org_id, user_id, role) VALUES (?, ?, ?)", + (org_id, owner_user_id, "owner"), + ) + self.execute( + "INSERT INTO teams (org_id, name, permission) VALUES (?, ?, ?)", + (org_id, "core", "admin"), + ) + self.log(owner_user_id, "org.create", slug) + return org_id + + def get_org(self, slug: str) -> dict[str, Any] | None: + return self.fetchone("SELECT * FROM organizations WHERE slug = ?", (slug,)) + + def list_orgs_for_user(self, user_id: int) -> list[dict[str, Any]]: + return self.fetchall( + "SELECT o.* FROM organizations o JOIN org_members m ON m.org_id = o.id WHERE m.user_id = ? ORDER BY o.created_at DESC", + (user_id,), + ) + + def create_repo( + self, + owner_slug: str, + owner_type: str, + owner_id: int, + name: str, + description: str, + visibility: str, + topics: list[str], + ) -> int: + repo_id = self.execute( + """ + INSERT INTO repositories ( + owner_slug, owner_type, owner_id, name, slug, description, visibility, topics_json, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + (owner_slug, owner_type, owner_id, name, name, description, visibility, json.dumps(topics), utcnow(), utcnow()), + ) + self.execute( + "INSERT INTO branch_rules (repo_id, branch_name, require_reviews, codeowners_required, required_status_checks) VALUES (?, ?, ?, ?, ?)", + (repo_id, "main", 0, 1, 0), + ) + self.log(owner_id if owner_type == "user" else None, "repo.create", f"{owner_slug}/{name}") + return repo_id + + def get_repo(self, owner_slug: str, slug: str) -> dict[str, Any] | None: + return self.fetchone("SELECT * FROM repositories WHERE owner_slug = ? AND slug = ?", (owner_slug, slug)) + + def get_repo_by_id(self, repo_id: int) -> dict[str, Any] | None: + return self.fetchone("SELECT * FROM repositories WHERE id = ?", (repo_id,)) + + def list_repos(self, visibility: str | None = None) -> list[dict[str, Any]]: + if visibility: + return self.fetchall("SELECT * FROM repositories WHERE visibility = ? ORDER BY updated_at DESC", (visibility,)) + return self.fetchall("SELECT * FROM repositories ORDER BY updated_at DESC") + + def list_owner_repos(self, owner_slug: str) -> list[dict[str, Any]]: + return self.fetchall("SELECT * FROM repositories WHERE owner_slug = ? ORDER BY updated_at DESC", (owner_slug,)) + + def update_repo_state(self, repo_id: int, *, archived: int | None = None, visibility: str | None = None) -> None: + repo = self.get_repo_by_id(repo_id) + archived_val = repo["archived"] if archived is None else archived + visibility_val = repo["visibility"] if visibility is None else visibility + self.execute( + "UPDATE repositories SET archived = ?, visibility = ?, updated_at = ? WHERE id = ?", + (archived_val, visibility_val, utcnow(), repo_id), + ) + + def delete_repo(self, repo_id: int) -> None: + self.execute("DELETE FROM repositories WHERE id = ?", (repo_id,)) + + def increment_counter(self, table: str, column: str, record_id: int, delta: int) -> None: + self.execute(f"UPDATE {table} SET {column} = {column} + ?, updated_at = ? WHERE id = ?", (delta, utcnow(), record_id)) + + def add_notification(self, user_id: int, kind: str, message: str, url: str = "") -> None: + self.execute( + "INSERT INTO notifications (user_id, kind, message, url, created_at) VALUES (?, ?, ?, ?, ?)", + (user_id, kind, message, url, utcnow()), + ) + + def list_notifications(self, user_id: int | None = None) -> list[dict[str, Any]]: + if user_id is None: + return self.fetchall("SELECT * FROM notifications ORDER BY created_at DESC") + return self.fetchall("SELECT * FROM notifications WHERE user_id = ? ORDER BY created_at DESC", (user_id,)) + + def get_user_security(self, user_id: int) -> dict[str, Any]: + record = self.fetchone("SELECT * FROM user_security WHERE user_id = ?", (user_id,)) + if record: + return record + self.execute("INSERT INTO user_security (user_id) VALUES (?)", (user_id,)) + return self.fetchone("SELECT * FROM user_security WHERE user_id = ?", (user_id,)) + + def enable_totp(self, user_id: int, secret: str) -> None: + self.execute( + "INSERT INTO user_security (user_id, two_factor_enabled, totp_secret, required_2fa) VALUES (?, 1, ?, 1) " + "ON CONFLICT(user_id) DO UPDATE SET two_factor_enabled = 1, totp_secret = excluded.totp_secret, required_2fa = 1", + (user_id, secret), + ) + + def create_user_session(self, user_id: int, session_token: str, user_agent: str = "") -> int: + return self.execute( + "INSERT INTO user_sessions (user_id, session_token, user_agent, created_at, last_seen_at) VALUES (?, ?, ?, ?, ?)", + (user_id, session_token, user_agent, utcnow(), utcnow()), + ) + + def get_user_session(self, session_token: str) -> dict[str, Any] | None: + return self.fetchone("SELECT * FROM user_sessions WHERE session_token = ?", (session_token,)) + + def list_user_sessions(self, user_id: int) -> list[dict[str, Any]]: + return self.fetchall( + "SELECT * FROM user_sessions WHERE user_id = ? AND revoked_at = '' ORDER BY last_seen_at DESC", + (user_id,), + ) + + def touch_user_session(self, session_token: str) -> None: + self.execute("UPDATE user_sessions SET last_seen_at = ? WHERE session_token = ?", (utcnow(), session_token)) + + def revoke_user_session(self, session_token: str) -> None: + self.execute("UPDATE user_sessions SET revoked_at = ? WHERE session_token = ?", (utcnow(), session_token)) + + def create_api_token( + self, + user_id: int, + name: str, + token_prefix: str, + token_hash: str, + scopes: list[str], + repo_scope: str = "", + ) -> int: + return self.execute( + """ + INSERT INTO api_tokens (user_id, name, token_prefix, token_hash, scopes_json, repo_scope, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + (user_id, name, token_prefix, token_hash, json.dumps(scopes), repo_scope, utcnow()), + ) + + def list_api_tokens(self, user_id: int) -> list[dict[str, Any]]: + return self.fetchall( + "SELECT * FROM api_tokens WHERE user_id = ? AND revoked_at = '' ORDER BY created_at DESC", + (user_id,), + ) + + def get_api_token_by_hash(self, token_hash: str) -> dict[str, Any] | None: + return self.fetchone( + "SELECT * FROM api_tokens WHERE token_hash = ? AND revoked_at = ''", + (token_hash,), + ) + + def touch_api_token(self, token_id: int) -> None: + self.execute("UPDATE api_tokens SET last_used_at = ? WHERE id = ?", (utcnow(), token_id)) + + def revoke_api_token(self, token_id: int) -> None: + self.execute("UPDATE api_tokens SET revoked_at = ? WHERE id = ?", (utcnow(), token_id)) + + def create_user_key(self, user_id: int, key_type: str, title: str, public_key: str) -> int: + return self.execute( + "INSERT INTO user_keys (user_id, key_type, title, public_key, created_at) VALUES (?, ?, ?, ?, ?)", + (user_id, key_type, title, public_key, utcnow()), + ) + + def list_user_keys(self, user_id: int, key_type: str | None = None) -> list[dict[str, Any]]: + if key_type: + return self.fetchall( + "SELECT * FROM user_keys WHERE user_id = ? AND key_type = ? AND revoked_at = '' ORDER BY created_at DESC", + (user_id, key_type), + ) + return self.fetchall( + "SELECT * FROM user_keys WHERE user_id = ? AND revoked_at = '' ORDER BY created_at DESC", + (user_id,), + ) + + def create_repo_webhook(self, repo_id: int, target_url: str, secret: str, events: list[str]) -> int: + return self.execute( + "INSERT INTO repo_webhooks (repo_id, target_url, secret, events_json, created_at) VALUES (?, ?, ?, ?, ?)", + (repo_id, target_url, secret, json.dumps(events), utcnow()), + ) + + def list_repo_webhooks(self, repo_id: int) -> list[dict[str, Any]]: + return self.fetchall( + "SELECT * FROM repo_webhooks WHERE repo_id = ? AND active = 1 ORDER BY created_at DESC", + (repo_id,), + ) + + def create_webhook_delivery(self, webhook_id: int, event_name: str, status: str, payload: dict[str, Any]) -> int: + return self.execute( + "INSERT INTO webhook_deliveries (webhook_id, event_name, status, payload_json, created_at) VALUES (?, ?, ?, ?, ?)", + (webhook_id, event_name, status, json.dumps(payload), utcnow()), + ) + + def list_webhook_deliveries(self, repo_id: int) -> list[dict[str, Any]]: + return self.fetchall( + """ + SELECT d.*, h.target_url + FROM webhook_deliveries d + JOIN repo_webhooks h ON h.id = d.webhook_id + WHERE h.repo_id = ? + ORDER BY d.created_at DESC + """, + (repo_id,), + ) + + def create_issue(self, repo_id: int, author_id: int, title: str, body: str, labels: list[str]) -> int: + number = (self.fetchone("SELECT COALESCE(MAX(number), 0) AS value FROM issues WHERE repo_id = ?", (repo_id,)) or {"value": 0})["value"] + 1 + issue_id = self.execute( + """ + INSERT INTO issues (repo_id, number, title, body, state, author_id, labels_json, created_at, updated_at) + VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?) + """, + (repo_id, number, title, body, author_id, json.dumps(labels), utcnow(), utcnow()), + ) + return issue_id + + def get_issue(self, repo_id: int, number: int) -> dict[str, Any] | None: + return self.fetchone("SELECT * FROM issues WHERE repo_id = ? AND number = ?", (repo_id, number)) + + def list_issues(self, repo_id: int) -> list[dict[str, Any]]: + return self.fetchall("SELECT * FROM issues WHERE repo_id = ? ORDER BY pinned DESC, number DESC", (repo_id,)) + + def create_pull_request( + self, + repo_id: int, + author_id: int, + title: str, + body: str, + source_branch: str, + target_branch: str, + linked_issue_number: int | None, + draft: bool, + ) -> int: + number = (self.fetchone("SELECT COALESCE(MAX(number), 0) AS value FROM pull_requests WHERE repo_id = ?", (repo_id,)) or {"value": 0})["value"] + 1 + pr_id = self.execute( + """ + INSERT INTO pull_requests ( + repo_id, number, title, body, state, draft, source_branch, target_branch, author_id, linked_issue_number, + created_at, updated_at + ) VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?) + """, + (repo_id, number, title, body, int(draft), source_branch, target_branch, author_id, linked_issue_number, utcnow(), utcnow()), + ) + return pr_id + + def get_pr(self, repo_id: int, number: int) -> dict[str, Any] | None: + return self.fetchone("SELECT * FROM pull_requests WHERE repo_id = ? AND number = ?", (repo_id, number)) + + def list_prs(self, repo_id: int) -> list[dict[str, Any]]: + return self.fetchall("SELECT * FROM pull_requests WHERE repo_id = ? ORDER BY number DESC", (repo_id,)) + + def get_reviews(self, pr_id: int) -> list[dict[str, Any]]: + return self.fetchall("SELECT * FROM reviews WHERE pr_id = ? ORDER BY created_at DESC", (pr_id,)) + + def add_review(self, pr_id: int, author_id: int, state: str, body: str) -> int: + review_id = self.execute( + "INSERT INTO reviews (pr_id, author_id, state, body, created_at) VALUES (?, ?, ?, ?, ?)", + (pr_id, author_id, state, body, utcnow()), + ) + self.execute( + "UPDATE pull_requests SET review_state = ?, updated_at = ? WHERE id = ?", + (state, utcnow(), pr_id), + ) + return review_id + + def set_pr_reviewers(self, pr_id: int, reviewers: list[str]) -> None: + self.execute( + "UPDATE pull_requests SET reviewers_json = ?, updated_at = ? WHERE id = ?", + (json.dumps(sorted(set(reviewers))), utcnow(), pr_id), + ) + + def set_pr_auto_merge(self, pr_id: int, enabled: bool, strategy: str, queue_position: int = 0) -> None: + self.execute( + "UPDATE pull_requests SET auto_merge = ?, merge_strategy = ?, merge_queue_position = ?, updated_at = ? WHERE id = ?", + (int(enabled), strategy, queue_position, utcnow(), pr_id), + ) + + def next_merge_queue_position(self, repo_id: int) -> int: + row = self.fetchone( + "SELECT COALESCE(MAX(merge_queue_position), 0) AS value FROM pull_requests WHERE repo_id = ?", + (repo_id,), + ) + return int((row or {"value": 0})["value"]) + 1 + + def list_auto_merge_prs(self, repo_id: int) -> list[dict[str, Any]]: + return self.fetchall( + """ + SELECT * FROM pull_requests + WHERE repo_id = ? AND state = 'open' AND auto_merge = 1 + ORDER BY merge_queue_position ASC, number ASC + """, + (repo_id,), + ) + + def add_review_comment( + self, + pr_id: int, + author_id: int, + file_path: str, + line_number: int, + body: str, + suggested_change: str = "", + ) -> int: + return self.execute( + """ + INSERT INTO review_comments (pr_id, author_id, file_path, line_number, body, suggested_change, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + (pr_id, author_id, file_path, line_number, body, suggested_change, utcnow()), + ) + + def list_review_comments(self, pr_id: int) -> list[dict[str, Any]]: + return self.fetchall( + """ + SELECT c.*, u.username AS author_username + FROM review_comments c + JOIN users u ON u.id = c.author_id + WHERE c.pr_id = ? + ORDER BY c.created_at ASC + """, + (pr_id,), + ) + + def merge_pr(self, pr_id: int, strategy: str) -> None: + self.execute( + """ + UPDATE pull_requests + SET state = 'merged', merge_strategy = ?, merged_at = ?, updated_at = ?, auto_merge = 0, merge_queue_position = 0 + WHERE id = ? + """, + (strategy, utcnow(), utcnow(), pr_id), + ) + + def list_branch_rules(self, repo_id: int) -> list[dict[str, Any]]: + return self.fetchall("SELECT * FROM branch_rules WHERE repo_id = ? ORDER BY branch_name", (repo_id,)) + + def get_branch_rule(self, repo_id: int, branch_name: str) -> dict[str, Any] | None: + return self.fetchone("SELECT * FROM branch_rules WHERE repo_id = ? AND branch_name = ?", (repo_id, branch_name)) + + def create_release(self, repo_id: int, tag_name: str, title: str, notes: str, prerelease: bool) -> int: + return self.execute( + "INSERT INTO releases (repo_id, tag_name, title, notes, prerelease, created_at) VALUES (?, ?, ?, ?, ?, ?)", + (repo_id, tag_name, title, notes, int(prerelease), utcnow()), + ) + + def list_releases(self, repo_id: int) -> list[dict[str, Any]]: + return self.fetchall("SELECT * FROM releases WHERE repo_id = ? ORDER BY created_at DESC", (repo_id,)) + + def create_discussion(self, repo_id: int, author_id: int, category: str, title: str, body: str) -> int: + number = (self.fetchone("SELECT COALESCE(MAX(number), 0) AS value FROM discussions WHERE repo_id = ?", (repo_id,)) or {"value": 0})["value"] + 1 + return self.execute( + "INSERT INTO discussions (repo_id, number, category, title, body, author_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + (repo_id, number, category, title, body, author_id, utcnow()), + ) + + def list_discussions(self, repo_id: int) -> list[dict[str, Any]]: + return self.fetchall("SELECT * FROM discussions WHERE repo_id = ? ORDER BY pinned DESC, number DESC", (repo_id,)) + + def create_project(self, repo_id: int, name: str, description: str, view_type: str) -> int: + return self.execute( + "INSERT INTO projects (repo_id, name, description, view_type, created_at) VALUES (?, ?, ?, ?, ?)", + (repo_id, name, description, view_type, utcnow()), + ) + + def list_projects(self, repo_id: int) -> list[dict[str, Any]]: + return self.fetchall("SELECT * FROM projects WHERE repo_id = ? ORDER BY created_at DESC", (repo_id,)) + + def create_wiki_page(self, repo_id: int, title: str, content: str, sidebar: str, updated_by: int) -> int: + slug = title.strip().lower().replace(" ", "-") + page_id = self.execute( + "INSERT INTO wiki_pages (repo_id, slug, title, content, sidebar, updated_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + (repo_id, slug, title, content, sidebar, updated_by, utcnow(), utcnow()), + ) + self.execute( + "INSERT INTO wiki_revisions (page_id, content, updated_by, created_at) VALUES (?, ?, ?, ?)", + (page_id, content, updated_by, utcnow()), + ) + return page_id + + def list_wiki_pages(self, repo_id: int) -> list[dict[str, Any]]: + return self.fetchall("SELECT * FROM wiki_pages WHERE repo_id = ? ORDER BY updated_at DESC", (repo_id,)) + + def get_wiki_page(self, repo_id: int, slug: str) -> dict[str, Any] | None: + return self.fetchone("SELECT * FROM wiki_pages WHERE repo_id = ? AND slug = ?", (repo_id, slug)) + + def get_wiki_revisions(self, page_id: int) -> list[dict[str, Any]]: + return self.fetchall("SELECT * FROM wiki_revisions WHERE page_id = ? ORDER BY created_at DESC", (page_id,)) + + def create_action_run(self, repo_id: int, workflow_name: str, event_name: str, status: str, logs: str) -> int: + return self.execute( + "INSERT INTO action_runs (repo_id, workflow_name, event_name, status, logs, created_at) VALUES (?, ?, ?, ?, ?, ?)", + (repo_id, workflow_name, event_name, status, logs, utcnow()), + ) + + def list_action_runs(self, repo_id: int) -> list[dict[str, Any]]: + return self.fetchall("SELECT * FROM action_runs WHERE repo_id = ? ORDER BY created_at DESC", (repo_id,)) + + def upsert_package(self, repo_id: int, ecosystem: str, name: str, version: str, visibility: str, metadata: dict[str, Any]) -> int: + existing = self.fetchone( + "SELECT * FROM packages WHERE repo_id = ? AND ecosystem = ? AND name = ? AND version = ?", + (repo_id, ecosystem, name, version), + ) + if existing: + return existing["id"] + return self.execute( + "INSERT INTO packages (repo_id, ecosystem, name, version, visibility, metadata_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + (repo_id, ecosystem, name, version, visibility, json.dumps(metadata), utcnow()), + ) + + def list_packages(self, repo_id: int) -> list[dict[str, Any]]: + return self.fetchall("SELECT * FROM packages WHERE repo_id = ? ORDER BY created_at DESC", (repo_id,)) + + def record_traffic(self, repo_id: int, event_type: str) -> None: + self.execute("INSERT INTO repo_traffic (repo_id, event_type, created_at) VALUES (?, ?, ?)", (repo_id, event_type, utcnow())) + + def get_traffic(self, repo_id: int) -> list[dict[str, Any]]: + return self.fetchall( + "SELECT event_type, COUNT(*) AS count FROM repo_traffic WHERE repo_id = ? GROUP BY event_type ORDER BY count DESC", + (repo_id,), + ) + + def search(self, query: str) -> dict[str, list[dict[str, Any]]]: + like = f"%{query}%" + return { + "repositories": self.fetchall( + "SELECT * FROM repositories WHERE name LIKE ? OR description LIKE ? ORDER BY updated_at DESC", + (like, like), + ), + "issues": self.fetchall( + "SELECT * FROM issues WHERE title LIKE ? OR body LIKE ? ORDER BY updated_at DESC", + (like, like), + ), + "pull_requests": self.fetchall( + "SELECT * FROM pull_requests WHERE title LIKE ? OR body LIKE ? ORDER BY updated_at DESC", + (like, like), + ), + "discussions": self.fetchall( + "SELECT * FROM discussions WHERE title LIKE ? OR body LIKE ? ORDER BY created_at DESC", + (like, like), + ), + "wiki_pages": self.fetchall( + "SELECT * FROM wiki_pages WHERE title LIKE ? OR content LIKE ? ORDER BY updated_at DESC", + (like, like), + ), + } + + def stats(self) -> dict[str, int]: + return { + "users": self.fetchone("SELECT COUNT(*) AS count FROM users")["count"], + "organizations": self.fetchone("SELECT COUNT(*) AS count FROM organizations")["count"], + "repositories": self.fetchone("SELECT COUNT(*) AS count FROM repositories")["count"], + "issues": self.fetchone("SELECT COUNT(*) AS count FROM issues")["count"], + "pull_requests": self.fetchone("SELECT COUNT(*) AS count FROM pull_requests")["count"], + "actions": self.fetchone("SELECT COUNT(*) AS count FROM action_runs")["count"], + "tokens": self.fetchone("SELECT COUNT(*) AS count FROM api_tokens")["count"], + "webhooks": self.fetchone("SELECT COUNT(*) AS count FROM repo_webhooks")["count"], + } + + def list_marketplace_apps(self) -> list[dict[str, Any]]: + return self.fetchall("SELECT * FROM marketplace_apps ORDER BY kind, name") + + def create_sponsorship_tier(self, owner_slug: str, name: str, amount_cents: int, perks: str) -> int: + return self.execute( + "INSERT INTO sponsorship_tiers (owner_slug, name, amount_cents, perks, created_at) VALUES (?, ?, ?, ?, ?)", + (owner_slug, name, amount_cents, perks, utcnow()), + ) + + def list_sponsorship_tiers(self, owner_slug: str) -> list[dict[str, Any]]: + return self.fetchall("SELECT * FROM sponsorship_tiers WHERE owner_slug = ? ORDER BY amount_cents", (owner_slug,)) + + def list_audit_logs(self) -> list[dict[str, Any]]: + return self.fetchall("SELECT * FROM audit_logs ORDER BY created_at DESC LIMIT 50") diff --git a/deliverable/gitvault/gitvault/gitops.py b/deliverable/gitvault/gitvault/gitops.py new file mode 100644 index 000000000..3bf94444e --- /dev/null +++ b/deliverable/gitvault/gitvault/gitops.py @@ -0,0 +1,264 @@ +from __future__ import annotations + +import os +import shutil +import subprocess +import tempfile +import zipfile +from collections import Counter +from fnmatch import fnmatch +from pathlib import Path +from typing import Any + +import yaml + +README_TEMPLATES = { + "python": "# {name}\n\nA Python project hosted on GitVault.\n", + "default": "# {name}\n\nBuilt with GitVault.\n", +} + +LICENSE_TEMPLATES = { + "mit": "MIT License\n\nCopyright (c) {year} {owner}\n", + "apache-2.0": "Apache License 2.0\n", +} + +GITIGNORE_TEMPLATES = { + "python": "__pycache__/\n.venv/\n*.pyc\n", + "node": "node_modules/\ndist/\n", + "default": ".DS_Store\n", +} + + +def run_git(repo_path: Path, *args: str, check: bool = True) -> str: + result = subprocess.run( + ["git", *args], + cwd=repo_path, + check=check, + capture_output=True, + text=True, + ) + return result.stdout.strip() + + +class GitService: + def __init__(self, repos_dir: Path): + self.repos_dir = repos_dir + self.repos_dir.mkdir(parents=True, exist_ok=True) + + def repo_path(self, owner_slug: str, repo_slug: str) -> Path: + return self.repos_dir / owner_slug / repo_slug + + def init_repo( + self, + owner_slug: str, + repo_name: str, + description: str, + readme_template: str, + license_template: str, + gitignore_template: str, + ) -> Path: + path = self.repo_path(owner_slug, repo_name) + path.parent.mkdir(parents=True, exist_ok=True) + path.mkdir(parents=True, exist_ok=True) + run_git(path, "init", "-b", "main") + (path / ".github").mkdir(exist_ok=True) + (path / ".github" / "PULL_REQUEST_TEMPLATE.md").write_text("## Summary\n\n- Describe your change\n", encoding="utf-8") + (path / "CODEOWNERS").write_text("* @maintainers\n", encoding="utf-8") + readme = README_TEMPLATES.get(readme_template, README_TEMPLATES["default"]).format(name=repo_name) + (path / "README.md").write_text(readme + f"\n{description}\n", encoding="utf-8") + (path / ".gitignore").write_text(GITIGNORE_TEMPLATES.get(gitignore_template, GITIGNORE_TEMPLATES["default"]), encoding="utf-8") + if license_template: + (path / "LICENSE").write_text(LICENSE_TEMPLATES.get(license_template, LICENSE_TEMPLATES["mit"]).format(year=2026, owner=owner_slug), encoding="utf-8") + self._commit_all(path, "Initial scaffold commit") + return path + + def _commit_all(self, path: Path, message: str, author_name: str = "GitVault", author_email: str = "system@gitvault.local") -> None: + run_git(path, "add", ".") + subprocess.run( + ["git", "-c", f"user.name={author_name}", "-c", f"user.email={author_email}", "commit", "-m", message], + cwd=path, + check=True, + capture_output=True, + text=True, + ) + + def create_branch(self, path: Path, branch_name: str, from_ref: str = "main") -> None: + run_git(path, "branch", branch_name, from_ref) + + def list_branches(self, path: Path) -> list[str]: + return [line.strip().lstrip("*").strip() for line in run_git(path, "branch", "--list").splitlines() if line.strip()] + + def list_tags(self, path: Path) -> list[str]: + output = run_git(path, "tag", "--list") + return [line.strip() for line in output.splitlines() if line.strip()] + + def create_tag(self, path: Path, tag_name: str, message: str) -> None: + run_git(path, "tag", "-a", tag_name, "-m", message) + + def get_tree(self, path: Path, ref: str, subpath: str = "") -> list[dict[str, str]]: + target = f"{ref}:{subpath}" if subpath else ref + output = run_git(path, "ls-tree", target) + entries = [] + for line in output.splitlines(): + meta, name = line.split("\t", 1) + _, kind, sha = meta.split() + entries.append({"kind": kind, "sha": sha, "name": name}) + return entries + + def get_file(self, path: Path, ref: str, file_path: str) -> str: + return run_git(path, "show", f"{ref}:{file_path}") + + def commit_file(self, path: Path, branch: str, file_path: str, content: str, message: str, author_name: str, author_email: str) -> None: + run_git(path, "checkout", branch) + target = path / file_path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(content, encoding="utf-8") + self._commit_all(path, message, author_name=author_name, author_email=author_email) + run_git(path, "checkout", "main") + + def history_for_path(self, path: Path, ref: str, file_path: str) -> list[dict[str, str]]: + output = run_git(path, "log", "--pretty=format:%H|%an|%ad|%s", ref, "--", file_path) + items = [] + for line in output.splitlines(): + sha, author, date, subject = line.split("|", 3) + items.append({"sha": sha, "author": author, "date": date, "subject": subject}) + return items + + def blame(self, path: Path, ref: str, file_path: str) -> list[str]: + output = run_git(path, "blame", ref, "--", file_path) + return output.splitlines() + + def commit_history(self, path: Path, limit: int = 30) -> list[dict[str, str]]: + output = run_git(path, "log", f"--max-count={limit}", "--pretty=format:%H|%an|%ad|%s") + items = [] + for line in output.splitlines(): + sha, author, date, subject = line.split("|", 3) + items.append({"sha": sha, "author": author, "date": date, "subject": subject}) + return items + + def compare(self, path: Path, base: str, head: str) -> str: + return run_git(path, "diff", f"{base}..{head}") + + def changed_files(self, path: Path, base: str, head: str) -> list[str]: + output = run_git(path, "diff", "--name-only", f"{base}..{head}") + return [line.strip() for line in output.splitlines() if line.strip()] + + def required_codeowners(self, path: Path, base: str, head: str, codeowners_ref: str = "main") -> set[str]: + try: + codeowners = self.get_file(path, codeowners_ref, "CODEOWNERS") + except subprocess.CalledProcessError: + return set() + changed = self.changed_files(path, base, head) + owners: set[str] = set() + rules: list[tuple[str, list[str]]] = [] + for raw_line in codeowners.splitlines(): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + parts = line.split() + if len(parts) < 2: + continue + pattern = parts[0] + rule_owners = [part.lstrip("@") for part in parts[1:] if part.startswith("@")] + rules.append((pattern, rule_owners)) + for file_path in changed: + matched: list[str] = [] + for pattern, rule_owners in rules: + normalized = pattern.lstrip("/") + if pattern == "*" or fnmatch(file_path, normalized) or fnmatch(Path(file_path).name, normalized): + matched = rule_owners + owners.update(matched) + return owners + + def merge_conflicts(self, path: Path, source: str, target: str) -> bool: + with tempfile.TemporaryDirectory() as tmpdir: + clone = Path(tmpdir) / "clone" + subprocess.run(["git", "clone", "--no-single-branch", str(path), str(clone)], check=True, capture_output=True, text=True) + subprocess.run(["git", "checkout", target], cwd=clone, check=True, capture_output=True, text=True) + verify = subprocess.run(["git", "rev-parse", "--verify", source], cwd=clone, capture_output=True, text=True) + source_ref = source if verify.returncode == 0 else f"origin/{source}" + result = subprocess.run(["git", "merge", "--no-commit", "--no-ff", source_ref], cwd=clone, capture_output=True, text=True) + return result.returncode != 0 + + def merge_pr(self, path: Path, source: str, target: str, strategy: str) -> None: + run_git(path, "checkout", target) + if strategy == "squash": + run_git(path, "merge", "--squash", source) + self._commit_all(path, f"Squash merge {source} into {target}") + elif strategy == "rebase": + run_git(path, "checkout", source) + run_git(path, "rebase", target) + run_git(path, "checkout", target) + run_git(path, "merge", "--ff-only", source) + else: + run_git(path, "merge", "--no-ff", source, "-m", f"Merge branch '{source}' into {target}") + run_git(path, "checkout", "main") + + def archive_zip(self, path: Path, destination: Path) -> Path: + destination.parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory() as tmpdir: + export = Path(tmpdir) / "export" + shutil.copytree(path, export, ignore=shutil.ignore_patterns(".git")) + with zipfile.ZipFile(destination, "w", zipfile.ZIP_DEFLATED) as zf: + for file in export.rglob("*"): + if file.is_file(): + zf.write(file, arcname=file.relative_to(export)) + return destination + + def fork_repo(self, source: Path, destination: Path) -> None: + destination.parent.mkdir(parents=True, exist_ok=True) + subprocess.run(["git", "clone", str(source), str(destination)], check=True, capture_output=True, text=True) + + def delete_repo(self, path: Path) -> None: + shutil.rmtree(path, ignore_errors=True) + + def insights(self, path: Path) -> dict[str, Any]: + languages = Counter() + dependencies: list[str] = [] + files = [] + for root, _, filenames in os.walk(path): + if ".git" in root.split(os.sep): + continue + for name in filenames: + file = Path(root) / name + rel = file.relative_to(path) + files.append(str(rel)) + suffix = file.suffix.lower() or "[no extension]" + languages[suffix] += len(file.read_text(encoding="utf-8", errors="ignore")) + if name in {"package.json", "requirements.txt", "pyproject.toml", "pom.xml"}: + dependencies.append(str(rel)) + contributors_output = run_git(path, "shortlog", "-sne", "HEAD") + contributors = [line.strip() for line in contributors_output.splitlines() if line.strip()] + return { + "languages": dict(languages.most_common()), + "dependencies": dependencies, + "contributors": contributors, + "files": files, + } + + def parse_workflows(self, path: Path) -> list[dict[str, Any]]: + workflows_dir = path / ".github" / "workflows" + if not workflows_dir.exists(): + return [] + workflows = [] + for file in workflows_dir.glob("*.y*ml"): + data = yaml.safe_load(file.read_text(encoding="utf-8")) or {} + workflows.append( + { + "name": data.get("name", file.stem), + "on": data.get("on", {}), + "jobs": list((data.get("jobs") or {}).keys()), + "steps": [ + step.get("run", step.get("uses", "step")) + for job in (data.get("jobs") or {}).values() + for step in (job.get("steps") or []) + ], + } + ) + return workflows + + def pages_content(self, path: Path) -> str: + for candidate in [path / "docs" / "index.md", path / "docs" / "index.html", path / "README.md"]: + if candidate.exists(): + return candidate.read_text(encoding="utf-8", errors="ignore") + return "No Pages site configured yet." diff --git a/deliverable/gitvault/gitvault/static/app.js b/deliverable/gitvault/gitvault/static/app.js new file mode 100644 index 000000000..30f1a08bc --- /dev/null +++ b/deliverable/gitvault/gitvault/static/app.js @@ -0,0 +1,9 @@ +document.addEventListener('keydown', (event) => { + if (event.key === '/' && !['INPUT', 'TEXTAREA'].includes(document.activeElement?.tagName)) { + const search = document.querySelector('input[name="q"]'); + if (search) { + event.preventDefault(); + search.focus(); + } + } +}); diff --git a/deliverable/gitvault/gitvault/static/style.css b/deliverable/gitvault/gitvault/static/style.css new file mode 100644 index 000000000..74c8125ef --- /dev/null +++ b/deliverable/gitvault/gitvault/static/style.css @@ -0,0 +1,50 @@ +:root { + color-scheme: dark light; + --bg: #0d1117; + --panel: #161b22; + --soft: #21262d; + --text: #e6edf3; + --muted: #8b949e; + --accent: #2f81f7; + --danger: #f85149; +} +* { box-sizing: border-box; } +body { margin: 0; font-family: Inter, system-ui, sans-serif; background: var(--bg); color: var(--text); } +a { color: var(--accent); text-decoration: none; } +a:hover { text-decoration: underline; } +.topbar { position: sticky; top: 0; display: flex; justify-content: space-between; gap: 1rem; padding: 1rem 1.25rem; background: rgba(13,17,23,.95); border-bottom: 1px solid #30363d; backdrop-filter: blur(10px); z-index: 20; } +.topnav { display: flex; flex-wrap: wrap; gap: .85rem; align-items: center; } +.brand a { color: white; font-weight: 800; font-size: 1.2rem; } +.container { max-width: 1240px; margin: 0 auto; padding: 1.5rem; } +.hero, .panel, .card { background: var(--panel); border: 1px solid #30363d; border-radius: 16px; } +.hero { padding: 1.5rem; display: flex; justify-content: space-between; gap: 1rem; align-items: flex-start; margin-bottom: 1rem; } +.hero-home { min-height: 280px; } +.panel, .card { padding: 1rem 1.15rem; } +.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 1rem; } +.two-up { grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); } +.stack { display: grid; gap: .9rem; } +.split { display: grid; grid-template-columns: 1.1fr .9fr; gap: 1rem; } +label { display: grid; gap: .35rem; font-weight: 600; } +input, textarea, select, button { font: inherit; } +input, textarea, select { width: 100%; padding: .8rem .9rem; background: #0d1117; border: 1px solid #30363d; color: var(--text); border-radius: 10px; } +textarea { min-height: 120px; } +button, .button { display: inline-flex; align-items: center; justify-content: center; gap: .35rem; border-radius: 10px; border: 1px solid #30363d; background: var(--soft); color: var(--text); padding: .7rem 1rem; cursor: pointer; text-decoration: none; } +button.primary, .button.primary { background: var(--accent); color: white; border-color: transparent; } +button.danger, .button.danger { background: var(--danger); color: white; border-color: transparent; } +.repo-tabs, .toolbar, .toolbar-actions, .cta-row, .meta-badges, .subactions { display: flex; gap: .75rem; flex-wrap: wrap; align-items: center; } +.repo-tabs { margin-bottom: 1rem; border-bottom: 1px solid #30363d; padding-bottom: .75rem; } +.repo-tabs a { color: var(--muted); padding-bottom: .45rem; border-bottom: 2px solid transparent; } +.repo-tabs a.active { color: var(--text); border-color: var(--accent); } +.list { list-style: none; padding: 0; margin: 0; display: grid; gap: .7rem; } +.list li { background: var(--panel); border: 1px solid #30363d; border-radius: 12px; padding: .9rem 1rem; } +pre { background: #010409; border: 1px solid #30363d; border-radius: 12px; padding: 1rem; overflow: auto; white-space: pre-wrap; } +.markdown-body { line-height: 1.6; } +.tag, .user-pill, .flash, .meta-badges span { display: inline-block; background: var(--soft); border: 1px solid #30363d; border-radius: 999px; padding: .3rem .65rem; } +.flash { margin-bottom: 1rem; } +.muted { color: var(--muted); } +.inline-form { display: flex; gap: .65rem; flex-wrap: wrap; margin-bottom: 1rem; } +.breadcrumbs { color: var(--muted); } +@media (max-width: 900px) { + .split { grid-template-columns: 1fr; } + .topbar { flex-direction: column; } +} diff --git a/deliverable/gitvault/pyproject.toml b/deliverable/gitvault/pyproject.toml new file mode 100644 index 000000000..c5dd8e88f --- /dev/null +++ b/deliverable/gitvault/pyproject.toml @@ -0,0 +1,31 @@ +[project] +name = "gitvault" +version = "0.1.0" +description = "GitVault: a GitHub-inspired full-stack platform" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "fastapi>=0.116.0", + "jinja2>=3.1.4", + "markdown>=3.7", + "pyyaml>=6.0.2", + "python-multipart>=0.0.20", + "uvicorn>=0.35.0", + "httpx>=0.28.1", + "itsdangerous>=2.2.0" +] + +[dependency-groups] +dev = [ + "pytest>=8.4.0" +] + +[tool.pytest.ini_options] +addopts = "-q" + +[build-system] +requires = ["setuptools>=70", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +include = ["gitvault*"] diff --git a/deliverable/gitvault/tasks/changedoc.md b/deliverable/gitvault/tasks/changedoc.md new file mode 100644 index 000000000..c321e2a13 --- /dev/null +++ b/deliverable/gitvault/tasks/changedoc.md @@ -0,0 +1,66 @@ +# Change Document + +**Sources reviewed:** agent1.2, agent_a + +## Summary +Final GitVault consolidates the verified FastAPI + SQLite + Git implementation into this workspace and keeps the strongest depth improvements in pull requests: review requests, inline review comments with suggested changes, CODEOWNERS-aware approval enforcement, queued auto-merge, and fallback status checks for protected branches. The delivered app remains a real, runnable web application with persistent state, on-disk repositories, browser UI, and JSON API surfaces rather than a mock prototype. + +## Decisions + +### DEC-001: Keep the real full-stack FastAPI + Git architecture +**Origin:** agent1.2 (kept) +**Choice:** Continue with a real server-rendered FastAPI application backed by SQLite and on-disk Git repositories. +**Why:** The request called for a GitHub-style platform, so the solution needed durable state, working authentication, real repository operations, and actual HTTP routes instead of static pages or placeholders. +**Synthesis Note:** This remained the right foundation for the final answer. The strongest path was to preserve the broad feature surface already present and deepen central collaboration flows. +**Alternatives considered:** +- Rebuild from scratch: rejected because the existing implementation already provided the strongest working platform base. +- Replace the app with a frontend-only demo: rejected because it would remove the persistence and Git behavior that make the result credible. +**Implementation:** +- `gitvault/app.py` → `create_app()` and the registered HTML/JSON routes +- `gitvault/database.py` → `Database` schema and persistence helpers +- `gitvault/gitops.py` → `GitService` repository lifecycle, history, compare, archive, and merge operations + +### DEC-002: Deepen pull requests into a fuller review workflow +**Origin:** agent1.2 → agent_a (modified) +**Choice:** Extend pull requests with review requests, inline review comments, suggested changes, queue-backed auto-merge, and richer PR detail rendering. +**Why:** Pull requests are a flagship GitHub workflow. Without reviewer assignment, code-review comments, or queued auto-merge, the application would feel materially less like a collaboration platform. +**Synthesis Note:** The final answer keeps the baseline open/review/merge flow and turns it into a more complete end-to-end review loop. +**Alternatives considered:** +- Spend the same effort on another shallow top-level module: rejected because PR collaboration is more central to the requested platform. +- Limit reviews to summary approvals only: rejected because inline context and suggested edits are part of the expected GitHub experience. +**Implementation:** +- `gitvault/app.py` → `pr_detail()`, `request_pr_reviewers()`, `add_pr_comment()`, `enable_auto_merge()` +- `gitvault/database.py` → `review_comments` table plus reviewer and auto-merge persistence helpers in `Database` +- `tests/test_gitvault.py` → `test_pull_request_review_requests_inline_suggestions_and_auto_merge_queue` + +### DEC-003: Make CODEOWNERS and branch protection rules affect mergeability +**Origin:** agent1.2 → agent_a (modified) +**Choice:** Add live PR gate evaluation for merge conflicts, required reviews, required status checks, and CODEOWNERS approvals based on changed files. +**Why:** Protected branches and CODEOWNERS only matter if they can block or permit merges. Wiring them into merge gates makes the platform's governance model meaningfully closer to GitHub. +**Synthesis Note:** The final implementation keeps the existing branch-rule concepts and connects them to real Git diff data and review state. +**Alternatives considered:** +- Keep CODEOWNERS informational only: rejected because it would not satisfy the intended repository-governance behavior. +- Hardcode placeholder approvers: rejected because it would make normal repository flows brittle and unrealistic. +**Implementation:** +- `gitvault/app.py` → `pr_gate_state()` and merge/queue enforcement paths +- `gitvault/gitops.py` → `changed_files()` and `required_codeowners()` +- `tests/test_gitvault.py` → advanced PR workflow coverage and merge-path regression coverage + +### DEC-004: Provide baseline status checks when no YAML workflows exist +**Origin:** agent_a — NEW +**Choice:** Have the Actions layer create a default “GitVault Checks” run for push- and PR-style events when no explicit workflow files are present. +**Why:** Required status checks were otherwise impossible to satisfy on fresh repositories. A baseline check keeps protected-branch rules coherent even before a repository adds custom CI. +**Implementation:** +- `gitvault/app.py` → `trigger_actions()` fallback check-run creation +- `tests/test_gitvault.py` → advanced PR auto-merge coverage exercises the required-status-check path + +## Deliberation Trail + +### agent1.2 +- Introduced the working FastAPI + SQLite + Git platform foundation with broad repository, collaboration, and admin surfaces. +- Established the initial pull-request, issues, wiki, projects, actions, and API shape that the final answer retains. + +### agent_a +- Kept the existing architecture and focused improvement effort on one of the most central GitHub workflows: pull requests. +- Added reviewer requests, inline suggested-change comments, queued auto-merge, and more credible protected-branch enforcement. +- **NEW:** Added fallback Actions checks so required-status-check rules work on new repositories without custom workflow YAML. diff --git a/deliverable/gitvault/tests/conftest.py b/deliverable/gitvault/tests/conftest.py new file mode 100644 index 000000000..2c0a88563 --- /dev/null +++ b/deliverable/gitvault/tests/conftest.py @@ -0,0 +1,4 @@ +from pathlib import Path +import sys +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) diff --git a/deliverable/gitvault/tests/test_gitvault.py b/deliverable/gitvault/tests/test_gitvault.py new file mode 100644 index 000000000..e18030279 --- /dev/null +++ b/deliverable/gitvault/tests/test_gitvault.py @@ -0,0 +1,414 @@ +import base64 +import hashlib +import hmac +import time +from pathlib import Path + +from fastapi.testclient import TestClient + +from gitvault.app import create_app + + +def make_client(tmp_path: Path): + app = create_app(data_dir=tmp_path / "data", testing=True) + return TestClient(app) + + +def register_and_login(client: TestClient, email="alice@example.com", username="alice", password="secret123"): + response = client.post( + "/register", + data={"email": email, "username": username, "password": password}, + follow_redirects=False, + ) + assert response.status_code == 303 + login = client.post( + "/login", + data={"email": email, "password": password}, + follow_redirects=False, + ) + assert login.status_code == 303 + + +def current_totp(secret: str) -> str: + normalized = secret.upper() + padding = "=" * ((8 - len(normalized) % 8) % 8) + key = base64.b32decode(normalized + padding) + counter = int(time.time()) // 30 + digest = hmac.new(key, counter.to_bytes(8, "big"), hashlib.sha1).digest() + offset = digest[-1] & 0x0F + chunk = digest[offset : offset + 4] + code = (int.from_bytes(chunk, "big") & 0x7FFFFFFF) % 1_000_000 + return f"{code:06d}" + + +def test_homepage_loads(tmp_path: Path): + client = make_client(tmp_path) + response = client.get("/") + assert response.status_code == 200 + assert "GitVault" in response.text + + +def test_create_repository_and_browse_file(tmp_path: Path): + client = make_client(tmp_path) + register_and_login(client) + + response = client.post( + "/repos/new", + data={ + "name": "demo-repo", + "description": "A demo repository", + "visibility": "public", + "readme_template": "python", + "license_template": "mit", + "gitignore_template": "python", + "topics": "python,fastapi", + }, + follow_redirects=False, + ) + assert response.status_code == 303 + + repo_page = client.get("/alice/demo-repo") + assert repo_page.status_code == 200 + assert "demo-repo" in repo_page.text + assert "README.md" in repo_page.text + + raw = client.get("/alice/demo-repo/raw/main/README.md") + assert raw.status_code == 200 + assert "demo-repo" in raw.text.lower() + + history = client.get("/alice/demo-repo/history/main/README.md") + assert history.status_code == 200 + assert "Initial scaffold commit" in history.text + + +def test_issue_and_pr_flow(tmp_path: Path): + client = make_client(tmp_path) + register_and_login(client) + client.post( + "/repos/new", + data={"name": "collab", "description": "repo", "visibility": "public"}, + follow_redirects=False, + ) + branch = client.post( + "/alice/collab/branches/create", + data={"branch_name": "feature-login", "from_ref": "main"}, + follow_redirects=False, + ) + assert branch.status_code == 303 + edit = client.post( + "/alice/collab/edit/feature-login/docs/notes.md", + data={"content": "hello world", "message": "Add notes"}, + follow_redirects=False, + ) + assert edit.status_code == 303 + + issue = client.post( + "/alice/collab/issues/new", + data={"title": "Bug report", "body": "something broke", "labels": "bug"}, + follow_redirects=False, + ) + assert issue.status_code == 303 + + pr = client.post( + "/alice/collab/pulls/new", + data={ + "title": "Add notes", + "body": "Implements docs", + "source_branch": "feature-login", + "target_branch": "main", + "linked_issue_number": "1", + "draft": "false", + }, + follow_redirects=False, + ) + assert pr.status_code == 303 + + review = client.post( + "/alice/collab/pulls/1/reviews", + data={"state": "APPROVED", "body": "Looks good"}, + follow_redirects=False, + ) + assert review.status_code == 303 + + merge = client.post( + "/alice/collab/pulls/1/merge", + data={"strategy": "merge"}, + follow_redirects=False, + ) + assert merge.status_code == 303 + + pulls = client.get("/alice/collab/pulls/1") + assert "merged" in pulls.text.lower() + + +def test_search_notifications_and_actions(tmp_path: Path): + client = make_client(tmp_path) + register_and_login(client) + client.post( + "/repos/new", + data={"name": "ops", "description": "automation repo", "visibility": "public"}, + follow_redirects=False, + ) + client.post( + "/alice/ops/edit/main/.github/workflows/ci.yml", + data={ + "content": "name: CI\non:\n push:\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - run: echo hello\n", + "message": "Add workflow", + }, + follow_redirects=False, + ) + + actions = client.get("/alice/ops/actions") + assert actions.status_code == 200 + assert "CI" in actions.text or "ci" in actions.text.lower() + + search = client.get("/search?q=automation") + assert search.status_code == 200 + assert "ops" in search.text + + notifications = client.get("/notifications") + assert notifications.status_code == 200 + + +def test_org_wiki_project_discussion_and_admin(tmp_path: Path): + client = make_client(tmp_path) + register_and_login(client) + org = client.post( + "/orgs/new", + data={"name": "Acme Inc", "slug": "acme", "description": "The org"}, + follow_redirects=False, + ) + assert org.status_code == 303 + repo = client.post( + "/repos/new", + data={"name": "platform", "description": "repo", "visibility": "private", "owner_slug": "acme"}, + follow_redirects=False, + ) + assert repo.status_code == 303 + + wiki = client.post( + "/acme/platform/wiki/new", + data={"title": "Home", "content": "# Welcome", "sidebar": "* [Home](/)"}, + follow_redirects=False, + ) + assert wiki.status_code == 303 + + project = client.post( + "/acme/platform/projects/new", + data={"name": "Roadmap", "description": "Q3 work", "view_type": "roadmap"}, + follow_redirects=False, + ) + assert project.status_code == 303 + + discussion = client.post( + "/acme/platform/discussions/new", + data={"category": "Q&A", "title": "How do we deploy?", "body": "Question body"}, + follow_redirects=False, + ) + assert discussion.status_code == 303 + + admin = client.get("/admin") + assert admin.status_code == 200 + assert "System health" in admin.text + + +def test_two_factor_sessions_and_personal_access_tokens(tmp_path: Path): + client = make_client(tmp_path) + register_and_login(client) + client.post( + "/repos/new", + data={"name": "secure-repo", "description": "repo", "visibility": "private"}, + follow_redirects=False, + ) + + enable = client.post("/settings/security/totp/enable", follow_redirects=False) + assert enable.status_code in {200, 303} + + user = client.app.state.db.get_user_by_email("alice@example.com") + security = client.app.state.db.get_user_security(user["id"]) + assert security["two_factor_enabled"] == 1 + assert security["totp_secret"] + + client.get("/logout", follow_redirects=False) + login = client.post( + "/login", + data={"email": "alice@example.com", "password": "secret123"}, + follow_redirects=False, + ) + assert login.status_code == 303 + assert login.headers["location"].endswith("/login/2fa") + + verify = client.post("/login/2fa", data={"code": current_totp(security["totp_secret"])}, follow_redirects=False) + assert verify.status_code == 303 + + security_page = client.get("/settings/security") + assert security_page.status_code == 200 + assert "Two-factor authentication" in security_page.text + assert "Active sessions" in security_page.text + + token_create = client.post( + "/settings/tokens", + data={"name": "ci-bot", "scopes": "repo:read", "repo_scope": "alice/secure-repo"}, + ) + assert token_create.status_code == 200 + token = token_create.json()["token"] + assert token.startswith("gv_") + + anonymous = make_client(tmp_path) + anonymous_repo = anonymous.get("/api/repos/alice/secure-repo") + assert anonymous_repo.status_code in {401, 403, 404} + + token_repo = anonymous.get( + "/api/repos/alice/secure-repo", + headers={"Authorization": f"Bearer {token}"}, + ) + assert token_repo.status_code == 200 + assert token_repo.json()["slug"] == "secure-repo" + + +def test_filtered_webhook_deliveries_are_recorded(tmp_path: Path): + client = make_client(tmp_path) + register_and_login(client) + client.post( + "/repos/new", + data={"name": "hooks", "description": "repo", "visibility": "public"}, + follow_redirects=False, + ) + + hook = client.post( + "/alice/hooks/settings/webhooks", + data={"target_url": "https://example.test/webhook", "events": "issues,push", "secret": "topsecret"}, + follow_redirects=False, + ) + assert hook.status_code == 303 + + issue = client.post( + "/alice/hooks/issues/new", + data={"title": "Webhook me", "body": "body", "labels": "bug"}, + follow_redirects=False, + ) + assert issue.status_code == 303 + + discussion = client.post( + "/alice/hooks/discussions/new", + data={"category": "Q&A", "title": "Ignored", "body": "no delivery wanted"}, + follow_redirects=False, + ) + assert discussion.status_code == 303 + + edit = client.post( + "/alice/hooks/edit/main/README.md", + data={"content": "# hooks\nupdated", "message": "Update README"}, + follow_redirects=False, + ) + assert edit.status_code == 303 + + deliveries = client.get("/api/repos/alice/hooks/webhooks/deliveries") + assert deliveries.status_code == 200 + events = [delivery["event_name"] for delivery in deliveries.json()["deliveries"]] + assert "issues" in events + assert "push" in events + assert "discussion" not in events + + +def test_pull_request_review_requests_inline_suggestions_and_auto_merge_queue(tmp_path: Path): + alice = make_client(tmp_path) + bob = make_client(tmp_path) + + register_and_login(alice) + register_and_login(bob, email="bob@example.com", username="bob", password="secret456") + + repo = alice.post( + "/repos/new", + data={"name": "review-lab", "description": "repo", "visibility": "public"}, + follow_redirects=False, + ) + assert repo.status_code == 303 + + codeowners = alice.post( + "/alice/review-lab/edit/main/CODEOWNERS", + data={"content": "* @bob\n", "message": "Require bob approval"}, + follow_redirects=False, + ) + assert codeowners.status_code == 303 + + alice.app.state.db.execute( + "UPDATE branch_rules SET require_reviews = 1, codeowners_required = 1, required_status_checks = 1 WHERE repo_id = ? AND branch_name = ?", + (alice.app.state.db.get_repo("alice", "review-lab")["id"], "main"), + ) + + branch = alice.post( + "/alice/review-lab/branches/create", + data={"branch_name": "feature-ui", "from_ref": "main"}, + follow_redirects=False, + ) + assert branch.status_code == 303 + + edit = alice.post( + "/alice/review-lab/edit/feature-ui/src/app.py", + data={"content": "print('hello from feature')\n", "message": "Add app entrypoint"}, + follow_redirects=False, + ) + assert edit.status_code == 303 + + pr = alice.post( + "/alice/review-lab/pulls/new", + data={ + "title": "Feature UI", + "body": "Implements the first version", + "source_branch": "feature-ui", + "target_branch": "main", + "linked_issue_number": "", + "draft": "false", + }, + follow_redirects=False, + ) + assert pr.status_code == 303 + + request_review = alice.post( + "/alice/review-lab/pulls/1/review-requests", + data={"reviewers": "bob"}, + follow_redirects=False, + ) + assert request_review.status_code == 303 + + comment = bob.post( + "/alice/review-lab/pulls/1/comments", + data={ + "file_path": "src/app.py", + "line_number": "1", + "body": "Use a function so the entrypoint is reusable.", + "suggested_change": "def main():\n print('hello from feature')\n\n\nif __name__ == '__main__':\n main()\n", + }, + follow_redirects=False, + ) + assert comment.status_code == 303 + + auto_merge = alice.post( + "/alice/review-lab/pulls/1/auto-merge", + data={"strategy": "squash"}, + follow_redirects=False, + ) + assert auto_merge.status_code == 303 + + queued_detail = alice.get("/alice/review-lab/pulls/1") + assert queued_detail.status_code == 200 + assert "Queued for auto-merge" in queued_detail.text + assert "Requested reviewers" in queued_detail.text + assert "Suggested change" in queued_detail.text + + approve = bob.post( + "/alice/review-lab/pulls/1/reviews", + data={"state": "APPROVED", "body": "Approved by code owner"}, + follow_redirects=False, + ) + assert approve.status_code == 303 + + final_detail = alice.get("/alice/review-lab/pulls/1") + assert final_detail.status_code == 200 + assert "merged" in final_detail.text.lower() + assert "squash" in final_detail.text.lower() + + notifications = bob.get("/notifications") + assert notifications.status_code == 200 + assert "review request" in notifications.text.lower() diff --git a/deliverable/gitvault/uv.lock b/deliverable/gitvault/uv.lock new file mode 100644 index 000000000..339d2b515 --- /dev/null +++ b/deliverable/gitvault/uv.lock @@ -0,0 +1,498 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + +[[package]] +name = "certifi" +version = "2026.4.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, +] + +[[package]] +name = "click" +version = "8.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "fastapi" +version = "0.136.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5d/45/c130091c2dfa061bbfe3150f2a5091ef1adf149f2a8d2ae769ecaf6e99a2/fastapi-0.136.1.tar.gz", hash = "sha256:7af665ad7acfa0a3baf8983d393b6b471b9da10ede59c60045f49fbc89a0fa7f", size = 397448, upload-time = "2026-04-23T16:49:44.046Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/ff/2e4eca3ade2c22fe1dea7043b8ee9dabe47753349eb1b56a202de8af6349/fastapi-0.136.1-py3-none-any.whl", hash = "sha256:a6e9d7eeada96c93a4d69cb03836b44fa34e2854accb7244a1ece36cd4781c3f", size = 117683, upload-time = "2026-04-23T16:49:42.437Z" }, +] + +[[package]] +name = "gitvault" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "fastapi" }, + { name = "httpx" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "python-multipart" }, + { name = "pyyaml" }, + { name = "uvicorn" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "fastapi", specifier = ">=0.116.0" }, + { name = "httpx", specifier = ">=0.28.1" }, + { name = "itsdangerous", specifier = ">=2.2.0" }, + { name = "jinja2", specifier = ">=3.1.4" }, + { name = "markdown", specifier = ">=3.7" }, + { name = "python-multipart", specifier = ">=0.0.20" }, + { name = "pyyaml", specifier = ">=6.0.2" }, + { name = "uvicorn", specifier = ">=0.35.0" }, +] + +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = ">=8.4.0" }] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "markdown" +version = "3.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/e4/40d09941a2cebcb20609b86a559817d5b9291c49dd6f8c87e5feffbe703a/pydantic-2.13.3.tar.gz", hash = "sha256:af09e9d1d09f4e7fe37145c1f577e1d61ceb9a41924bf0094a36506285d0a84d", size = 844068, upload-time = "2026-04-20T14:46:43.632Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/0a/fd7d723f8f8153418fb40cf9c940e82004fce7e987026b08a68a36dd3fe7/pydantic-2.13.3-py3-none-any.whl", hash = "sha256:6db14ac8dfc9a1e57f87ea2c0de670c251240f43cb0c30a5130e9720dc612927", size = 471981, upload-time = "2026-04-20T14:46:41.402Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/ef/f7abb56c49382a246fd2ce9c799691e3c3e7175ec74b14d99e798bcddb1a/pydantic_core-2.46.3.tar.gz", hash = "sha256:41c178f65b8c29807239d47e6050262eb6bf84eb695e41101e62e38df4a5bc2c", size = 471412, upload-time = "2026-04-20T14:40:56.672Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/cb/5b47425556ecc1f3fe18ed2a0083188aa46e1dd812b06e406475b3a5d536/pydantic_core-2.46.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b11b59b3eee90a80a36701ddb4576d9ae31f93f05cb9e277ceaa09e6bf074a67", size = 2101946, upload-time = "2026-04-20T14:40:52.581Z" }, + { url = "https://files.pythonhosted.org/packages/a1/4f/2fb62c2267cae99b815bbf4a7b9283812c88ca3153ef29f7707200f1d4e5/pydantic_core-2.46.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:af8653713055ea18a3abc1537fe2ebc42f5b0bbb768d1eb79fd74eb47c0ac089", size = 1951612, upload-time = "2026-04-20T14:42:42.996Z" }, + { url = "https://files.pythonhosted.org/packages/50/6e/b7348fd30d6556d132cddd5bd79f37f96f2601fe0608afac4f5fb01ec0b3/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:75a519dab6d63c514f3a81053e5266c549679e4aa88f6ec57f2b7b854aceb1b0", size = 1977027, upload-time = "2026-04-20T14:42:02.001Z" }, + { url = "https://files.pythonhosted.org/packages/82/11/31d60ee2b45540d3fb0b29302a393dbc01cd771c473f5b5147bcd353e593/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6cd87cb1575b1ad05ba98894c5b5c96411ef678fa2f6ed2576607095b8d9789", size = 2063008, upload-time = "2026-04-20T14:44:17.952Z" }, + { url = "https://files.pythonhosted.org/packages/8a/db/3a9d1957181b59258f44a2300ab0f0be9d1e12d662a4f57bb31250455c52/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f80a55484b8d843c8ada81ebf70a682f3f00a3d40e378c06cf17ecb44d280d7d", size = 2233082, upload-time = "2026-04-20T14:40:57.934Z" }, + { url = "https://files.pythonhosted.org/packages/9c/e1/3277c38792aeb5cfb18c2f0c5785a221d9ff4e149abbe1184d53d5f72273/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3861f1731b90c50a3266316b9044f5c9b405eecb8e299b0a7120596334e4fe9c", size = 2304615, upload-time = "2026-04-20T14:42:12.584Z" }, + { url = "https://files.pythonhosted.org/packages/5e/d5/e3d9717c9eba10855325650afd2a9cba8e607321697f18953af9d562da2f/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb528e295ed31570ac3dcc9bfdd6e0150bc11ce6168ac87a8082055cf1a67395", size = 2094380, upload-time = "2026-04-20T14:43:05.522Z" }, + { url = "https://files.pythonhosted.org/packages/a1/20/abac35dedcbfd66c6f0b03e4e3564511771d6c9b7ede10a362d03e110d9b/pydantic_core-2.46.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:367508faa4973b992b271ba1494acaab36eb7e8739d1e47be5035fb1ea225396", size = 2135429, upload-time = "2026-04-20T14:41:55.549Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a5/41bfd1df69afad71b5cf0535055bccc73022715ad362edbc124bc1e021d7/pydantic_core-2.46.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5ad3c826fe523e4becf4fe39baa44286cff85ef137c729a2c5e269afbfd0905d", size = 2174582, upload-time = "2026-04-20T14:41:45.96Z" }, + { url = "https://files.pythonhosted.org/packages/79/65/38d86ea056b29b2b10734eb23329b7a7672ca604df4f2b6e9c02d4ee22fe/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ec638c5d194ef8af27db69f16c954a09797c0dc25015ad6123eb2c73a4d271ca", size = 2187533, upload-time = "2026-04-20T14:40:55.367Z" }, + { url = "https://files.pythonhosted.org/packages/b6/55/a1129141678a2026badc539ad1dee0a71d06f54c2f06a4bd68c030ac781b/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:28ed528c45446062ee66edb1d33df5d88828ae167de76e773a3c7f64bd14e976", size = 2332985, upload-time = "2026-04-20T14:44:13.05Z" }, + { url = "https://files.pythonhosted.org/packages/d7/60/cb26f4077719f709e54819f4e8e1d43f4091f94e285eb6bd21e1190a7b7c/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aed19d0c783886d5bd86d80ae5030006b45e28464218747dcf83dabfdd092c7b", size = 2373670, upload-time = "2026-04-20T14:41:53.421Z" }, + { url = "https://files.pythonhosted.org/packages/6b/7e/c3f21882bdf1d8d086876f81b5e296206c69c6082551d776895de7801fa0/pydantic_core-2.46.3-cp312-cp312-win32.whl", hash = "sha256:06d5d8820cbbdb4147578c1fe7ffcd5b83f34508cb9f9ab76e807be7db6ff0a4", size = 1966722, upload-time = "2026-04-20T14:44:30.588Z" }, + { url = "https://files.pythonhosted.org/packages/57/be/6b5e757b859013ebfbd7adba02f23b428f37c86dcbf78b5bb0b4ffd36e99/pydantic_core-2.46.3-cp312-cp312-win_amd64.whl", hash = "sha256:c3212fda0ee959c1dd04c60b601ec31097aaa893573a3a1abd0a47bcac2968c1", size = 2072970, upload-time = "2026-04-20T14:42:54.248Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f8/a989b21cc75e9a32d24192ef700eea606521221a89faa40c919ce884f2b1/pydantic_core-2.46.3-cp312-cp312-win_arm64.whl", hash = "sha256:f1f8338dd7a7f31761f1f1a3c47503a9a3b34eea3c8b01fa6ee96408affb5e72", size = 2035963, upload-time = "2026-04-20T14:44:20.4Z" }, + { url = "https://files.pythonhosted.org/packages/9b/3c/9b5e8eb9821936d065439c3b0fb1490ffa64163bfe7e1595985a47896073/pydantic_core-2.46.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:12bc98de041458b80c86c56b24df1d23832f3e166cbaff011f25d187f5c62c37", size = 2102109, upload-time = "2026-04-20T14:41:24.219Z" }, + { url = "https://files.pythonhosted.org/packages/91/97/1c41d1f5a19f241d8069f1e249853bcce378cdb76eec8ab636d7bc426280/pydantic_core-2.46.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:85348b8f89d2c3508b65b16c3c33a4da22b8215138d8b996912bb1532868885f", size = 1951820, upload-time = "2026-04-20T14:42:14.236Z" }, + { url = "https://files.pythonhosted.org/packages/30/b4/d03a7ae14571bc2b6b3c7b122441154720619afe9a336fa3a95434df5e2f/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1105677a6df914b1fb71a81b96c8cce7726857e1717d86001f29be06a25ee6f8", size = 1977785, upload-time = "2026-04-20T14:42:31.648Z" }, + { url = "https://files.pythonhosted.org/packages/ae/0c/4086f808834b59e3c8f1aa26df8f4b6d998cdcf354a143d18ef41529d1fe/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87082cd65669a33adeba5470769e9704c7cf026cc30afb9cc77fd865578ebaad", size = 2062761, upload-time = "2026-04-20T14:40:37.093Z" }, + { url = "https://files.pythonhosted.org/packages/fa/71/a649be5a5064c2df0db06e0a512c2281134ed2fcc981f52a657936a7527c/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:60e5f66e12c4f5212d08522963380eaaeac5ebd795826cfd19b2dfb0c7a52b9c", size = 2232989, upload-time = "2026-04-20T14:42:59.254Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/7756e75763e810b3a710f4724441d1ecc5883b94aacb07ca71c5fb5cfb69/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b6cdf19bf84128d5e7c37e8a73a0c5c10d51103a650ac585d42dd6ae233f2b7f", size = 2303975, upload-time = "2026-04-20T14:41:32.287Z" }, + { url = "https://files.pythonhosted.org/packages/6c/35/68a762e0c1e31f35fa0dac733cbd9f5b118042853698de9509c8e5bf128b/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:031bb17f4885a43773c8c763089499f242aee2ea85cf17154168775dccdecf35", size = 2095325, upload-time = "2026-04-20T14:42:47.685Z" }, + { url = "https://files.pythonhosted.org/packages/77/bf/1bf8c9a8e91836c926eae5e3e51dce009bf495a60ca56060689d3df3f340/pydantic_core-2.46.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:bcf2a8b2982a6673693eae7348ef3d8cf3979c1d63b54fca7c397a635cc68687", size = 2133368, upload-time = "2026-04-20T14:41:22.766Z" }, + { url = "https://files.pythonhosted.org/packages/e5/50/87d818d6bab915984995157ceb2380f5aac4e563dddbed6b56f0ed057aba/pydantic_core-2.46.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28e8cf2f52d72ced402a137145923a762cbb5081e48b34312f7a0c8f55928ec3", size = 2173908, upload-time = "2026-04-20T14:42:52.044Z" }, + { url = "https://files.pythonhosted.org/packages/91/88/a311fb306d0bd6185db41fa14ae888fb81d0baf648a761ae760d30819d33/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:17eaface65d9fc5abb940003020309c1bf7a211f5f608d7870297c367e6f9022", size = 2186422, upload-time = "2026-04-20T14:43:29.55Z" }, + { url = "https://files.pythonhosted.org/packages/8f/79/28fd0d81508525ab2054fef7c77a638c8b5b0afcbbaeee493cf7c3fef7e1/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:93fd339f23408a07e98950a89644f92c54d8729719a40b30c0a30bb9ebc55d23", size = 2332709, upload-time = "2026-04-20T14:42:16.134Z" }, + { url = "https://files.pythonhosted.org/packages/b3/21/795bf5fe5c0f379308b8ef19c50dedab2e7711dbc8d0c2acf08f1c7daa05/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:23cbdb3aaa74dfe0837975dbf69b469753bbde8eacace524519ffdb6b6e89eb7", size = 2372428, upload-time = "2026-04-20T14:41:10.974Z" }, + { url = "https://files.pythonhosted.org/packages/45/b3/ed14c659cbe7605e3ef063077680a64680aec81eb1a04763a05190d49b7f/pydantic_core-2.46.3-cp313-cp313-win32.whl", hash = "sha256:610eda2e3838f401105e6326ca304f5da1e15393ae25dacae5c5c63f2c275b13", size = 1965601, upload-time = "2026-04-20T14:41:42.128Z" }, + { url = "https://files.pythonhosted.org/packages/ef/bb/adb70d9a762ddd002d723fbf1bd492244d37da41e3af7b74ad212609027e/pydantic_core-2.46.3-cp313-cp313-win_amd64.whl", hash = "sha256:68cc7866ed863db34351294187f9b729964c371ba33e31c26f478471c52e1ed0", size = 2071517, upload-time = "2026-04-20T14:43:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/52/eb/66faefabebfe68bd7788339c9c9127231e680b11906368c67ce112fdb47f/pydantic_core-2.46.3-cp313-cp313-win_arm64.whl", hash = "sha256:f64b5537ac62b231572879cd08ec05600308636a5d63bcbdb15063a466977bec", size = 2035802, upload-time = "2026-04-20T14:43:38.507Z" }, + { url = "https://files.pythonhosted.org/packages/7f/db/a7bcb4940183fda36022cd18ba8dd12f2dff40740ec7b58ce7457befa416/pydantic_core-2.46.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:afa3aa644f74e290cdede48a7b0bee37d1c35e71b05105f6b340d484af536d9b", size = 2097614, upload-time = "2026-04-20T14:44:38.374Z" }, + { url = "https://files.pythonhosted.org/packages/24/35/e4066358a22e3e99519db370494c7528f5a2aa1367370e80e27e20283543/pydantic_core-2.46.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ced3310e51aa425f7f77da8bbbb5212616655bedbe82c70944320bc1dbe5e018", size = 1951896, upload-time = "2026-04-20T14:40:53.996Z" }, + { url = "https://files.pythonhosted.org/packages/87/92/37cf4049d1636996e4b888c05a501f40a43ff218983a551d57f9d5e14f0d/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e29908922ce9da1a30b4da490bd1d3d82c01dcfdf864d2a74aacee674d0bfa34", size = 1979314, upload-time = "2026-04-20T14:41:49.446Z" }, + { url = "https://files.pythonhosted.org/packages/d8/36/9ff4d676dfbdfb2d591cf43f3d90ded01e15b1404fd101180ed2d62a2fd3/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0c9ff69140423eea8ed2d5477df3ba037f671f5e897d206d921bc9fdc39613e7", size = 2056133, upload-time = "2026-04-20T14:42:23.574Z" }, + { url = "https://files.pythonhosted.org/packages/bc/f0/405b442a4d7ba855b06eec8b2bf9c617d43b8432d099dfdc7bf999293495/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b675ab0a0d5b1c8fdb81195dc5bcefea3f3c240871cdd7ff9a2de8aa50772eb2", size = 2228726, upload-time = "2026-04-20T14:44:22.816Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f8/65cd92dd5a0bd89ba277a98ecbfaf6fc36bbd3300973c7a4b826d6ab1391/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0087084960f209a9a4af50ecd1fb063d9ad3658c07bb81a7a53f452dacbfb2ba", size = 2301214, upload-time = "2026-04-20T14:44:48.792Z" }, + { url = "https://files.pythonhosted.org/packages/fd/86/ef96a4c6e79e7a2d0410826a68fbc0eccc0fd44aa733be199d5fcac3bb87/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed42e6cc8e1b0e2b9b96e2276bad70ae625d10d6d524aed0c93de974ae029f9f", size = 2099927, upload-time = "2026-04-20T14:41:40.196Z" }, + { url = "https://files.pythonhosted.org/packages/6d/53/269caf30e0096e0a8a8f929d1982a27b3879872cca2d917d17c2f9fdf4fe/pydantic_core-2.46.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:f1771ce258afb3e4201e67d154edbbae712a76a6081079fe247c2f53c6322c22", size = 2128789, upload-time = "2026-04-20T14:41:15.868Z" }, + { url = "https://files.pythonhosted.org/packages/00/b0/1a6d9b6a587e118482910c244a1c5acf4d192604174132efd12bf0ac486f/pydantic_core-2.46.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a7610b6a5242a6c736d8ad47fd5fff87fcfe8f833b281b1c409c3d6835d9227f", size = 2173815, upload-time = "2026-04-20T14:44:25.152Z" }, + { url = "https://files.pythonhosted.org/packages/87/56/e7e00d4041a7e62b5a40815590114db3b535bf3ca0bf4dca9f16cef25246/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:ff5e7783bcc5476e1db448bf268f11cb257b1c276d3e89f00b5727be86dd0127", size = 2181608, upload-time = "2026-04-20T14:41:28.933Z" }, + { url = "https://files.pythonhosted.org/packages/e8/22/4bd23c3d41f7c185d60808a1de83c76cf5aeabf792f6c636a55c3b1ec7f9/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:9d2e32edcc143bc01e95300671915d9ca052d4f745aa0a49c48d4803f8a85f2c", size = 2326968, upload-time = "2026-04-20T14:42:03.962Z" }, + { url = "https://files.pythonhosted.org/packages/24/ac/66cd45129e3915e5ade3b292cb3bc7fd537f58f8f8dbdaba6170f7cabb74/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6e42d83d1c6b87fa56b521479cff237e626a292f3b31b6345c15a99121b454c1", size = 2369842, upload-time = "2026-04-20T14:41:35.52Z" }, + { url = "https://files.pythonhosted.org/packages/a2/51/dd4248abb84113615473aa20d5545b7c4cd73c8644003b5259686f93996c/pydantic_core-2.46.3-cp314-cp314-win32.whl", hash = "sha256:07bc6d2a28c3adb4f7c6ae46aa4f2d2929af127f587ed44057af50bf1ce0f505", size = 1959661, upload-time = "2026-04-20T14:41:00.042Z" }, + { url = "https://files.pythonhosted.org/packages/20/eb/59980e5f1ae54a3b86372bd9f0fa373ea2d402e8cdcd3459334430f91e91/pydantic_core-2.46.3-cp314-cp314-win_amd64.whl", hash = "sha256:8940562319bc621da30714617e6a7eaa6b98c84e8c685bcdc02d7ed5e7c7c44e", size = 2071686, upload-time = "2026-04-20T14:43:16.471Z" }, + { url = "https://files.pythonhosted.org/packages/8c/db/1cf77e5247047dfee34bc01fa9bca134854f528c8eb053e144298893d370/pydantic_core-2.46.3-cp314-cp314-win_arm64.whl", hash = "sha256:5dcbbcf4d22210ced8f837c96db941bdb078f419543472aca5d9a0bb7cddc7df", size = 2026907, upload-time = "2026-04-20T14:43:31.732Z" }, + { url = "https://files.pythonhosted.org/packages/57/c0/b3df9f6a543276eadba0a48487b082ca1f201745329d97dbfa287034a230/pydantic_core-2.46.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d0fe3dce1e836e418f912c1ad91c73357d03e556a4d286f441bf34fed2dbeecf", size = 2095047, upload-time = "2026-04-20T14:42:37.982Z" }, + { url = "https://files.pythonhosted.org/packages/66/57/886a938073b97556c168fd99e1a7305bb363cd30a6d2c76086bf0587b32a/pydantic_core-2.46.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9ce92e58abc722dac1bf835a6798a60b294e48eb0e625ec9fd994b932ac5feee", size = 1934329, upload-time = "2026-04-20T14:43:49.655Z" }, + { url = "https://files.pythonhosted.org/packages/0b/7c/b42eaa5c34b13b07ecb51da21761297a9b8eb43044c864a035999998f328/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a03e6467f0f5ab796a486146d1b887b2dc5e5f9b3288898c1b1c3ad974e53e4a", size = 1974847, upload-time = "2026-04-20T14:42:10.737Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9b/92b42db6543e7de4f99ae977101a2967b63122d4b6cf7773812da2d7d5b5/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2798b6ba041b9d70acfb9071a2ea13c8456dd1e6a5555798e41ba7b0790e329c", size = 2041742, upload-time = "2026-04-20T14:40:44.262Z" }, + { url = "https://files.pythonhosted.org/packages/0f/19/46fbe1efabb5aa2834b43b9454e70f9a83ad9c338c1291e48bdc4fecf167/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9be3e221bdc6d69abf294dcf7aff6af19c31a5cdcc8f0aa3b14be29df4bd03b1", size = 2236235, upload-time = "2026-04-20T14:41:27.307Z" }, + { url = "https://files.pythonhosted.org/packages/77/da/b3f95bc009ad60ec53120f5d16c6faa8cabdbe8a20d83849a1f2b8728148/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f13936129ce841f2a5ddf6f126fea3c43cd128807b5a59588c37cf10178c2e64", size = 2282633, upload-time = "2026-04-20T14:44:33.271Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6e/401336117722e28f32fb8220df676769d28ebdf08f2f4469646d404c43a3/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28b5f2ef03416facccb1c6ef744c69793175fd27e44ef15669201601cf423acb", size = 2109679, upload-time = "2026-04-20T14:44:41.065Z" }, + { url = "https://files.pythonhosted.org/packages/fc/53/b289f9bc8756a32fe718c46f55afaeaf8d489ee18d1a1e7be1db73f42cc4/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:830d1247d77ad23852314f069e9d7ddafeec5f684baf9d7e7065ed46a049c4e6", size = 2108342, upload-time = "2026-04-20T14:42:50.144Z" }, + { url = "https://files.pythonhosted.org/packages/10/5b/8292fc7c1f9111f1b2b7c1b0dcf1179edcd014fc3ea4517499f50b829d71/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0793c90c1a3c74966e7975eaef3ed30ebdff3260a0f815a62a22adc17e4c01c", size = 2157208, upload-time = "2026-04-20T14:42:08.133Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9e/f80044e9ec07580f057a89fc131f78dda7a58751ddf52bbe05eaf31db50f/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:d2d0aead851b66f5245ec0c4fb2612ef457f8bbafefdf65a2bf9d6bac6140f47", size = 2167237, upload-time = "2026-04-20T14:42:25.412Z" }, + { url = "https://files.pythonhosted.org/packages/f8/84/6781a1b037f3b96be9227edbd1101f6d3946746056231bf4ac48cdff1a8d/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:2f40e4246676beb31c5ce77c38a55ca4e465c6b38d11ea1bd935420568e0b1ab", size = 2312540, upload-time = "2026-04-20T14:40:40.313Z" }, + { url = "https://files.pythonhosted.org/packages/3e/db/19c0839feeb728e7df03255581f198dfdf1c2aeb1e174a8420b63c5252e5/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:cf489cf8986c543939aeee17a09c04d6ffb43bfef8ca16fcbcc5cfdcbed24dba", size = 2369556, upload-time = "2026-04-20T14:41:09.427Z" }, + { url = "https://files.pythonhosted.org/packages/e0/15/3228774cb7cd45f5f721ddf1b2242747f4eb834d0c491f0c02d606f09fed/pydantic_core-2.46.3-cp314-cp314t-win32.whl", hash = "sha256:ffe0883b56cfc05798bf994164d2b2ff03efe2d22022a2bb080f3b626176dd56", size = 1949756, upload-time = "2026-04-20T14:41:25.717Z" }, + { url = "https://files.pythonhosted.org/packages/b8/2a/c79cf53fd91e5a87e30d481809f52f9a60dd221e39de66455cf04deaad37/pydantic_core-2.46.3-cp314-cp314t-win_amd64.whl", hash = "sha256:706d9d0ce9cf4593d07270d8e9f53b161f90c57d315aeec4fb4fd7a8b10240d8", size = 2051305, upload-time = "2026-04-20T14:43:18.627Z" }, + { url = "https://files.pythonhosted.org/packages/0b/db/d8182a7f1d9343a032265aae186eb063fe26ca4c40f256b21e8da4498e89/pydantic_core-2.46.3-cp314-cp314t-win_arm64.whl", hash = "sha256:77706aeb41df6a76568434701e0917da10692da28cb69d5fb6919ce5fdb07374", size = 2026310, upload-time = "2026-04-20T14:41:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/34/42/f426db557e8ab2791bc7562052299944a118655496fbff99914e564c0a94/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:b12dd51f1187c2eb489af8e20f880362db98e954b54ab792fa5d92e8bcc6b803", size = 2091877, upload-time = "2026-04-20T14:43:27.091Z" }, + { url = "https://files.pythonhosted.org/packages/5c/4f/86a832a9d14df58e663bfdf4627dc00d3317c2bd583c4fb23390b0f04b8e/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f00a0961b125f1a47af7bcc17f00782e12f4cd056f83416006b30111d941dfa3", size = 1932428, upload-time = "2026-04-20T14:40:45.781Z" }, + { url = "https://files.pythonhosted.org/packages/11/1a/fe857968954d93fb78e0d4b6df5c988c74c4aaa67181c60be7cfe327c0ca/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57697d7c056aca4bbb680200f96563e841a6386ac1129370a0102592f4dddff5", size = 1997550, upload-time = "2026-04-20T14:44:02.425Z" }, + { url = "https://files.pythonhosted.org/packages/17/eb/9d89ad2d9b0ba8cd65393d434471621b98912abb10fbe1df08e480ba57b5/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd35aa21299def8db7ef4fe5c4ff862941a9a158ca7b63d61e66fe67d30416b4", size = 2137657, upload-time = "2026-04-20T14:42:45.149Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.27" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/69/9b/f23807317a113dc36e74e75eb265a02dd1a4d9082abc3c1064acd22997c4/python_multipart-0.0.27.tar.gz", hash = "sha256:9870a6a8c5a20a5bf4f07c017bd1489006ff8836cff097b6933355ee2b49b602", size = 44043, upload-time = "2026-04-27T10:51:26.649Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/78/4126abcbdbd3c559d43e0db7f7b9173fc6befe45d39a2856cc0b8ec2a5a6/python_multipart-0.0.27-py3-none-any.whl", hash = "sha256:6fccfad17a27334bd0193681b369f476eda3409f17381a2d65aa7df3f7275645", size = 29254, upload-time = "2026-04-27T10:51:24.997Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "starlette" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.46.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/93/041fca8274050e40e6791f267d82e0e2e27dd165627bd640d3e0e378d877/uvicorn-0.46.0.tar.gz", hash = "sha256:fb9da0926999cc6cb22dc7cd71a94a632f078e6ae47ff683c5c420750fb7413d", size = 88758, upload-time = "2026-04-23T07:16:00.151Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/a3/5b1562db76a5a488274b2332a97199b32d0442aca0ed193697fd47786316/uvicorn-0.46.0-py3-none-any.whl", hash = "sha256:bbebbcbed972d162afca128605223022bedd345b7bc7855ce66deb31487a9048", size = 70926, upload-time = "2026-04-23T07:15:58.355Z" }, +] diff --git a/deliverable/noteflow/README.md b/deliverable/noteflow/README.md new file mode 100644 index 000000000..47d517247 --- /dev/null +++ b/deliverable/noteflow/README.md @@ -0,0 +1,38 @@ +# NoteFlow + +NoteFlow is a self-contained full-stack collaborative workspace app inspired by Notion. It ships as a zero-dependency Node.js application with a browser-based editor, workspace/page hierarchy, first-class databases, tasks, search, sharing, notifications, auth, import/export, file management, and realtime presence via Server-Sent Events. + +## Run locally + +```bash +node server.mjs +``` + +Then open: http://localhost:3000 + +## Default behavior + +- First run seeds a demo workspace and starter templates. +- Create an account with email/password, or use the demo OAuth buttons. +- Data persists to `data/noteflow-db.json` and uploaded files under `uploads/`. + +## Implemented highlights + +- Personal and team workspaces +- Nested pages, templates, breadcrumbs, favorites, recent pages, trash/restore +- Block editor with slash insertion, drag/drop reorder, markdown-ish shortcuts, synced blocks, comments, backlinks, table of contents, version history, publish/share controls, SEO/custom URL settings +- First-class databases with schemas, row CRUD, and table/board/calendar/timeline/gallery views +- Realtime presence + page refresh events via SSE +- Task database with priorities, statuses, dependencies, reminders, recurrence, dashboards, my tasks, workload grouping, ICS export +- Global search across pages, database rows, comments, tasks, and files with filters +- File uploads, signed access URLs, version replacement, quota checks +- In-app notifications + email-preview outbox + preferences +- Email/password auth, magic links, demo OAuth flows, demo SSO/SAML login, SCIM provisioning, 2FA setup/verify, sessions/device metadata, profile management +- Admin center for workspace security settings, invite tracking, audit activity, page/database permission grants, and publishing/share controls +- Import/export for Markdown, HTML, CSV, JSON, simple PDF generation, plus database CSV/JSON/Markdown export + +## Testing + +```bash +node --test tests/*.test.mjs +``` diff --git a/deliverable/noteflow/data/noteflow-db.json b/deliverable/noteflow/data/noteflow-db.json new file mode 100644 index 000000000..0e0181454 --- /dev/null +++ b/deliverable/noteflow/data/noteflow-db.json @@ -0,0 +1,688 @@ +{ + "users": [ + { + "id": "usr_374484b3e06c", + "name": "SCIM Owner", + "email": "scim-owner@example.com", + "passwordHash": "7c334074b6db7d0a6cf574215426a211:933376cbff1be3674595d00cbf7995bd871be6df218749543cd14912b08d6445b513e01a522386291efe0a2ce5a69621a3931ed8558ce491a3b0a722fd92d9fb", + "avatarColor": "#4f46e5", + "title": "Workspace builder", + "bio": "", + "createdAt": "2026-05-05T06:06:21.590Z", + "notificationPreferences": {}, + "identity": { + "authMethods": [ + "password" + ], + "lastAuditAt": "2026-05-05T06:06:21.590Z", + "deviceMetadata": [ + { + "sessionId": "ses_e4801412d212", + "userAgent": "node", + "ipAddress": "127.0.0.1", + "lastSeenAt": "2026-05-05T06:06:21.590Z" + } + ] + }, + "twoFactorEnabled": false, + "twoFactorSecret": "", + "recoveryCodes": [] + }, + { + "id": "usr_0fcdd9c9f240", + "name": "Provisioned Person", + "email": "provisioned@example.com", + "passwordHash": "aba9c82e738d5d633958786171317eb4:36b71c49be3d7a79388e1dfe943a9c856b735098d8fe806396f0dca49dc3ca550dc65570a011678099dfac2fac83c32a2ec663fe122637bf7f1de8e2f8f693e9", + "avatarColor": "#7c3aed", + "title": "Analyst", + "bio": "", + "createdAt": "2026-05-05T06:06:21.612Z", + "notificationPreferences": {}, + "identity": { + "authMethods": [ + "scim" + ], + "lastAuditAt": "2026-05-05T06:06:21.612Z", + "deviceMetadata": [], + "samlExternalId": "", + "scimExternalId": "scim-42" + }, + "twoFactorEnabled": false, + "twoFactorSecret": "", + "recoveryCodes": [] + } + ], + "sessions": [ + { + "id": "ses_e4801412d212", + "userId": "usr_374484b3e06c", + "createdAt": "2026-05-05T06:06:21.590Z", + "lastSeenAt": "2026-05-05T06:06:21.615Z", + "userAgent": "node", + "ipAddress": "127.0.0.1", + "label": "Session 5/4/2026, 11:06:21 PM" + } + ], + "workspaces": [ + { + "id": "ws_624e65305d73", + "name": "NoteFlow Demo Workspace", + "kind": "team", + "ownerId": "system", + "createdAt": "2026-05-05T06:06:21.568Z", + "settings": { + "storageQuotaMb": 100, + "domainRestriction": "", + "allowGuests": true, + "publicSharing": true, + "databasePermissions": "workspace-role", + "samlEnabled": false, + "scimEnabled": false + }, + "members": [] + }, + { + "id": "ws_55eaa3560f0b", + "name": "SCIM's Workspace", + "kind": "personal", + "ownerId": "usr_374484b3e06c", + "createdAt": "2026-05-05T06:06:21.590Z", + "settings": { + "storageQuotaMb": 250, + "domainRestriction": "", + "allowGuests": true, + "publicSharing": true, + "databasePermissions": "workspace-role", + "samlEnabled": false, + "scimEnabled": false + }, + "members": [ + { + "userId": "usr_374484b3e06c", + "role": "owner", + "invitedAt": "2026-05-05T06:06:21.590Z", + "acceptedAt": "2026-05-05T06:06:21.590Z" + } + ] + }, + { + "id": "ws_7761e51b5b27", + "name": "SCIM's Team Space", + "kind": "team", + "ownerId": "usr_374484b3e06c", + "createdAt": "2026-05-05T06:06:21.590Z", + "settings": { + "storageQuotaMb": 500, + "domainRestriction": "example.com", + "allowGuests": true, + "publicSharing": true, + "databasePermissions": "workspace-role", + "samlEnabled": false, + "scimEnabled": true + }, + "members": [ + { + "userId": "usr_374484b3e06c", + "role": "owner", + "invitedAt": "2026-05-05T06:06:21.590Z", + "acceptedAt": "2026-05-05T06:06:21.590Z" + }, + { + "userId": "usr_0fcdd9c9f240", + "role": "viewer", + "invitedAt": "2026-05-05T06:06:21.612Z", + "acceptedAt": "2026-05-05T06:06:21.612Z" + } + ] + } + ], + "pages": [ + { + "id": "pg_aab64a6160ca", + "workspaceId": "ws_624e65305d73", + "parentId": null, + "title": "Welcome to NoteFlow", + "icon": "🌊", + "cover": "https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?auto=format&fit=crop&w=1200&q=80", + "slug": "welcome-to-noteflow", + "customUrl": "/welcome-to-noteflow", + "kind": "page", + "locked": false, + "fullWidth": true, + "smallText": false, + "verified": true, + "deletedAt": null, + "favoriteBy": [], + "recentBy": [], + "published": true, + "seo": { + "title": "Welcome to NoteFlow", + "description": "Explore the NoteFlow collaborative workspace starter experience." + }, + "share": { + "enabled": true, + "token": "shr_472af8e24f10", + "expiresAt": "", + "allowedDomain": "" + }, + "permissions": [], + "blocks": [ + { + "id": "blk_acdd1911784d", + "type": "heading1", + "text": "Welcome to NoteFlow" + }, + { + "id": "blk_8f6e68abc26e", + "type": "callout", + "text": "This demo shows nested pages, templates, comments, backlinks, tasks, search, notifications, publishing, sharing, and export." + }, + { + "id": "blk_b5f225c04bc0", + "type": "heading2", + "text": "Try these features" + }, + { + "id": "blk_6f56399025f9", + "type": "bullet", + "text": "Create a page with the slash menu" + }, + { + "id": "blk_b15ac48d915e", + "type": "bullet", + "text": "Open the search palette with Cmd/Ctrl + K" + }, + { + "id": "blk_4991b4e505f6", + "type": "bullet", + "text": "Upload files and attach them to pages" + }, + { + "id": "blk_910b3982aa9b", + "type": "heading2", + "text": "Backlinks demo" + }, + { + "id": "blk_f78d3b8a4cdb", + "type": "paragraph", + "text": "Mention [[Welcome to NoteFlow]] from another page to create backlinks." + } + ], + "history": [], + "commentsEnabled": true, + "commentsSummary": { + "total": 0, + "unresolved": 0 + }, + "templateId": null, + "createdAt": "2026-05-05T06:06:21.568Z", + "updatedAt": "2026-05-05T06:06:21.568Z", + "createdBy": "system" + }, + { + "id": "pg_9f6d54e95c3f", + "workspaceId": "ws_55eaa3560f0b", + "parentId": null, + "title": "Home", + "icon": "🏠", + "cover": "", + "slug": "home-3e06c", + "customUrl": "/home-3e06c", + "kind": "page", + "locked": false, + "fullWidth": false, + "smallText": false, + "verified": true, + "deletedAt": null, + "favoriteBy": [ + "usr_374484b3e06c" + ], + "recentBy": [ + { + "userId": "usr_374484b3e06c", + "visitedAt": "2026-05-05T06:06:21.590Z" + } + ], + "published": false, + "seo": { + "title": "Home", + "description": "Personal home page" + }, + "share": { + "enabled": false, + "token": "shr_bca52e268d2c", + "expiresAt": "", + "allowedDomain": "" + }, + "permissions": [], + "blocks": [ + { + "id": "blk_9555db7cfa59", + "type": "heading1", + "text": "Welcome, SCIM Owner" + }, + { + "id": "blk_28bf1f4f877e", + "type": "paragraph", + "text": "Use / to insert blocks, create nested pages, and collaborate in real time." + }, + { + "id": "blk_c4ad3cfc0526", + "type": "todo", + "text": "Create your first project page", + "checked": false + } + ], + "history": [], + "commentsEnabled": true, + "commentsSummary": { + "total": 0, + "unresolved": 0 + }, + "templateId": null, + "createdAt": "2026-05-05T06:06:21.590Z", + "updatedAt": "2026-05-05T06:06:21.590Z", + "createdBy": "usr_374484b3e06c" + } + ], + "comments": [], + "tasks": [ + { + "id": "tsk_c16c99d97195", + "workspaceId": "ws_624e65305d73", + "pageId": "pg_aab64a6160ca", + "title": "Explore NoteFlow demo workspace", + "description": "Review page management, tasks, search, and sharing features.", + "assigneeUserId": null, + "dueDate": "2026-05-06", + "priority": "high", + "status": "in-progress", + "recurring": "weekly", + "reminderAt": "2026-05-05T07:06:21.568Z", + "dependencies": [], + "subItems": [ + { + "id": "sub_19ef0a499b2b", + "text": "Open the Welcome page", + "done": true + } + ], + "milestone": false, + "progress": 40, + "linkedPageId": "pg_aab64a6160ca", + "createdAt": "2026-05-05T06:06:21.568Z", + "updatedAt": "2026-05-05T06:06:21.568Z" + } + ], + "databases": [ + { + "id": "db_9c67b72beebf", + "workspaceId": "ws_624e65305d73", + "pageId": "pg_aab64a6160ca", + "title": "Roadmap Database", + "description": "Track initiatives with multiple workspace views.", + "icon": "🗂️", + "fields": [ + { + "id": "fld_name", + "name": "Name", + "type": "title" + }, + { + "id": "fld_status", + "name": "Status", + "type": "status", + "options": [ + "Backlog", + "In Progress", + "Done" + ] + }, + { + "id": "fld_owner", + "name": "Owner", + "type": "person" + }, + { + "id": "fld_due", + "name": "Due", + "type": "date" + }, + { + "id": "fld_priority", + "name": "Priority", + "type": "select", + "options": [ + "low", + "medium", + "high" + ] + } + ], + "views": [ + { + "id": "view_table", + "name": "Table", + "type": "table" + }, + { + "id": "view_board", + "name": "Board", + "type": "board", + "groupBy": "fld_status" + }, + { + "id": "view_calendar", + "name": "Calendar", + "type": "calendar", + "dateField": "fld_due" + }, + { + "id": "view_timeline", + "name": "Timeline", + "type": "timeline", + "dateField": "fld_due" + }, + { + "id": "view_gallery", + "name": "Gallery", + "type": "gallery" + } + ], + "permissions": [], + "createdAt": "2026-05-05T06:06:21.568Z", + "updatedAt": "2026-05-05T06:06:21.568Z", + "createdBy": "system" + }, + { + "id": "db_20ff55e571f9", + "workspaceId": "ws_55eaa3560f0b", + "pageId": "pg_9f6d54e95c3f", + "title": "My Tasks Database", + "description": "A starter database for milestones, tasks, and planning views.", + "icon": "🗃️", + "fields": [ + { + "id": "fld_name", + "name": "Name", + "type": "title" + }, + { + "id": "fld_status", + "name": "Status", + "type": "status", + "options": [ + "Backlog", + "In Progress", + "Done" + ] + }, + { + "id": "fld_owner", + "name": "Owner", + "type": "person" + }, + { + "id": "fld_due", + "name": "Due", + "type": "date" + }, + { + "id": "fld_priority", + "name": "Priority", + "type": "select", + "options": [ + "low", + "medium", + "high" + ] + } + ], + "views": [ + { + "id": "view_table", + "name": "Table", + "type": "table" + }, + { + "id": "view_board", + "name": "Board", + "type": "board", + "groupBy": "fld_status" + }, + { + "id": "view_calendar", + "name": "Calendar", + "type": "calendar", + "dateField": "fld_due" + }, + { + "id": "view_timeline", + "name": "Timeline", + "type": "timeline", + "dateField": "fld_due" + }, + { + "id": "view_gallery", + "name": "Gallery", + "type": "gallery" + } + ], + "permissions": [], + "createdAt": "2026-05-05T06:06:21.590Z", + "updatedAt": "2026-05-05T06:06:21.590Z", + "createdBy": "usr_374484b3e06c" + } + ], + "databaseRows": [ + { + "id": "row_bd2415ffced1", + "databaseId": "db_9c67b72beebf", + "workspaceId": "ws_624e65305d73", + "pageId": "pg_aab64a6160ca", + "values": { + "fld_name": "Launch public demo", + "fld_status": "In Progress", + "fld_owner": "system", + "fld_due": "2026-05-08", + "fld_priority": "high" + }, + "verified": true, + "createdAt": "2026-05-05T06:06:21.568Z", + "updatedAt": "2026-05-05T06:06:21.568Z", + "createdBy": "system" + }, + { + "id": "row_da33d7d7ddcc", + "databaseId": "db_20ff55e571f9", + "workspaceId": "ws_55eaa3560f0b", + "pageId": "pg_9f6d54e95c3f", + "values": { + "fld_name": "Ship first workspace doc", + "fld_status": "Backlog", + "fld_owner": "usr_374484b3e06c", + "fld_due": "2026-05-12", + "fld_priority": "medium" + }, + "verified": false, + "createdAt": "2026-05-05T06:06:21.590Z", + "updatedAt": "2026-05-05T06:06:21.590Z", + "createdBy": "usr_374484b3e06c" + } + ], + "files": [ + { + "id": "fil_5edc2416b66a", + "workspaceId": "ws_624e65305d73", + "pageId": "pg_aab64a6160ca", + "name": "noteflow-demo.txt", + "type": "text/plain", + "size": 28, + "storagePath": "", + "preview": "NoteFlow demo attachment file", + "versions": [], + "createdAt": "2026-05-05T06:06:21.568Z", + "updatedAt": "2026-05-05T06:06:21.568Z", + "uploadedBy": "system" + } + ], + "notifications": [ + { + "id": "ntf_7fbf291e31b4", + "read": false, + "createdAt": "2026-05-05T06:06:21.590Z", + "delivery": [ + "in-app" + ], + "userId": "usr_374484b3e06c", + "type": "welcome", + "title": "Welcome to NoteFlow", + "body": "Your workspaces are ready. Start with Home or create a page from a template.", + "category": "workspace", + "batchKey": "welcome" + } + ], + "invitations": [], + "activity": [ + { + "id": "act_d6c6609d9131", + "createdAt": "2026-05-05T06:06:21.612Z", + "actorUserId": "usr_374484b3e06c", + "workspaceId": "ws_7761e51b5b27", + "kind": "workspace.scim-provisioned", + "message": "Provisioned provisioned@example.com via SCIM" + }, + { + "id": "act_d175c9eb60a8", + "createdAt": "2026-05-05T06:06:21.592Z", + "actorUserId": "usr_374484b3e06c", + "workspaceId": "ws_7761e51b5b27", + "kind": "workspace.settings.updated", + "message": "Updated security and sharing settings for SCIM's Team Space" + }, + { + "id": "act_4908dbaba848", + "createdAt": "2026-05-05T06:06:21.590Z", + "actorUserId": "usr_374484b3e06c", + "kind": "workspace.created", + "message": "Created initial workspaces", + "workspaceId": "ws_55eaa3560f0b" + } + ], + "templates": [ + { + "id": "tpl_project_brief", + "name": "Project Brief", + "icon": "🚀", + "description": "Goal, scope, milestones, and linked tasks", + "blocks": [ + { + "id": "blk_31897dda77f8", + "type": "heading1", + "text": "Project Brief" + }, + { + "id": "blk_3268755b8c04", + "type": "callout", + "text": "Summarize the mission, owner, and due date." + }, + { + "id": "blk_01fe4d119503", + "type": "heading2", + "text": "Goals" + }, + { + "id": "blk_1138c4a6ce4e", + "type": "bullet", + "text": "Primary outcome" + }, + { + "id": "blk_993548e7c356", + "type": "bullet", + "text": "Success metric" + }, + { + "id": "blk_7ed676108cf2", + "type": "heading2", + "text": "Milestones" + }, + { + "id": "blk_67c06d75ee51", + "type": "todo", + "text": "Define scope", + "checked": false + } + ] + }, + { + "id": "tpl_meeting_notes", + "name": "Meeting Notes", + "icon": "📝", + "description": "Agenda, notes, decisions, and follow-ups", + "blocks": [ + { + "id": "blk_058478507eb1", + "type": "heading1", + "text": "Meeting Notes" + }, + { + "id": "blk_24dfd9856733", + "type": "heading2", + "text": "Agenda" + }, + { + "id": "blk_de5b9c557709", + "type": "bullet", + "text": "Topic 1" + }, + { + "id": "blk_6d33c7a73e15", + "type": "heading2", + "text": "Decisions" + }, + { + "id": "blk_71a3f3263996", + "type": "quote", + "text": "Record key decisions here." + } + ] + }, + { + "id": "tpl_task_hub", + "name": "Task Hub", + "icon": "✅", + "description": "Work tracker with status, priority, and owners", + "blocks": [ + { + "id": "blk_055fb25c5c0a", + "type": "heading1", + "text": "Task Hub" + }, + { + "id": "blk_b059b673ee33", + "type": "paragraph", + "text": "Use the Tasks panel to manage project execution." + }, + { + "id": "blk_96f4f0d7328a", + "type": "bookmark", + "text": "Link related specs or docs here.", + "url": "https://example.com" + } + ] + } + ], + "recentSearches": [], + "emailOutbox": [ + { + "id": "mail_873444c7a050", + "to": "scim-owner@example.com", + "subject": "Welcome to NoteFlow", + "body": "Your workspaces are ready. Start with Home or create a page from a template.", + "createdAt": "2026-05-05T06:06:21.590Z", + "digestKey": "welcome", + "type": "welcome" + } + ], + "magicLinks": [] +} \ No newline at end of file diff --git a/deliverable/noteflow/package.json b/deliverable/noteflow/package.json new file mode 100644 index 000000000..97de278da --- /dev/null +++ b/deliverable/noteflow/package.json @@ -0,0 +1,11 @@ +{ + "name": "noteflow", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "start": "node server.mjs", + "dev": "node --watch server.mjs", + "test": "node --test tests/*.test.mjs" + } +} diff --git a/deliverable/noteflow/public/app.js b/deliverable/noteflow/public/app.js new file mode 100644 index 000000000..cfb475e35 --- /dev/null +++ b/deliverable/noteflow/public/app.js @@ -0,0 +1,1657 @@ +const state = { + data: null, + activeWorkspaceId: null, + activePageId: null, + activeDatabaseId: null, + activeDatabaseView: '', + activePanel: 'comments', + searchOpen: false, + searchResults: [], + searchQuery: '', + searchType: '', + searchWorkspaceId: '', + localPage: null, + dirty: false, + draftRecovered: false, + dragBlockId: null, + selectedBlocks: new Set(), + sse: null, + lastMagicToken: '', + presence: [], + notice: '', +}; + +const BLOCK_TYPES = [ + 'paragraph', 'heading1', 'heading2', 'heading3', 'bullet', 'numbered', 'todo', 'toggle', 'quote', 'callout', 'divider', 'code', + 'equation', 'table', 'columns', 'embed', 'bookmark', 'image', 'video', 'audio', 'pdf', 'synced', +]; + +const app = document.getElementById('app'); + +function draftKey(pageId) { + return `noteflow:draft:${pageId}`; +} + +async function api(path, options = {}) { + const response = await fetch(path, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...(options.headers || {}), + }, + }); + const contentType = response.headers.get('content-type') || ''; + const payload = contentType.includes('application/json') ? await response.json() : await response.text(); + if (!response.ok) throw new Error(payload.error || payload || `Request failed: ${response.status}`); + return payload; +} + +function formatDate(value) { + if (!value) return '—'; + try { + return new Date(value).toLocaleString(); + } catch { + return value; + } +} + +function pageById(id) { + return state.data?.pages?.find((page) => page.id === id) || null; +} + +function workspaceById(id) { + return state.data?.workspaces?.find((workspace) => workspace.id === id) || null; +} + +function pagesForWorkspace(workspaceId) { + return (state.data?.pages || []).filter((page) => page.workspaceId === workspaceId); +} + +function activeWorkspace() { + return workspaceById(state.activeWorkspaceId) || state.data?.workspaces?.[0] || null; +} + +function activePage() { + return state.localPage || pageById(state.activePageId) || null; +} + +function commentsForPage(pageId) { + return (state.data?.comments || []).filter((comment) => comment.pageId === pageId); +} + +function tasksForWorkspace(workspaceId) { + return (state.data?.tasks || []).filter((task) => task.workspaceId === workspaceId); +} + +function databasesForWorkspace(workspaceId) { + return (state.data?.databases || []).filter((database) => database.workspaceId === workspaceId); +} + +function rowsForDatabase(databaseId) { + return (state.data?.databaseRows || []).filter((row) => row.databaseId === databaseId); +} + +function filesForWorkspace(workspaceId) { + return (state.data?.files || []).filter((file) => file.workspaceId === workspaceId); +} + +function invitationsForWorkspace(workspaceId) { + return (state.data?.invitations || []).filter((invitation) => invitation.workspaceId === workspaceId); +} + +function activityForWorkspace(workspaceId) { + return (state.data?.activity || []).filter((entry) => entry.workspaceId === workspaceId); +} + +function databaseById(id) { + return (state.data?.databases || []).find((database) => database.id === id) || null; +} + +function activeDatabase() { + return databaseById(state.activeDatabaseId) || null; +} + +function currentUser() { + return state.data?.user || null; +} + +function userDirectory() { + return state.data?.directory || (currentUser() ? [currentUser()] : []); +} + +function workspaceMembers(workspace) { + const directory = userDirectory(); + return (workspace?.members || []).map((member) => ({ + ...member, + user: directory.find((entry) => entry.id === member.userId), + })); +} + +function currentWorkspaceRole(workspace) { + if (!workspace || !currentUser()) return null; + if (workspace.ownerId === currentUser().id) return 'owner'; + return workspace.members?.find((member) => member.userId === currentUser().id)?.role || null; +} + +function sharedPagesForWorkspace(workspaceId) { + return pagesForWorkspace(workspaceId).filter((page) => !page.deletedAt && ( + page.published + || page.share?.enabled + || (page.permissions || []).length + )); +} + +function toDateTimeLocal(value) { + if (!value) return ''; + try { + return new Date(value).toISOString().slice(0, 16); + } catch { + return ''; + } +} + +function breadcrumbs(page) { + const chain = []; + let cursor = page; + while (cursor) { + chain.unshift(cursor); + cursor = cursor.parentId ? pageById(cursor.parentId) : null; + } + return chain; +} + +function computeToC(page) { + return (page?.blocks || []).filter((block) => ['heading1', 'heading2', 'heading3'].includes(block.type)); +} + +function saveDraft(page) { + localStorage.setItem(draftKey(page.id), JSON.stringify(page)); + state.dirty = true; +} + +function clearDraft(pageId) { + localStorage.removeItem(draftKey(pageId)); + state.dirty = false; + state.draftRecovered = false; +} + +function restoreDraftIfPresent(page) { + const raw = localStorage.getItem(draftKey(page.id)); + if (!raw) return structuredClone(page); + try { + const parsed = JSON.parse(raw); + state.draftRecovered = true; + state.notice = 'Recovered an offline draft for this page.'; + return parsed; + } catch { + return structuredClone(page); + } +} + +function selectWorkspace(workspaceId) { + state.activeWorkspaceId = workspaceId; + localStorage.setItem('noteflow:workspace', workspaceId); + const candidate = pagesForWorkspace(workspaceId).find((page) => !page.deletedAt) || null; + selectPage(candidate?.id || null, false); +} + +function selectPage(pageId, rerender = true) { + state.activePageId = pageId; + state.activeDatabaseId = null; + localStorage.setItem('noteflow:page', pageId || ''); + const page = pageById(pageId); + state.localPage = page ? restoreDraftIfPresent(page) : null; + state.selectedBlocks = new Set(); + connectEvents(); + if (rerender) render(); +} + +function selectDatabase(databaseId, rerender = true) { + state.activeDatabaseId = databaseId; + const database = databaseById(databaseId); + state.activeDatabaseView = database?.views?.[0]?.id || ''; + state.activePanel = 'databases'; + if (rerender) render(); +} + +let saveTimer = null; +function scheduleSave() { + clearTimeout(saveTimer); + saveTimer = setTimeout(() => saveCurrentPage(), 700); +} + +async function saveCurrentPage() { + const page = activePage(); + if (!page || !currentUser()) return; + try { + await api(`/api/pages/${page.id}`, { method: 'PUT', body: JSON.stringify(page) }); + clearDraft(page.id); + state.notice = 'Saved'; + await refresh(false); + } catch (error) { + state.notice = error.message; + renderNotice(); + } +} + +function touchPageVisit() { + const page = activePage(); + if (!page || !currentUser()) return; + api(`/api/pages/${page.id}/recent`, { method: 'POST', body: '{}' }).catch(() => {}); +} + +async function refresh(initial = false) { + state.data = await api('/api/bootstrap'); + if (!state.activeWorkspaceId) state.activeWorkspaceId = localStorage.getItem('noteflow:workspace') || state.data.workspaces?.[0]?.id || null; + if (!workspaceById(state.activeWorkspaceId)) state.activeWorkspaceId = state.data.workspaces?.[0]?.id || null; + const defaultPageId = localStorage.getItem('noteflow:page') || pagesForWorkspace(state.activeWorkspaceId).find((page) => !page.deletedAt)?.id || state.data.pages?.[0]?.id || null; + if (!state.activePageId || !pageById(state.activePageId)) state.activePageId = defaultPageId; + const current = pageById(state.activePageId); + if (!state.localPage || !state.dirty || !initial) state.localPage = current ? restoreDraftIfPresent(current) : null; + render(); + touchPageVisit(); + connectEvents(); +} + +function renderNotice() { + const node = document.querySelector('[data-role="notice"]'); + if (node) node.innerHTML = state.notice ? `
${escapeHtml(state.notice)}
` : ''; +} + +function escapeHtml(value) { + return String(value || '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +function snippet(value, length = 90) { + const text = String(value || ''); + return text.length > length ? `${text.slice(0, length)}…` : text; +} + +function buildPageTree(workspaceId, parentId = null, depth = 0) { + return pagesForWorkspace(workspaceId) + .filter((page) => page.parentId === parentId && !page.deletedAt) + .sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt)) + .map((page) => ` +
+ + ${buildPageTree(workspaceId, page.id, depth + 1)} +
`) + .join(''); +} + +function renderSidebar(workspace, page) { + const pages = pagesForWorkspace(workspace?.id || ''); + const databases = databasesForWorkspace(workspace?.id || ''); + const favorites = pages.filter((item) => item.favoriteBy?.includes(currentUser()?.id)); + const sharedPages = sharedPagesForWorkspace(workspace?.id || ''); + const recent = pages.filter((item) => item.recentBy?.some((entry) => entry.userId === currentUser()?.id)).sort((a, b) => { + const aDate = a.recentBy?.find((entry) => entry.userId === currentUser()?.id)?.visitedAt || ''; + const bDate = b.recentBy?.find((entry) => entry.userId === currentUser()?.id)?.visitedAt || ''; + return String(bDate).localeCompare(String(aDate)); + }); + const trash = pages.filter((item) => item.deletedAt); + return ` + `; +} + +function renderAuthSection() { + if (currentUser()) return ''; + const teamWorkspaces = (state.data?.workspaces || []).filter((workspace) => workspace.kind === 'team'); + return ` +
+

Sign in to collaborate

+

Use email/password, demo OAuth, or a magic link. You can still browse the public demo workspace below.

+
+
+ Create account + + + + +
+
+ Sign in + + + + +
+ + +
+
+
+
+ Magic link +
+ + +
+ ${state.lastMagicToken ? `
Dev token: ${escapeHtml(state.lastMagicToken)}
` : ''} +
+
+ Enterprise SSO / SAML demo + + + + + +
+
`; +} + +function databaseField(database, type) { + return (database?.fields || []).find((field) => field.type === type) || database?.fields?.[0] || null; +} + +function rowTitle(database, row) { + const field = databaseField(database, 'title'); + return String(row?.values?.[field?.id] || 'Untitled row'); +} + +function rowStatus(database, row) { + const field = databaseField(database, 'status'); + return String(row?.values?.[field?.id] || 'Unspecified'); +} + +function rowDue(database, row) { + const field = databaseField(database, 'date'); + return String(row?.values?.[field?.id] || ''); +} + +function rowOwner(database, row) { + const field = databaseField(database, 'person'); + const userId = row?.values?.[field?.id]; + return userDirectory().find((entry) => entry.id === userId)?.name || String(userId || 'Unassigned'); +} + +function renderBlock(block, index) { + return ` +
+
⋮⋮
+
+
+ + + ${block.type === 'todo' ? `` : ''} +
+ + ${['bookmark','embed','image','video','audio','pdf'].includes(block.type) ? `` : ''} + ${block.type === 'code' ? `
${escapeHtml(block.text || '')}
` : ''} + ${String(block.text || '').startsWith('/') ? '
Slash commands: /heading1 /heading2 /bullet /todo /quote /code /image /bookmark /synced
' : ''} +
+
+ + + +
+
`; +} + +function renderEditor(workspace, page) { + if (!page) return '
Create or select a page to begin.
'; + const breadcrumbsHtml = breadcrumbs(page).map((item) => `${escapeHtml(item.title)}`).join(' / '); + const toc = computeToC(page); + const readOnly = !currentUser(); + const backlinks = page.backlinks || []; + const publicUrl = page.published ? `${location.origin}/p/${page.slug}` : ''; + return ` +
+
+
${escapeHtml(workspace?.name || 'Workspace')}
+

${escapeHtml(page.title)}

+
+
+ + ${currentUser() ? `` : ''} +
+
+ ${renderAuthSection()} +
+
+
+ +
+
${escapeHtml(page.icon || '📄')}
+
+ +
Custom URL: ${escapeHtml(page.customUrl || `/p/${page.slug}`)} · ${page.verified ? 'Verified' : 'Unverified'} · ${page.published ? 'Published' : 'Private'}
+
+
+
+ + + + + ${currentUser() ? ` + + + + + + ` : ''} + ${publicUrl ? `Open public page ↗` : ''} +
+
+
+
${state.notice ? `
${escapeHtml(state.notice)}
` : ''}
+ ${state.draftRecovered ? '
Offline draft restored. Save to persist it to the server.
' : ''} +
+ ${currentUser() ? ` + + + + + + + + ` : ''} +
+
${(page.blocks || []).map(renderBlock).join('')}
+ ${currentUser() ? '
' : ''} +
+

Table of contents

+ ${toc.length ? toc.map((block) => `${escapeHtml(block.text || block.type)}`).join('') : '
Add headings to generate a TOC.
'} +
+
+

Backlinks

+ ${backlinks.length ? backlinks.map((item) => ``).join('') : '
No backlinks yet. Mention [[Page Title]] in another page.
'} +
+
+
`; +} + +function renderCommentsPanel(page) { + const comments = commentsForPage(page?.id).sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt)); + const directory = userDirectory(); + const renderThread = (parentId = null) => comments + .filter((comment) => comment.parentCommentId === parentId) + .map((comment) => { + const author = directory.find((entry) => entry.id === comment.authorUserId) || { name: 'Unknown', avatarColor: '#94a3b8' }; + return `
+
${escapeHtml(author.name?.[0] || '?')}
${escapeHtml(author.name)}${comment.resolved ? 'Resolved' : 'Open'}
+
${escapeHtml(comment.text)}
+
${formatDate(comment.createdAt)} ${comment.blockId ? '· inline block comment' : ''}
+
+ ${currentUser() ? ` + ` : ''} +
+ ${renderThread(comment.id)} +
`; + }).join(''); + return ` +
+

Page comments & discussions

+ ${currentUser() ? ` +
+ + + +
` : '
Sign in to comment.
'} +
+ ${renderThread() || '
No comments yet.
'}`; +} + +function renderTasksPanel(workspace) { + const tasks = tasksForWorkspace(workspace?.id || ''); + const mine = tasks.filter((task) => task.assigneeUserId === currentUser()?.id); + const open = tasks.filter((task) => task.status !== 'done'); + const milestones = tasks.filter((task) => task.milestone); + const workload = Object.values(tasks.reduce((acc, task) => { + const key = task.assigneeUserId || 'unassigned'; + acc[key] ||= { label: userDirectory().find((entry) => entry.id === task.assigneeUserId)?.name || 'Unassigned', count: 0 }; + acc[key].count += 1; + return acc; + }, {})); + return ` +
+
Open tasks
${open.length}
+
My tasks
${mine.length}
+
Milestones
${milestones.length}
+
+ ${currentUser() ? ` +
+

Create task

+ + +
+ + + + + + +
+ + +
` : ''} +
+
+

Team task views

+ ${tasks.length ? tasks.map((task) => `
+ ${escapeHtml(task.title)} +
${escapeHtml(task.status)} · ${escapeHtml(task.priority)} · due ${escapeHtml(task.dueDate || 'n/a')}
+
${escapeHtml(task.description || '')}
+
Dependencies: ${(task.dependencies || []).join(', ') || 'none'} · Progress ${task.progress || 0}%
+ ${currentUser() ? `
` : ''} +
`).join('') : '
No tasks in this workspace.
'} +
+
+

Workload view

+ ${workload.map((item) => `
${escapeHtml(item.label)}
${item.count} tasks assigned
`).join('') || '
No workload data yet.
'} +
`; +} + +function renderFilesPanel(workspace, page) { + const files = filesForWorkspace(workspace?.id || ''); + const usedMb = (files.reduce((sum, file) => sum + (file.size || 0), 0) / (1024 * 1024)).toFixed(2); + return ` +
+

Files & media

+
Usage: ${usedMb} MB / ${(workspace?.settings?.storageQuotaMb || 0)} MB · Signed URL delivery enabled
+
+ ${currentUser() ? ` +
+ + +
` : ''} + ${files.map((file) => `
+ ${escapeHtml(file.name)} +
${escapeHtml(file.type)} · ${(file.size / 1024).toFixed(1)} KB · versions ${file.versions?.length || 0}
+
+ Open + ${currentUser() ? `` : ''} +
+
`).join('') || '
No files yet.
'}`; +} + +function renderNotificationsPanel() { + const notifications = state.data?.notifications || []; + const emailOutbox = state.data?.emailOutbox || []; + return ` +
+

Notification inbox

+ ${(notifications.length ? notifications : []).map((notification) => `
+ ${escapeHtml(notification.title)} +
${escapeHtml(notification.body)}
+
${formatDate(notification.createdAt)} · ${escapeHtml(notification.type)}
+ ${!notification.read && currentUser() ? `` : ''} +
`).join('') || '
No notifications yet.
'} +
+
+

Email digest preview

+ ${emailOutbox.map((mail) => `
${escapeHtml(mail.subject)}
${escapeHtml(mail.body)}
to ${escapeHtml(mail.to)} · ${formatDate(mail.createdAt)}
`).join('') || '
No email previews queued.
'} +
+ ${currentUser() ? ` +
+

Preferences

+ + + + + + +
` : ''}`; +} + +function renderHistoryPanel(page) { + return ` +
+

Page history & versioning

+ ${(page?.history || []).map((version) => `
+ ${escapeHtml(version.title)} +
${formatDate(version.createdAt)} · ${escapeHtml(version.userId)}
+ ${currentUser() ? `` : ''} +
`).join('') || '
No snapshots yet. Save page updates to create history.
'} +
`; +} + +function renderSearchImportPanel(workspace, page) { + return ` +
+

Search & knowledge

+
+ ${(state.searchResults || []).slice(0, 8).map((result) => `
${escapeHtml(result.title)}
${escapeHtml(result.snippet)}
${result.type} · ${formatDate(result.updatedAt)}
`).join('') || '
Run a search to see ranking, snippets, and permission-trimmed results.
'} +
+ ${currentUser() ? ` +
+

Import

+
+ + +
+ + +
+
+

Workspace export

+ +
` : ''}`; +} + +function renderDatabaseView(database, rows) { + const selectedView = database?.views?.find((view) => view.id === state.activeDatabaseView) || database?.views?.[0] || { type: 'table', name: 'Table' }; + const type = selectedView.type || 'table'; + if (type === 'board') { + const statusField = selectedView.groupBy || databaseField(database, 'status')?.id; + const groups = [...new Set(rows.map((row) => row.values?.[statusField] || 'Unspecified'))]; + return `
${groups.map((group) => `

${escapeHtml(group)}

${rows.filter((row) => (row.values?.[statusField] || 'Unspecified') === group).map((row) => `
${escapeHtml(rowTitle(database, row))}
${escapeHtml(rowOwner(database, row))} · ${escapeHtml(rowDue(database, row) || 'No date')}
${currentUser() ? `` : ''}
`).join('') || '
No rows
'}
`).join('')}
`; + } + if (type === 'calendar' || type === 'timeline') { + return `
${rows.slice().sort((a, b) => String(rowDue(database, a)).localeCompare(String(rowDue(database, b)))).map((row) => `
${escapeHtml(rowDue(database, row) || 'No date')}
${escapeHtml(rowTitle(database, row))}
${escapeHtml(rowStatus(database, row))} · ${escapeHtml(rowOwner(database, row))}
`).join('') || '
No scheduled rows yet.
'}
`; + } + if (type === 'gallery') { + return ``; + } + return `
${(database?.fields || []).map((field) => ``).join('')}${rows.map((row) => `${(database?.fields || []).map((field) => ``).join('')}`).join('') || ''}
${escapeHtml(field.name)}Actions
${escapeHtml(row.values?.[field.id] ?? '')}
${currentUser() ? `` : ''}
No rows yet.
`; +} + +function renderDatabasesPanel(workspace) { + const database = activeDatabase() || databasesForWorkspace(workspace?.id || '')[0] || null; + const rows = database ? rowsForDatabase(database.id) : []; + const selectedView = database?.views?.find((view) => view.id === state.activeDatabaseView) || database?.views?.[0] || null; + if (!database) { + return `

Databases

Create a database to manage projects, tasks, milestones, and team knowledge.
${currentUser() ? '' : ''}
`; + } + return ` +
+

${escapeHtml(database.icon || '🗂️')} ${escapeHtml(database.title)}

${rows.length} rows
+
${escapeHtml(database.description || 'Workspace database')}
+
+
Views
${database.views?.length || 0}
+
Rows
${rows.length}
+
Verified
${rows.filter((row) => row.verified).length}
+
+
+ + ${currentUser() ? `` : ''} +
+
+ ${currentUser() ? `

Add row

${(database.fields || []).map((field) => { + if (field.type === 'status' || field.type === 'select') return ``; + if (field.type === 'date') return ``; + return ``; + }).join('')}
` : ''} +
+

${escapeHtml(selectedView?.name || 'View')}

+ ${renderDatabaseView(database, rows)} +
`; +} + +function renderAdminPanel(workspace, page) { + const workspaceRole = currentWorkspaceRole(workspace); + const invitations = invitationsForWorkspace(workspace?.id || ''); + const activity = activityForWorkspace(workspace?.id || '').slice(0, 16); + const database = activeDatabase() || databasesForWorkspace(workspace?.id || '')[0] || null; + const pagePermissions = page?.permissions || []; + const databasePermissions = database?.permissions || []; + const assignableUsers = userDirectory().filter((entry) => entry.id !== currentUser()?.id); + return ` +
+

Workspace admin & security

+
${workspaceRole === 'owner' ? 'Owner controls for sharing, enterprise identity, and provisioning.' : 'Read-only audit and identity overview for this workspace.'}
+
+ ${workspaceRole === 'owner' ? ` +
+

Workspace settings

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

SCIM provisioning

+
+ + + + + +
+ +
` : ''} +
+

Pending and accepted invitations

+ ${invitations.map((invitation) => `
${escapeHtml(invitation.email)}
${escapeHtml(invitation.role)} · invited ${formatDate(invitation.createdAt)} · ${invitation.acceptedAt ? `accepted ${formatDate(invitation.acceptedAt)}` : 'pending'}
`).join('') || '
No invitations yet.
'} +
+
+

Identity & device sessions

+
+ ${escapeHtml(currentUser()?.name || 'Guest')} +
Auth methods: ${(currentUser()?.identity?.authMethods || []).map(escapeHtml).join(', ') || 'none'}
+
Last identity audit: ${formatDate(currentUser()?.identity?.lastAuditAt)}
+
+ ${currentUser() ? `` : ''} +
+
+ ${(state.data?.sessions || []).map((session) => `
${escapeHtml(session.label || 'Session')}
${escapeHtml(session.userAgent || 'unknown')} · ${escapeHtml(session.ipAddress || 'unknown')} · last seen ${formatDate(session.lastSeenAt)}
`).join('') || '
No active sessions.
'} +
+
+

Page sharing, SEO & permissions

+ ${page ? ` +
+
+ + + + + + +
+
+ + +
+ +
+
+ ${pagePermissions.map((rule) => { + const user = userDirectory().find((entry) => entry.id === rule.userId); + return `
${escapeHtml(user?.name || rule.userId)}
Page access: ${escapeHtml(rule.access)}
`; + }).join('') || '
No page-specific grants yet.
'} + ${workspaceRole === 'owner' || workspaceRole === 'editor' ? `
+ + + +
` : ''} +
` : '
Select a page to manage sharing.
'} +
+
+

Database permissions

+ ${database ? ` + ${(databasePermissions.map((rule) => { + const user = userDirectory().find((entry) => entry.id === rule.userId); + return `
${escapeHtml(user?.name || rule.userId)}
Database access: ${escapeHtml(rule.access)}
`; + }).join('')) || '
No database-specific grants yet.
'} + ${workspaceRole === 'owner' || workspaceRole === 'editor' ? `
+ + + +
` : ''}` : '
Open a database to manage database-level access.
'} +
+
+

Workspace activity feed

+ ${activity.map((entry) => `
${escapeHtml(entry.kind)}
${escapeHtml(entry.message || '')}
${formatDate(entry.createdAt)}
`).join('') || '
No activity yet.
'} +
`; +} + +function renderDetails(workspace, page) { + const members = workspaceMembers(workspace); + return ` + `; +} + +function renderSearchModal() { + if (!state.searchOpen) return ''; + return ` +
+
+
+
+ + +
+
${(state.searchResults || []).map((result) => `
${escapeHtml(result.title)}
${escapeHtml(result.snippet)}
${escapeHtml(result.type)} · ${formatDate(result.updatedAt)}
${result.type === 'page' ? `` : ''}${result.type === 'database' ? `` : ''}${result.type === 'database_row' ? `` : ''}
`).join('') || '
Type to search.
'}
+
+
`; +} + +function render() { + const workspace = activeWorkspace(); + const page = activePage(); + app.innerHTML = ` +
+ ${renderSidebar(workspace, page)} +
${renderEditor(workspace, page)}
+ ${renderDetails(workspace, page)} +
+ ${renderSearchModal()}`; + document.body.classList.toggle('modal-open', state.searchOpen); + attachListeners(); +} + +function markdownShortcut(block) { + const text = String(block.text || ''); + const slash = text.trim().slice(1); + if (text.startsWith('/')) { + if (BLOCK_TYPES.includes(slash)) { + block.type = slash; + block.text = ''; + return block; + } + } + if (text.startsWith('# ')) { block.type = 'heading1'; block.text = text.slice(2); } + if (text.startsWith('## ')) { block.type = 'heading2'; block.text = text.slice(3); } + if (text.startsWith('### ')) { block.type = 'heading3'; block.text = text.slice(4); } + if (text.startsWith('- [ ] ')) { block.type = 'todo'; block.text = text.slice(6); block.checked = false; } + if (text.startsWith('- [x] ')) { block.type = 'todo'; block.text = text.slice(6); block.checked = true; } + if (text.startsWith('- ')) { block.type = 'bullet'; block.text = text.slice(2); } + if (/^\d+\. /.test(text)) { block.type = 'numbered'; block.text = text.replace(/^\d+\. /, ''); } + if (text.startsWith('> ')) { block.type = 'quote'; block.text = text.slice(2); } + return block; +} + +function mutateLocalPage(mutator) { + if (!state.localPage) return; + mutator(state.localPage); + state.localPage.updatedAt = new Date().toISOString(); + saveDraft(state.localPage); +} + +function findBlock(blockId) { + return activePage()?.blocks?.find((block) => block.id === blockId) || null; +} + +function attachListeners() { + app.querySelectorAll('[data-action="open-page"]').forEach((button) => button.addEventListener('click', () => selectPage(button.dataset.pageId))); + app.querySelectorAll('[data-action="open-database"]').forEach((button) => button.addEventListener('click', () => selectDatabase(button.dataset.databaseId))); + app.querySelectorAll('[data-action="workspace-select"]').forEach((select) => select.addEventListener('change', () => selectWorkspace(select.value))); + app.querySelectorAll('[data-action="set-panel"]').forEach((button) => button.addEventListener('click', () => { state.activePanel = button.dataset.panel; render(); })); + app.querySelectorAll('[data-action="open-search"]').forEach((button) => button.addEventListener('click', () => { state.searchOpen = true; render(); bindSearchInputs(); })); + app.querySelectorAll('[data-action="close-search"]').forEach((button) => button.addEventListener('click', () => { state.searchOpen = false; render(); })); + app.querySelectorAll('[data-form]').forEach((form) => form.addEventListener('submit', handleFormSubmit)); + app.querySelectorAll('[data-action="oauth"]').forEach((button) => button.addEventListener('click', () => oauth(button.dataset.provider))); + app.querySelectorAll('[data-action="consume-magic"]').forEach((button) => button.addEventListener('click', consumeMagicToken)); + app.querySelectorAll('[data-action="logout"]').forEach((button) => button.addEventListener('click', logout)); + app.querySelectorAll('[data-action="new-page"]').forEach((button) => button.addEventListener('click', () => createPage())); + app.querySelectorAll('[data-action="new-page-template"]').forEach((button) => button.addEventListener('click', () => createPage(true))); + app.querySelectorAll('[data-action="create-database"]').forEach((button) => button.addEventListener('click', createDatabase)); + app.querySelectorAll('[data-action="create-workspace"]').forEach((button) => button.addEventListener('click', createWorkspace)); + app.querySelectorAll('[data-action="restore-page"]').forEach((button) => button.addEventListener('click', () => restorePage(button.dataset.pageId))); + app.querySelectorAll('[data-action="page-title"]').forEach((input) => input.addEventListener('input', () => mutateLocalPage((page) => { page.title = input.value; scheduleSave(); }))); + app.querySelectorAll('[data-action="page-toggle"]').forEach((input) => input.addEventListener('change', () => mutateLocalPage((page) => { page[input.dataset.field] = input.checked; scheduleSave(); render(); }))); + app.querySelectorAll('[data-action="favorite-page"]').forEach((button) => button.addEventListener('click', favoritePage)); + app.querySelectorAll('[data-action="duplicate-page"]').forEach((button) => button.addEventListener('click', duplicatePage)); + app.querySelectorAll('[data-action="publish-page"]').forEach((button) => button.addEventListener('click', publishPage)); + app.querySelectorAll('[data-action="share-page"]').forEach((button) => button.addEventListener('click', sharePage)); + app.querySelectorAll('[data-action="trash-page"]').forEach((button) => button.addEventListener('click', trashPage)); + app.querySelectorAll('[data-action="save-page"]').forEach((button) => button.addEventListener('click', saveCurrentPage)); + app.querySelectorAll('[data-action="page-settings"]').forEach((button) => button.addEventListener('click', updatePageSettings)); + app.querySelectorAll('[data-action="add-block"]').forEach((button) => button.addEventListener('click', addBlock)); + app.querySelectorAll('[data-action="block-text"]').forEach((textarea) => textarea.addEventListener('input', () => mutateLocalPage((page) => { + const block = page.blocks.find((item) => item.id === textarea.dataset.blockId); + block.text = textarea.value; + markdownShortcut(block); + scheduleSave(); + }))); + app.querySelectorAll('[data-action="block-url"]').forEach((input) => input.addEventListener('input', () => mutateLocalPage((page) => { + const block = page.blocks.find((item) => item.id === input.dataset.blockId); + block.url = input.value; + scheduleSave(); + }))); + app.querySelectorAll('[data-action="block-type"]').forEach((select) => select.addEventListener('change', () => mutateLocalPage((page) => { + const block = page.blocks.find((item) => item.id === select.dataset.blockId); + block.type = select.value; + scheduleSave(); + render(); + }))); + app.querySelectorAll('[data-action="select-block"]').forEach((checkbox) => checkbox.addEventListener('change', () => { + if (checkbox.checked) state.selectedBlocks.add(checkbox.dataset.blockId); else state.selectedBlocks.delete(checkbox.dataset.blockId); + render(); + })); + app.querySelectorAll('[data-action="toggle-check"]').forEach((checkbox) => checkbox.addEventListener('change', () => mutateLocalPage((page) => { + const block = page.blocks.find((item) => item.id === checkbox.dataset.blockId); + block.checked = checkbox.checked; + scheduleSave(); + }))); + app.querySelectorAll('[data-action="duplicate-block"]').forEach((button) => button.addEventListener('click', () => duplicateBlock(button.dataset.blockId))); + app.querySelectorAll('[data-action="delete-block"]').forEach((button) => button.addEventListener('click', () => deleteBlock(button.dataset.blockId))); + app.querySelectorAll('[data-action="comment-block"]').forEach((button) => button.addEventListener('click', () => quickComment(button.dataset.blockId))); + app.querySelectorAll('[data-action="bulk-delete"]').forEach((button) => button.addEventListener('click', bulkDelete)); + app.querySelectorAll('[data-action="bulk-duplicate"]').forEach((button) => button.addEventListener('click', bulkDuplicate)); + app.querySelectorAll('[data-action="download-ics"]').forEach((button) => button.addEventListener('click', downloadIcs)); + app.querySelectorAll('[data-action="advance-task"]').forEach((button) => button.addEventListener('click', () => advanceTask(button.dataset.taskId))); + app.querySelectorAll('[data-action="link-task-page"]').forEach((button) => button.addEventListener('click', () => linkTaskPage(button.dataset.taskId))); + app.querySelectorAll('[data-action="replace-file"]').forEach((button) => button.addEventListener('click', () => replaceFile(button.dataset.fileId))); + app.querySelectorAll('[data-action="mark-read"]').forEach((button) => button.addEventListener('click', () => markRead(button.dataset.notificationId))); + app.querySelectorAll('[data-action="resolve-comment"]').forEach((button) => button.addEventListener('click', () => resolveComment(button.dataset.commentId))); + app.querySelectorAll('[data-action="reply-comment"]').forEach((button) => button.addEventListener('click', () => replyComment(button.dataset.commentId))); + app.querySelectorAll('[data-action="restore-version"]').forEach((button) => button.addEventListener('click', () => restoreVersion(button.dataset.versionId))); + app.querySelectorAll('[data-action="export-page"]').forEach((button) => button.addEventListener('click', () => exportPage(button.dataset.format))); + app.querySelectorAll('[data-action="export-database"]').forEach((button) => button.addEventListener('click', () => exportDatabase(button.dataset.format))); + app.querySelectorAll('[data-action="export-workspace"]').forEach((button) => button.addEventListener('click', exportWorkspace)); + app.querySelectorAll('[data-action="database-view-select"]').forEach((select) => select.addEventListener('change', () => { state.activeDatabaseView = select.value; render(); })); + app.querySelectorAll('[data-action="edit-row"]').forEach((button) => button.addEventListener('click', () => editRow(button.dataset.rowId))); + app.querySelectorAll('[data-action="advance-row"]').forEach((button) => button.addEventListener('click', () => advanceRow(button.dataset.rowId))); + app.querySelectorAll('[data-action="verify-row"]').forEach((button) => button.addEventListener('click', () => verifyRow(button.dataset.rowId))); + app.querySelectorAll('[data-action="setup-2fa"]').forEach((button) => button.addEventListener('click', setupTwoFactor)); + attachDragAndDrop(); +} + +function bindSearchInputs() { + const input = document.getElementById('search-input'); + const type = document.getElementById('search-type'); + const workspace = document.getElementById('search-workspace'); + if (!input) return; + const run = async () => { + if (!currentUser()) return; + state.searchQuery = input.value; + state.searchType = type.value; + state.searchWorkspaceId = workspace.value; + const query = new URLSearchParams({ q: state.searchQuery, type: state.searchType, workspaceId: state.searchWorkspaceId }); + state.searchResults = await api(`/api/search?${query.toString()}`); + render(); + bindSearchInputs(); + document.getElementById('search-input')?.focus(); + document.getElementById('search-input')?.setSelectionRange(input.value.length, input.value.length); + }; + input.addEventListener('input', run); + type.addEventListener('change', run); + workspace.addEventListener('change', run); + setTimeout(() => input.focus(), 0); +} + +function attachDragAndDrop() { + app.querySelectorAll('[data-action="drag-block"]').forEach((card) => { + card.addEventListener('dragstart', () => { + state.dragBlockId = card.dataset.blockId; + card.classList.add('dragging'); + }); + card.addEventListener('dragend', () => { + state.dragBlockId = null; + card.classList.remove('dragging'); + }); + card.addEventListener('dragover', (event) => event.preventDefault()); + card.addEventListener('drop', () => { + if (!state.dragBlockId || state.dragBlockId === card.dataset.blockId) return; + mutateLocalPage((page) => { + const from = page.blocks.findIndex((block) => block.id === state.dragBlockId); + const to = page.blocks.findIndex((block) => block.id === card.dataset.blockId); + const [moved] = page.blocks.splice(from, 1); + page.blocks.splice(to, 0, moved); + scheduleSave(); + }); + render(); + }); + }); +} + +async function handleFormSubmit(event) { + event.preventDefault(); + const form = event.currentTarget; + const formData = new FormData(form); + try { + if (form.dataset.form === 'register') { + await api('/api/auth/register', { method: 'POST', body: JSON.stringify(Object.fromEntries(formData.entries())) }); + state.lastMagicToken = ''; + await refresh(); + return; + } + if (form.dataset.form === 'login') { + await api('/api/auth/login', { method: 'POST', body: JSON.stringify(Object.fromEntries(formData.entries())) }); + await refresh(); + return; + } + if (form.dataset.form === 'magic-link') { + const result = await api('/api/auth/magic-link/request', { method: 'POST', body: JSON.stringify(Object.fromEntries(formData.entries())) }); + state.lastMagicToken = result.token; + state.notice = 'Magic link token generated for local development.'; + render(); + return; + } + if (form.dataset.form === 'sso-login') { + await api('/api/auth/sso', { method: 'POST', body: JSON.stringify(Object.fromEntries(formData.entries())) }); + await refresh(); + return; + } + if (form.dataset.form === 'comment') { + const page = activePage(); + await api(`/api/pages/${page.id}/comments`, { + method: 'POST', + body: JSON.stringify({ + text: formData.get('text'), + mentions: String(formData.get('mentions') || '').split(',').map((item) => item.trim()).filter(Boolean), + }), + }); + await refresh(); + return; + } + if (form.dataset.form === 'task') { + const deps = String(formData.get('dependencies') || '').split(',').map((item) => item.trim()).filter(Boolean); + await api('/api/tasks', { + method: 'POST', + body: JSON.stringify({ + workspaceId: activeWorkspace().id, + pageId: activePage()?.id || null, + title: formData.get('title'), + description: formData.get('description'), + dueDate: formData.get('dueDate'), + priority: formData.get('priority'), + status: formData.get('status'), + assigneeUserId: formData.get('assigneeUserId') || null, + recurring: formData.get('recurring'), + dependencies: deps, + milestone: Boolean(formData.get('milestone')), + linkedPageId: activePage()?.id || null, + }), + }); + await refresh(); + return; + } + if (form.dataset.form === 'upload') { + const file = formData.get('file'); + if (!(file instanceof File)) throw new Error('Choose a file first'); + const base64 = await readFileAsBase64(file); + await api('/api/files', { + method: 'POST', + body: JSON.stringify({ workspaceId: activeWorkspace().id, pageId: activePage()?.id || null, name: file.name, type: file.type, data: base64 }), + }); + await refresh(); + return; + } + if (form.dataset.form === 'invite') { + await api(`/api/workspaces/${activeWorkspace().id}/invite`, { method: 'POST', body: JSON.stringify(Object.fromEntries(formData.entries())) }); + await refresh(); + return; + } + if (form.dataset.form === 'workspace-settings') { + const payload = { + domainRestriction: formData.get('domainRestriction'), + storageQuotaMb: Number(formData.get('storageQuotaMb') || 250), + databasePermissions: formData.get('databasePermissions'), + allowGuests: formData.get('allowGuests') === 'on', + publicSharing: formData.get('publicSharing') === 'on', + samlEnabled: formData.get('samlEnabled') === 'on', + scimEnabled: formData.get('scimEnabled') === 'on', + }; + await api(`/api/workspaces/${activeWorkspace().id}/settings`, { method: 'PUT', body: JSON.stringify(payload) }); + await refresh(); + state.activePanel = 'admin'; + return; + } + if (form.dataset.form === 'notification-preferences') { + const payload = Object.fromEntries([...formData.keys()].map((key) => [key, true])); + const names = ['email', 'mentions', 'comments', 'assignments', 'digest']; + for (const name of names) payload[name] = formData.get(name) === 'on'; + await api('/api/preferences/notifications', { method: 'PUT', body: JSON.stringify(payload) }); + await refresh(); + return; + } + if (form.dataset.form === 'import') { + await api('/api/import', { + method: 'POST', + body: JSON.stringify({ + workspaceId: activeWorkspace().id, + parentId: activePage()?.id || null, + format: formData.get('format'), + title: formData.get('title'), + content: formData.get('content'), + }), + }); + await refresh(); + return; + } + if (form.dataset.form === 'database-row') { + const database = activeDatabase(); + const values = Object.fromEntries((database?.fields || []).map((field) => [field.id, formData.get(field.id)])); + await api(`/api/databases/${database.id}/rows`, { + method: 'POST', + body: JSON.stringify({ values, pageId: activePage()?.id || null }), + }); + await refresh(); + state.activePanel = 'databases'; + state.activeDatabaseId = database.id; + return; + } + if (form.dataset.form === 'page-sharing') { + const page = activePage(); + const payload = { + customUrl: String(formData.get('customUrl') || ''), + seo: { + title: String(formData.get('seoTitle') || page.title), + description: String(formData.get('seoDescription') || ''), + }, + }; + await api(`/api/pages/${page.id}`, { method: 'PUT', body: JSON.stringify(payload) }); + await api(`/api/pages/${page.id}/publish`, { + method: 'POST', + body: JSON.stringify({ + published: formData.get('published') === 'on', + slug: String(formData.get('slug') || page.slug), + allowedDomain: String(formData.get('allowedDomain') || ''), + expiresAt: formData.get('expiresAt') ? new Date(String(formData.get('expiresAt'))).toISOString() : '', + seo: payload.seo, + }), + }); + await api(`/api/pages/${page.id}/share`, { + method: 'POST', + body: JSON.stringify({ + enabled: formData.get('shared') === 'on', + allowedDomain: String(formData.get('allowedDomain') || ''), + expiresAt: formData.get('expiresAt') ? new Date(String(formData.get('expiresAt'))).toISOString() : '', + }), + }); + await refresh(); + state.activePanel = 'admin'; + return; + } + if (form.dataset.form === 'page-permission') { + const page = activePage(); + const permissions = upsertPermission(page.permissions || [], String(formData.get('userId') || ''), String(formData.get('access') || 'viewer')); + await api(`/api/pages/${page.id}`, { method: 'PUT', body: JSON.stringify({ permissions }) }); + await refresh(); + state.activePanel = 'admin'; + return; + } + if (form.dataset.form === 'database-permission') { + const database = activeDatabase() || databasesForWorkspace(activeWorkspace().id)[0]; + const permissions = upsertPermission(database.permissions || [], String(formData.get('userId') || ''), String(formData.get('access') || 'viewer')); + await api(`/api/databases/${database.id}`, { method: 'PUT', body: JSON.stringify({ permissions }) }); + await refresh(); + state.activePanel = 'admin'; + state.activeDatabaseId = database.id; + return; + } + if (form.dataset.form === 'scim-provision') { + await api(`/api/workspaces/${activeWorkspace().id}/scim/users`, { method: 'POST', body: JSON.stringify(Object.fromEntries(formData.entries())) }); + await refresh(); + state.activePanel = 'admin'; + return; + } + } catch (error) { + state.notice = error.message; + render(); + } +} + +async function oauth(provider) { + try { + await api('/api/auth/oauth', { method: 'POST', body: JSON.stringify({ provider }) }); + await refresh(); + } catch (error) { + state.notice = error.message; + render(); + } +} + +async function consumeMagicToken() { + try { + await api('/api/auth/magic-link/consume', { method: 'POST', body: JSON.stringify({ token: state.lastMagicToken }) }); + await refresh(); + } catch (error) { + state.notice = error.message; + render(); + } +} + +async function logout() { + await api('/api/auth/logout', { method: 'POST', body: '{}' }); + state.localPage = null; + await refresh(); +} + +async function createWorkspace() { + const name = prompt('Workspace name', 'New Workspace'); + if (!name) return; + await api('/api/workspaces', { method: 'POST', body: JSON.stringify({ name, kind: 'team' }) }); + await refresh(); +} + +async function createDatabase() { + if (!currentUser()) return; + const title = prompt('Database title', 'Project Database'); + if (!title) return; + const description = prompt('Description', 'Track work with multiple views') || ''; + const database = await api('/api/databases', { + method: 'POST', + body: JSON.stringify({ + workspaceId: activeWorkspace().id, + pageId: activePage()?.id || null, + title, + description, + icon: '🗂️', + fields: [ + { id: 'fld_name', name: 'Name', type: 'title' }, + { id: 'fld_status', name: 'Status', type: 'status', options: ['Backlog', 'In Progress', 'Done'] }, + { id: 'fld_owner', name: 'Owner', type: 'person' }, + { id: 'fld_due', name: 'Due', type: 'date' }, + ], + views: [ + { id: 'view_table', name: 'Table', type: 'table' }, + { id: 'view_board', name: 'Board', type: 'board', groupBy: 'fld_status' }, + { id: 'view_calendar', name: 'Calendar', type: 'calendar', dateField: 'fld_due' }, + { id: 'view_timeline', name: 'Timeline', type: 'timeline', dateField: 'fld_due' }, + { id: 'view_gallery', name: 'Gallery', type: 'gallery' }, + ], + }), + }); + await refresh(); + selectDatabase(database.id); +} + +async function createPage(fromTemplate = false) { + if (!currentUser()) return; + const title = prompt('Page title', fromTemplate ? 'New template page' : 'Untitled'); + if (!title) return; + let templateId = null; + if (fromTemplate) { + const options = (state.data.templates || []).map((tpl) => `${tpl.id}: ${tpl.name}`).join('\n'); + templateId = prompt(`Choose template id:\n${options}`, state.data.templates?.[0]?.id || '') || null; + } + const page = await api('/api/pages', { + method: 'POST', + body: JSON.stringify({ workspaceId: activeWorkspace().id, parentId: activePage()?.id || null, title, templateId }), + }); + await refresh(); + selectPage(page.id); +} + +async function restorePage(pageId) { + await api(`/api/pages/${pageId}/restore`, { method: 'POST', body: '{}' }); + await refresh(); + selectPage(pageId); +} + +async function favoritePage() { + await api(`/api/pages/${activePage().id}/favorite`, { method: 'POST', body: '{}' }); + await refresh(); +} + +async function duplicatePage() { + const copy = await api(`/api/pages/${activePage().id}/duplicate`, { method: 'POST', body: '{}' }); + await refresh(); + selectPage(copy.id); +} + +async function publishPage() { + const page = activePage(); + const slug = prompt('Public slug', page.slug) || page.slug; + await api(`/api/pages/${page.id}/publish`, { method: 'POST', body: JSON.stringify({ published: !page.published, slug }) }); + await refresh(); +} + +async function sharePage() { + const page = activePage(); + const allowedDomain = prompt('Domain restriction (optional, e.g. example.com)', page.share?.allowedDomain || '') ?? (page.share?.allowedDomain || ''); + await api(`/api/pages/${page.id}/share`, { method: 'POST', body: JSON.stringify({ enabled: !page.share?.enabled, allowedDomain }) }); + await refresh(); +} + +async function trashPage() { + if (!confirm('Move this page to trash?')) return; + await api(`/api/pages/${activePage().id}/trash`, { method: 'POST', body: '{}' }); + await refresh(); +} + +async function updatePageSettings() { + const page = activePage(); + const customUrl = prompt('Custom page URL (e.g. /product-strategy)', page.customUrl || '') ?? (page.customUrl || ''); + const seoTitle = prompt('SEO title', page.seo?.title || page.title) ?? (page.seo?.title || page.title); + const seoDescription = prompt('SEO description', page.seo?.description || '') ?? (page.seo?.description || ''); + const cover = prompt('Cover image URL', page.cover || '') ?? (page.cover || ''); + mutateLocalPage((draft) => { + draft.customUrl = customUrl; + draft.seo = { ...(draft.seo || {}), title: seoTitle, description: seoDescription }; + draft.cover = cover; + }); + render(); + await saveCurrentPage(); +} + +function addBlock() { + mutateLocalPage((page) => { + page.blocks.push({ id: cryptoRandomId('blk'), type: 'paragraph', text: '', checked: false, url: '' }); + scheduleSave(); + }); + render(); +} + +function cryptoRandomId(prefix) { + return `${prefix}_${Math.random().toString(36).slice(2, 10)}`; +} + +function duplicateBlock(blockId) { + mutateLocalPage((page) => { + const index = page.blocks.findIndex((block) => block.id === blockId); + const copy = structuredClone(page.blocks[index]); + copy.id = cryptoRandomId('blk'); + page.blocks.splice(index + 1, 0, copy); + scheduleSave(); + }); + render(); +} + +function deleteBlock(blockId) { + mutateLocalPage((page) => { + page.blocks = page.blocks.filter((block) => block.id !== blockId); + scheduleSave(); + }); + render(); +} + +function upsertPermission(existingPermissions, userId, access) { + const next = (existingPermissions || []).filter((rule) => rule.userId !== userId); + if (userId) next.push({ userId, access }); + return next; +} + +async function setupTwoFactor() { + if (!currentUser() || currentUser().twoFactorEnabled) { + state.notice = currentUser()?.twoFactorEnabled ? 'Two-factor authentication is already enabled.' : 'Sign in first.'; + render(); + return; + } + try { + const setup = await api('/api/auth/2fa/setup', { method: 'POST', body: '{}' }); + await api('/api/auth/2fa/verify', { method: 'POST', body: JSON.stringify({ code: setup.currentCode }) }); + state.notice = `2FA enabled. Secret: ${setup.secret}. Recovery codes: ${(setup.recoveryCodes || []).join(', ')}`; + await refresh(); + state.activePanel = 'admin'; + } catch (error) { + state.notice = error.message; + render(); + } +} + +async function quickComment(blockId) { + const text = prompt('Comment for this block'); + if (!text) return; + await api(`/api/pages/${activePage().id}/comments`, { method: 'POST', body: JSON.stringify({ text, blockId }) }); + state.activePanel = 'comments'; + await refresh(); +} + +function bulkDelete() { + if (!state.selectedBlocks.size) return; + mutateLocalPage((page) => { + page.blocks = page.blocks.filter((block) => !state.selectedBlocks.has(block.id)); + scheduleSave(); + }); + state.selectedBlocks = new Set(); + render(); +} + +function bulkDuplicate() { + if (!state.selectedBlocks.size) return; + mutateLocalPage((page) => { + const additions = page.blocks.filter((block) => state.selectedBlocks.has(block.id)).map((block) => ({ ...structuredClone(block), id: cryptoRandomId('blk') })); + page.blocks.push(...additions); + scheduleSave(); + }); + render(); +} + +async function downloadIcs() { + const response = await fetch(`/api/tasks.ics?workspaceId=${activeWorkspace().id}`); + const text = await response.text(); + downloadBlob(new Blob([text], { type: 'text/calendar' }), `noteflow-${activeWorkspace().name}.ics`); +} + +async function advanceTask(taskId) { + const task = (state.data.tasks || []).find((item) => item.id === taskId); + const statuses = ['todo', 'in-progress', 'blocked', 'done']; + const nextStatus = statuses[(statuses.indexOf(task.status) + 1) % statuses.length]; + await api(`/api/tasks/${taskId}`, { method: 'PUT', body: JSON.stringify({ status: nextStatus, progress: nextStatus === 'done' ? 100 : Math.min((task.progress || 0) + 25, 95) }) }); + await refresh(); +} + +async function linkTaskPage(taskId) { + await api(`/api/tasks/${taskId}`, { method: 'PUT', body: JSON.stringify({ linkedPageId: activePage()?.id || null, pageId: activePage()?.id || null }) }); + await refresh(); +} + +async function replaceFile(fileId) { + const input = document.createElement('input'); + input.type = 'file'; + input.onchange = async () => { + const file = input.files?.[0]; + if (!file) return; + const base64 = await readFileAsBase64(file); + await api(`/api/files/${fileId}/replace`, { method: 'POST', body: JSON.stringify({ data: base64 }) }); + await refresh(); + }; + input.click(); +} + +async function markRead(notificationId) { + await api(`/api/notifications/${notificationId}/read`, { method: 'POST', body: '{}' }); + await refresh(); +} + +async function resolveComment(commentId) { + await api(`/api/comments/${commentId}/resolve`, { method: 'POST', body: '{}' }); + await refresh(); +} + +async function replyComment(commentId) { + const text = prompt('Reply'); + if (!text) return; + await api(`/api/pages/${activePage().id}/comments`, { method: 'POST', body: JSON.stringify({ text, parentCommentId: commentId }) }); + await refresh(); +} + +async function restoreVersion(versionId) { + await api(`/api/pages/${activePage().id}/history/${versionId}/restore`, { method: 'POST', body: '{}' }); + clearDraft(activePage().id); + await refresh(); +} + +async function exportPage(format) { + const response = await fetch(`/api/export/page/${activePage().id}?format=${format}`); + if (!response.ok) throw new Error(await response.text()); + const blob = await response.blob(); + downloadBlob(blob, `${activePage().title.replace(/[^a-z0-9]+/gi, '-').toLowerCase()}.${format === 'markdown' ? 'md' : format}`); +} + +async function exportWorkspace() { + const response = await fetch(`/api/export/workspace/${activeWorkspace().id}`); + const blob = await response.blob(); + downloadBlob(blob, `${activeWorkspace().name.replace(/[^a-z0-9]+/gi, '-').toLowerCase()}.json`); +} + +async function exportDatabase(format) { + const database = activeDatabase(); + if (!database) return; + const response = await fetch(`/api/export/database/${database.id}?format=${format}`); + if (!response.ok) throw new Error(await response.text()); + const blob = await response.blob(); + downloadBlob(blob, `${database.title.replace(/[^a-z0-9]+/gi, '-').toLowerCase()}.${format === 'markdown' ? 'md' : format}`); +} + +async function editRow(rowId) { + const database = activeDatabase(); + const row = rowsForDatabase(database.id).find((item) => item.id === rowId); + if (!row) return; + const values = { ...row.values }; + for (const field of database.fields || []) { + const next = prompt(field.name, values[field.id] || ''); + if (next !== null) values[field.id] = next; + } + await api(`/api/databases/${database.id}/rows/${row.id}`, { method: 'PUT', body: JSON.stringify({ values }) }); + await refresh(); + state.activeDatabaseId = database.id; + state.activePanel = 'databases'; +} + +async function advanceRow(rowId) { + const database = activeDatabase(); + const row = rowsForDatabase(database.id).find((item) => item.id === rowId); + if (!row) return; + const statusField = databaseField(database, 'status'); + const options = statusField?.options || ['Backlog', 'In Progress', 'Done']; + const current = row.values?.[statusField?.id] || options[0]; + const next = options[(options.indexOf(current) + 1) % options.length]; + await api(`/api/databases/${database.id}/rows/${row.id}`, { method: 'PUT', body: JSON.stringify({ values: { [statusField.id]: next } }) }); + await refresh(); + state.activeDatabaseId = database.id; + state.activePanel = 'databases'; +} + +async function verifyRow(rowId) { + const database = activeDatabase(); + const row = rowsForDatabase(database.id).find((item) => item.id === rowId); + if (!row) return; + await api(`/api/databases/${database.id}/rows/${row.id}`, { method: 'PUT', body: JSON.stringify({ verified: !row.verified }) }); + await refresh(); + state.activeDatabaseId = database.id; + state.activePanel = 'databases'; +} + +function downloadBlob(blob, filename) { + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = filename; + anchor.click(); + URL.revokeObjectURL(url); +} + +function readFileAsBase64(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result).split(',')[1]); + reader.onerror = reject; + reader.readAsDataURL(file); + }); +} + +function connectEvents() { + if (state.sse) state.sse.close(); + const workspace = activeWorkspace(); + const page = activePage(); + if (!currentUser() || !workspace) return; + state.sse = new EventSource(`/api/events?workspaceId=${workspace.id}&pageId=${page?.id || ''}`); + state.sse.addEventListener('presence.updated', (event) => { + const payload = JSON.parse(event.data); + state.presence = payload.presence || []; + if (!state.dirty) render(); + }); + ['page.created','page.updated','page.restored','comment.created','comment.resolved','task.created','task.updated','database.created','database.updated','database.row.created','database.row.updated','file.uploaded','file.replaced'].forEach((name) => { + state.sse.addEventListener(name, async () => { + if (!state.dirty) await refresh(false); + }); + }); + sendPresence(); +} + +async function sendPresence() { + const page = activePage(); + const workspace = activeWorkspace(); + if (!currentUser() || !page || !workspace) return; + try { + state.presence = await api('/api/presence', { method: 'POST', body: JSON.stringify({ workspaceId: workspace.id, pageId: page.id, cursor: { x: 0, y: 0 } }) }); + } catch {} +} + +window.addEventListener('keydown', (event) => { + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') { + event.preventDefault(); + state.searchOpen = true; + render(); + bindSearchInputs(); + } + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 's') { + event.preventDefault(); + saveCurrentPage(); + } + if (event.key === 'Escape' && state.searchOpen) { + state.searchOpen = false; + render(); + } +}); + +setInterval(() => { + if (currentUser() && activePage()) sendPresence(); +}, 12000); + +refresh(true).catch((error) => { + app.innerHTML = `

NoteFlow failed to load

${escapeHtml(error.message)}
`; +}); diff --git a/deliverable/noteflow/public/index.html b/deliverable/noteflow/public/index.html new file mode 100644 index 000000000..8a9937f2c --- /dev/null +++ b/deliverable/noteflow/public/index.html @@ -0,0 +1,14 @@ + + + + + + NoteFlow + + + + +
Loading NoteFlow…
+ + + diff --git a/deliverable/noteflow/public/styles.css b/deliverable/noteflow/public/styles.css new file mode 100644 index 000000000..4a64bb520 --- /dev/null +++ b/deliverable/noteflow/public/styles.css @@ -0,0 +1,106 @@ +:root{ + --bg:#f6f7fb; + --surface:#ffffff; + --muted:#64748b; + --text:#0f172a; + --line:#e2e8f0; + --brand:#4f46e5; + --brand-soft:#eef2ff; + --good:#16a34a; + --warn:#d97706; + --danger:#dc2626; + --shadow:0 10px 30px rgba(15,23,42,.08); + --radius:18px; +} +*{box-sizing:border-box} +html,body{margin:0;height:100%;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,sans-serif;background:var(--bg);color:var(--text)} +button,input,select,textarea{font:inherit} +a{color:#2563eb} +body.modal-open{overflow:hidden} +#app{min-height:100vh} +.shell{display:grid;grid-template-columns:290px 1fr 360px;min-height:100vh;gap:18px;padding:18px} +.panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow)} +.sidebar,.details{padding:18px;overflow:auto;max-height:calc(100vh - 36px)} +.main{display:flex;flex-direction:column;gap:14px;min-width:0} +.header-card{padding:16px 18px;display:flex;align-items:center;justify-content:space-between;gap:14px} +.brand{display:flex;align-items:center;gap:10px;font-weight:800} +.brand-badge{width:38px;height:38px;border-radius:14px;background:linear-gradient(135deg,#4f46e5,#06b6d4);display:grid;place-items:center;color:white;box-shadow:0 8px 20px rgba(79,70,229,.28)} +.muted{color:var(--muted)} +.workspace-switcher, .toolbar-row, .auth-grid, .search-filters, .inline-actions{display:flex;gap:8px;flex-wrap:wrap} +.workspace-switcher select,.toolbar-row select,.search-filters select,.search-filters input,.form input,.form textarea,.form select, .block-card textarea, .block-card input{width:100%;border:1px solid var(--line);border-radius:12px;padding:10px 12px;background:white;color:var(--text)} +button{border:none;border-radius:12px;padding:10px 14px;background:#e2e8f0;color:#0f172a;cursor:pointer;font-weight:600} +button.primary{background:var(--brand);color:white} +button.ghost{background:transparent;border:1px solid var(--line)} +button.warn{background:#fff7ed;color:#9a3412} +button.danger{background:#fef2f2;color:#991b1b} +button.good{background:#ecfdf5;color:#166534} +button.small{padding:7px 10px;border-radius:10px;font-size:.92rem} +button:disabled{opacity:.55;cursor:not-allowed} +.sidebar h3,.details h3,.details h4{margin:0 0 10px} +.section{margin-bottom:18px} +.page-tree{display:flex;flex-direction:column;gap:6px} +.page-item{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:8px 10px;border-radius:12px;border:1px solid transparent;background:transparent;cursor:pointer;text-align:left} +.page-item.active{background:var(--brand-soft);border-color:#c7d2fe} +.page-item small{display:block;color:var(--muted)} +.page-indent-1{margin-left:16px}.page-indent-2{margin-left:32px}.page-indent-3{margin-left:48px} +.badge{display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border-radius:999px;background:#f1f5f9;color:#334155;font-size:.82rem} +.editor-card{padding:0;overflow:hidden} +.cover{height:220px;background:linear-gradient(135deg,#c7d2fe,#a5f3fc);display:flex;align-items:flex-end;padding:18px;border-bottom:1px solid var(--line);background-size:cover;background-position:center} +.cover.empty{display:none} +.editor-meta{padding:20px 24px 0} +.breadcrumbs{display:flex;gap:8px;flex-wrap:wrap;color:var(--muted);font-size:.92rem} +.title-row{display:flex;gap:16px;align-items:flex-start;margin-top:12px} +.page-icon{font-size:2rem} +.page-title{font-size:2rem;font-weight:800;line-height:1.1;border:none;background:transparent;padding:0;width:100%} +.page-config{display:flex;gap:8px;flex-wrap:wrap;margin-top:16px;padding-bottom:16px;border-bottom:1px solid var(--line)} +.page-config label{display:inline-flex;align-items:center;gap:6px;padding:6px 10px;border:1px solid var(--line);border-radius:999px;background:white} +.editor-wrap{padding:20px 24px 26px;max-width:960px} +.editor-wrap.full-width{max-width:none} +.editor-wrap.small-text .block-card textarea{font-size:.92rem} +.block-list{display:flex;flex-direction:column;gap:10px} +.block-card{display:grid;grid-template-columns:24px 1fr auto;gap:10px;padding:12px;border:1px solid var(--line);border-radius:16px;background:white;align-items:flex-start} +.block-card.dragging{opacity:.55} +.block-handle{cursor:grab;color:#94a3b8;padding-top:8px} +.block-body{display:flex;flex-direction:column;gap:8px} +.block-toolbar{display:flex;gap:8px;flex-wrap:wrap} +.block-card textarea{min-height:52px;resize:vertical} +.block-card[data-type="heading1"] textarea{font-size:1.5rem;font-weight:700} +.block-card[data-type="heading2"] textarea{font-size:1.25rem;font-weight:700} +.block-card[data-type="heading3"] textarea{font-size:1.08rem;font-weight:700} +.block-card[data-type="quote"] textarea{border-left:4px solid #cbd5e1} +.block-card[data-type="callout"]{background:#eef2ff} +.block-card[data-type="code"] textarea,.code-preview{background:#111827;color:#e5e7eb;font-family:ui-monospace,SFMono-Regular,Menlo,monospace} +.block-card.selected{outline:2px solid #818cf8} +.inline-checkbox{margin-top:10px} +.slash-help{font-size:.84rem;color:var(--muted);background:#f8fafc;border:1px dashed var(--line);padding:8px 10px;border-radius:12px} +.right-panel-tabs{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px} +.list-card,.comment-card,.task-card,.notification-card,.file-card,.history-card,.search-result{padding:12px;border:1px solid var(--line);border-radius:14px;background:white;margin-bottom:10px} +.list-card strong,.comment-card strong,.task-card strong,.notification-card strong{display:block} +.comment-thread{margin-left:14px;border-left:2px solid #e2e8f0;padding-left:12px} +.toc a{display:block;padding:4px 0;text-decoration:none} +.presence-row{display:flex;gap:8px;flex-wrap:wrap} +.avatar{width:28px;height:28px;border-radius:999px;display:grid;place-items:center;color:white;font-size:.78rem;font-weight:700} +.hero-auth{padding:22px} +.auth-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px} +.form{display:flex;flex-direction:column;gap:10px} +.empty-state{padding:28px;text-align:center;color:var(--muted)} +.search-modal{position:fixed;inset:0;background:rgba(15,23,42,.45);display:flex;align-items:flex-start;justify-content:center;padding:56px 16px;z-index:20} +.search-dialog{width:min(920px,100%);background:white;border-radius:22px;box-shadow:var(--shadow);border:1px solid var(--line);padding:18px} +.search-dialog input[type="search"]{width:100%;padding:14px 16px;border:1px solid var(--line);border-radius:14px;font-size:1rem} +.notice{padding:10px 12px;border-radius:12px;background:#eff6ff;border:1px solid #bfdbfe;color:#1d4ed8;margin-bottom:10px} +.grid-two{display:grid;grid-template-columns:1fr 1fr;gap:12px} +.metric-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:10px} +.metric{padding:12px;border-radius:16px;background:#f8fafc;border:1px solid var(--line)} +.metric strong{display:block;font-size:1.35rem} +table{width:100%;border-collapse:collapse} +th,td{padding:8px;border-bottom:1px solid var(--line);text-align:left;font-size:.92rem} +.database-table-wrap{overflow:auto;border:1px solid var(--line);border-radius:16px;background:white} +.database-table th{background:#f8fafc;position:sticky;top:0} +.database-board{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px} +.board-column{border:1px solid var(--line);border-radius:16px;background:#f8fafc;padding:12px;min-height:180px} +.board-column h4{margin:0 0 10px} +.database-gallery{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px} +.code-preview{padding:8px 10px;border-radius:12px;white-space:pre-wrap} +.hidden{display:none !important} +@media (max-width: 1280px){.shell{grid-template-columns:260px 1fr}.details{grid-column:1 / -1;max-height:none}} +@media (max-width: 920px){.shell{grid-template-columns:1fr;padding:12px}.sidebar,.details{max-height:none}.auth-grid,.grid-two,.metric-grid{grid-template-columns:1fr}} diff --git a/deliverable/noteflow/server.mjs b/deliverable/noteflow/server.mjs new file mode 100644 index 000000000..a0a84b973 --- /dev/null +++ b/deliverable/noteflow/server.mjs @@ -0,0 +1,2282 @@ +import http from 'node:http'; +import fs from 'node:fs'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const DATA_DIR = path.join(__dirname, 'data'); +const PUBLIC_DIR = path.join(__dirname, 'public'); +const UPLOAD_DIR = path.join(__dirname, 'uploads'); +const DB_FILE = path.join(DATA_DIR, 'noteflow-db.json'); +const PORT = Number(process.env.PORT || 3000); +const SESSION_COOKIE = 'noteflow_session'; +const SSE_CLIENTS = new Map(); +const PRESENCE = new Map(); + +fs.mkdirSync(DATA_DIR, { recursive: true }); +fs.mkdirSync(PUBLIC_DIR, { recursive: true }); +fs.mkdirSync(UPLOAD_DIR, { recursive: true }); + +function now() { + return new Date().toISOString(); +} + +function id(prefix = 'id') { + return `${prefix}_${crypto.randomUUID().replace(/-/g, '').slice(0, 12)}`; +} + +function hashPassword(password, salt = crypto.randomBytes(16).toString('hex')) { + const derived = crypto.pbkdf2Sync(password, salt, 100000, 64, 'sha512').toString('hex'); + return `${salt}:${derived}`; +} + +function verifyPassword(password, stored) { + const [salt] = String(stored || '').split(':'); + if (!salt) return false; + return hashPassword(password, salt) === stored; +} + +function parseCookies(req) { + const header = req.headers.cookie || ''; + return Object.fromEntries( + header + .split(';') + .map((part) => part.trim()) + .filter(Boolean) + .map((pair) => { + const idx = pair.indexOf('='); + return [pair.slice(0, idx), decodeURIComponent(pair.slice(idx + 1))]; + }), + ); +} + +function setCookie(res, name, value, options = {}) { + const parts = [`${name}=${encodeURIComponent(value)}`, 'Path=/', 'HttpOnly', 'SameSite=Lax']; + if (options.maxAge !== undefined) parts.push(`Max-Age=${options.maxAge}`); + res.setHeader('Set-Cookie', parts.join('; ')); +} + +function clearCookie(res, name) { + setCookie(res, name, '', { maxAge: 0 }); +} + +function json(res, status, payload) { + const body = JSON.stringify(payload, null, 2); + res.writeHead(status, { + 'Content-Type': 'application/json; charset=utf-8', + 'Content-Length': Buffer.byteLength(body), + 'Cache-Control': 'no-store', + }); + res.end(body); +} + +function html(res, status, payload) { + res.writeHead(status, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(payload); +} + +function text(res, status, payload, contentType = 'text/plain; charset=utf-8') { + res.writeHead(status, { 'Content-Type': contentType }); + res.end(payload); +} + +function redirect(res, location) { + res.writeHead(302, { Location: location }); + res.end(); +} + +function readBody(req) { + return new Promise((resolve, reject) => { + const chunks = []; + req.on('data', (chunk) => chunks.push(chunk)); + req.on('end', () => { + try { + const raw = Buffer.concat(chunks).toString('utf8'); + resolve(raw ? JSON.parse(raw) : {}); + } catch (error) { + reject(error); + } + }); + req.on('error', reject); + }); +} + +function safeReadJson(file, fallback) { + try { + return JSON.parse(fs.readFileSync(file, 'utf8')); + } catch { + return fallback; + } +} + +function starterTemplates() { + return [ + { + id: 'tpl_project_brief', + name: 'Project Brief', + icon: '🚀', + description: 'Goal, scope, milestones, and linked tasks', + blocks: [ + { id: id('blk'), type: 'heading1', text: 'Project Brief' }, + { id: id('blk'), type: 'callout', text: 'Summarize the mission, owner, and due date.' }, + { id: id('blk'), type: 'heading2', text: 'Goals' }, + { id: id('blk'), type: 'bullet', text: 'Primary outcome' }, + { id: id('blk'), type: 'bullet', text: 'Success metric' }, + { id: id('blk'), type: 'heading2', text: 'Milestones' }, + { id: id('blk'), type: 'todo', text: 'Define scope', checked: false }, + ], + }, + { + id: 'tpl_meeting_notes', + name: 'Meeting Notes', + icon: '📝', + description: 'Agenda, notes, decisions, and follow-ups', + blocks: [ + { id: id('blk'), type: 'heading1', text: 'Meeting Notes' }, + { id: id('blk'), type: 'heading2', text: 'Agenda' }, + { id: id('blk'), type: 'bullet', text: 'Topic 1' }, + { id: id('blk'), type: 'heading2', text: 'Decisions' }, + { id: id('blk'), type: 'quote', text: 'Record key decisions here.' }, + ], + }, + { + id: 'tpl_task_hub', + name: 'Task Hub', + icon: '✅', + description: 'Work tracker with status, priority, and owners', + blocks: [ + { id: id('blk'), type: 'heading1', text: 'Task Hub' }, + { id: id('blk'), type: 'paragraph', text: 'Use the Tasks panel to manage project execution.' }, + { id: id('blk'), type: 'bookmark', text: 'Link related specs or docs here.', url: 'https://example.com' }, + ], + }, + ]; +} + +function defaultData() { + const templates = starterTemplates(); + const demoWorkspaceId = id('ws'); + const demoPageId = id('pg'); + const demoTaskId = id('tsk'); + const demoFileId = id('fil'); + const demoDatabaseId = id('db'); + const demoRowId = id('row'); + return { + users: [], + sessions: [], + workspaces: [ + { + id: demoWorkspaceId, + name: 'NoteFlow Demo Workspace', + kind: 'team', + ownerId: 'system', + createdAt: now(), + settings: { + storageQuotaMb: 100, + domainRestriction: '', + allowGuests: true, + publicSharing: true, + databasePermissions: 'workspace-role', + samlEnabled: false, + scimEnabled: false, + }, + members: [], + }, + ], + pages: [ + { + id: demoPageId, + workspaceId: demoWorkspaceId, + parentId: null, + title: 'Welcome to NoteFlow', + icon: '🌊', + cover: 'https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?auto=format&fit=crop&w=1200&q=80', + slug: 'welcome-to-noteflow', + customUrl: '/welcome-to-noteflow', + kind: 'page', + locked: false, + fullWidth: true, + smallText: false, + verified: true, + deletedAt: null, + favoriteBy: [], + recentBy: [], + published: true, + seo: { + title: 'Welcome to NoteFlow', + description: 'Explore the NoteFlow collaborative workspace starter experience.', + }, + share: { + enabled: true, + token: id('shr'), + expiresAt: '', + allowedDomain: '', + }, + permissions: [], + blocks: [ + { id: id('blk'), type: 'heading1', text: 'Welcome to NoteFlow' }, + { + id: id('blk'), + type: 'callout', + text: 'This demo shows nested pages, templates, comments, backlinks, tasks, search, notifications, publishing, sharing, and export.', + }, + { id: id('blk'), type: 'heading2', text: 'Try these features' }, + { id: id('blk'), type: 'bullet', text: 'Create a page with the slash menu' }, + { id: id('blk'), type: 'bullet', text: 'Open the search palette with Cmd/Ctrl + K' }, + { id: id('blk'), type: 'bullet', text: 'Upload files and attach them to pages' }, + { id: id('blk'), type: 'heading2', text: 'Backlinks demo' }, + { id: id('blk'), type: 'paragraph', text: 'Mention [[Welcome to NoteFlow]] from another page to create backlinks.' }, + ], + history: [], + commentsEnabled: true, + commentsSummary: { total: 0, unresolved: 0 }, + templateId: null, + createdAt: now(), + updatedAt: now(), + createdBy: 'system', + }, + ], + comments: [], + tasks: [ + { + id: demoTaskId, + workspaceId: demoWorkspaceId, + pageId: demoPageId, + title: 'Explore NoteFlow demo workspace', + description: 'Review page management, tasks, search, and sharing features.', + assigneeUserId: null, + dueDate: new Date(Date.now() + 86400000).toISOString().slice(0, 10), + priority: 'high', + status: 'in-progress', + recurring: 'weekly', + reminderAt: new Date(Date.now() + 3600000).toISOString(), + dependencies: [], + subItems: [{ id: id('sub'), text: 'Open the Welcome page', done: true }], + milestone: false, + progress: 40, + linkedPageId: demoPageId, + createdAt: now(), + updatedAt: now(), + }, + ], + databases: [ + { + id: demoDatabaseId, + workspaceId: demoWorkspaceId, + pageId: demoPageId, + title: 'Roadmap Database', + description: 'Track initiatives with multiple workspace views.', + icon: '🗂️', + fields: [ + { id: 'fld_name', name: 'Name', type: 'title' }, + { id: 'fld_status', name: 'Status', type: 'status', options: ['Backlog', 'In Progress', 'Done'] }, + { id: 'fld_owner', name: 'Owner', type: 'person' }, + { id: 'fld_due', name: 'Due', type: 'date' }, + { id: 'fld_priority', name: 'Priority', type: 'select', options: ['low', 'medium', 'high'] }, + ], + views: [ + { id: 'view_table', name: 'Table', type: 'table' }, + { id: 'view_board', name: 'Board', type: 'board', groupBy: 'fld_status' }, + { id: 'view_calendar', name: 'Calendar', type: 'calendar', dateField: 'fld_due' }, + { id: 'view_timeline', name: 'Timeline', type: 'timeline', dateField: 'fld_due' }, + { id: 'view_gallery', name: 'Gallery', type: 'gallery' }, + ], + permissions: [], + createdAt: now(), + updatedAt: now(), + createdBy: 'system', + }, + ], + databaseRows: [ + { + id: demoRowId, + databaseId: demoDatabaseId, + workspaceId: demoWorkspaceId, + pageId: demoPageId, + values: { + fld_name: 'Launch public demo', + fld_status: 'In Progress', + fld_owner: 'system', + fld_due: new Date(Date.now() + 3 * 86400000).toISOString().slice(0, 10), + fld_priority: 'high', + }, + verified: true, + createdAt: now(), + updatedAt: now(), + createdBy: 'system', + }, + ], + files: [ + { + id: demoFileId, + workspaceId: demoWorkspaceId, + pageId: demoPageId, + name: 'noteflow-demo.txt', + type: 'text/plain', + size: 28, + storagePath: '', + preview: 'NoteFlow demo attachment file', + versions: [], + createdAt: now(), + updatedAt: now(), + uploadedBy: 'system', + }, + ], + notifications: [], + invitations: [], + activity: [], + templates, + recentSearches: [], + emailOutbox: [], + }; +} + +function loadDb() { + if (!fs.existsSync(DB_FILE)) { + fs.writeFileSync(DB_FILE, JSON.stringify(defaultData(), null, 2)); + } + const data = safeReadJson(DB_FILE, defaultData()); + data.users ||= []; + data.sessions ||= []; + data.workspaces ||= []; + data.pages ||= []; + data.comments ||= []; + data.tasks ||= []; + data.databases ||= []; + data.databaseRows ||= []; + data.files ||= []; + data.notifications ||= []; + data.invitations ||= []; + data.activity ||= []; + data.templates ||= starterTemplates(); + data.recentSearches ||= []; + data.emailOutbox ||= []; + data.magicLinks ||= []; + return data; +} + +function saveDb(data) { + fs.writeFileSync(DB_FILE, JSON.stringify(data, null, 2)); +} + +function getSession(req, data) { + const cookies = parseCookies(req); + const sessionId = cookies[SESSION_COOKIE]; + if (!sessionId) return null; + const session = data.sessions.find((item) => item.id === sessionId); + if (!session) return null; + session.lastSeenAt = now(); + const user = data.users.find((item) => item.id === session.userId); + if (!user) return null; + return { session, user }; +} + +function publicUser(user) { + if (!user) return null; + const { passwordHash, twoFactorSecret, recoveryCodes, ...rest } = user; + return rest; +} + +function roleRank(role) { + return { guest: 1, viewer: 1, commenter: 2, editor: 3, owner: 4 }[role] || 0; +} + +function getWorkspace(data, workspaceId) { + return data.workspaces.find((workspace) => workspace.id === workspaceId) || null; +} + +function getMembership(workspace, userId) { + return workspace?.members?.find((member) => member.userId === userId) || null; +} + +function getWorkspaceRole(data, workspaceId, userId) { + const workspace = getWorkspace(data, workspaceId); + if (!workspace || !userId) return null; + if (workspace.ownerId === userId) return 'owner'; + return getMembership(workspace, userId)?.role || null; +} + +function canAccessWorkspace(data, workspaceId, userId) { + const workspace = getWorkspace(data, workspaceId); + if (!workspace) return false; + if (workspace.ownerId === 'system') return true; + return Boolean(getWorkspaceRole(data, workspaceId, userId)); +} + +function canAccessPage(data, page, userId, mode = 'view') { + if (!page) return false; + if (page.deletedAt && mode !== 'restore') return false; + const role = getWorkspaceRole(data, page.workspaceId, userId); + if (!role && getWorkspace(data, page.workspaceId)?.ownerId !== 'system') return false; + const pageRule = (page.permissions || []).find((item) => item.userId === userId); + const access = pageRule?.access || role || 'viewer'; + if (mode === 'view') return roleRank(access) >= 1; + if (mode === 'comment') return roleRank(access) >= 2; + if (mode === 'edit') { + if (page.locked && access !== 'owner') return false; + return roleRank(access) >= 3; + } + if (mode === 'restore') return roleRank(access) >= 3; + return false; +} + +function getDatabase(data, databaseId) { + return data.databases.find((database) => database.id === databaseId) || null; +} + +function canAccessDatabase(data, database, userId, mode = 'view') { + if (!database) return false; + const role = getWorkspaceRole(data, database.workspaceId, userId); + if (!role && getWorkspace(data, database.workspaceId)?.ownerId !== 'system') return false; + const rule = (database.permissions || []).find((item) => item.userId === userId); + const access = rule?.access || role || 'viewer'; + if (mode === 'view') return roleRank(access) >= 1; + if (mode === 'edit') return roleRank(access) >= 3; + return false; +} + +function getAccessibleWorkspaceIds(data, userId) { + return data.workspaces + .filter((workspace) => workspace.ownerId === 'system' || getWorkspaceRole(data, workspace.id, userId)) + .map((workspace) => workspace.id); +} + +function createActivity(data, payload) { + data.activity.unshift({ id: id('act'), createdAt: now(), ...payload }); + data.activity = data.activity.slice(0, 200); +} + +function ensureAuthMethod(user, method) { + user.identity ||= {}; + user.identity.authMethods ||= []; + if (!user.identity.authMethods.includes(method)) user.identity.authMethods.push(method); + user.identity.lastAuditAt = now(); +} + +function upsertWorkspaceMember(workspace, userId, role = 'viewer') { + workspace.members ||= []; + const existing = workspace.members.find((member) => member.userId === userId); + if (existing) { + existing.role = role || existing.role; + existing.acceptedAt ||= now(); + return existing; + } + const member = { userId, role: role || 'viewer', invitedAt: now(), acceptedAt: now() }; + workspace.members.push(member); + return member; +} + +function acceptPendingInvitations(data, user, actorUserId = user.id, source = 'workspace.invite.accepted') { + const accepted = []; + for (const invitation of data.invitations || []) { + if (invitation.acceptedAt || invitation.email !== user.email) continue; + const workspace = getWorkspace(data, invitation.workspaceId); + if (!workspace) continue; + upsertWorkspaceMember(workspace, user.id, invitation.role || 'viewer'); + invitation.acceptedAt = now(); + accepted.push(invitation); + createActivity(data, { + actorUserId, + workspaceId: workspace.id, + kind: source, + message: `${user.email} joined ${workspace.name}`, + }); + } + return accepted; +} + +function queueNotification(data, payload) { + const notification = { + id: id('ntf'), + read: false, + createdAt: now(), + delivery: ['in-app'], + ...payload, + }; + data.notifications.unshift(notification); + const user = data.users.find((item) => item.id === payload.userId); + if (user?.notificationPreferences?.email !== false) { + data.emailOutbox.unshift({ + id: id('mail'), + to: user.email, + subject: payload.title, + body: payload.body, + createdAt: now(), + digestKey: payload.batchKey || '', + type: payload.type, + }); + } + return notification; +} + +function bootstrapWorkspaceForUser(data, user) { + const personalWorkspace = { + id: id('ws'), + name: `${user.name.split(' ')[0]}'s Workspace`, + kind: 'personal', + ownerId: user.id, + createdAt: now(), + settings: { + storageQuotaMb: 250, + domainRestriction: '', + allowGuests: true, + publicSharing: true, + databasePermissions: 'workspace-role', + samlEnabled: false, + scimEnabled: false, + }, + members: [{ userId: user.id, role: 'owner', invitedAt: now(), acceptedAt: now() }], + }; + const teamWorkspace = { + id: id('ws'), + name: `${user.name.split(' ')[0]}'s Team Space`, + kind: 'team', + ownerId: user.id, + createdAt: now(), + settings: { + storageQuotaMb: 500, + domainRestriction: '', + allowGuests: true, + publicSharing: true, + databasePermissions: 'workspace-role', + samlEnabled: false, + scimEnabled: false, + }, + members: [{ userId: user.id, role: 'owner', invitedAt: now(), acceptedAt: now() }], + }; + const rootPage = { + id: id('pg'), + workspaceId: personalWorkspace.id, + parentId: null, + title: 'Home', + icon: '🏠', + cover: '', + slug: `home-${user.id.slice(-5)}`, + customUrl: `/home-${user.id.slice(-5)}`, + kind: 'page', + locked: false, + fullWidth: false, + smallText: false, + verified: true, + deletedAt: null, + favoriteBy: [user.id], + recentBy: [{ userId: user.id, visitedAt: now() }], + published: false, + seo: { title: 'Home', description: 'Personal home page' }, + share: { enabled: false, token: id('shr'), expiresAt: '', allowedDomain: '' }, + permissions: [], + blocks: [ + { id: id('blk'), type: 'heading1', text: `Welcome, ${user.name}` }, + { id: id('blk'), type: 'paragraph', text: 'Use / to insert blocks, create nested pages, and collaborate in real time.' }, + { id: id('blk'), type: 'todo', text: 'Create your first project page', checked: false }, + ], + history: [], + commentsEnabled: true, + commentsSummary: { total: 0, unresolved: 0 }, + templateId: null, + createdAt: now(), + updatedAt: now(), + createdBy: user.id, + }; + const starterDatabase = { + id: id('db'), + workspaceId: personalWorkspace.id, + pageId: rootPage.id, + title: 'My Tasks Database', + description: 'A starter database for milestones, tasks, and planning views.', + icon: '🗃️', + fields: [ + { id: 'fld_name', name: 'Name', type: 'title' }, + { id: 'fld_status', name: 'Status', type: 'status', options: ['Backlog', 'In Progress', 'Done'] }, + { id: 'fld_owner', name: 'Owner', type: 'person' }, + { id: 'fld_due', name: 'Due', type: 'date' }, + { id: 'fld_priority', name: 'Priority', type: 'select', options: ['low', 'medium', 'high'] }, + ], + views: [ + { id: 'view_table', name: 'Table', type: 'table' }, + { id: 'view_board', name: 'Board', type: 'board', groupBy: 'fld_status' }, + { id: 'view_calendar', name: 'Calendar', type: 'calendar', dateField: 'fld_due' }, + { id: 'view_timeline', name: 'Timeline', type: 'timeline', dateField: 'fld_due' }, + { id: 'view_gallery', name: 'Gallery', type: 'gallery' }, + ], + permissions: [], + createdAt: now(), + updatedAt: now(), + createdBy: user.id, + }; + const starterRow = { + id: id('row'), + databaseId: starterDatabase.id, + workspaceId: personalWorkspace.id, + pageId: rootPage.id, + values: { + fld_name: 'Ship first workspace doc', + fld_status: 'Backlog', + fld_owner: user.id, + fld_due: new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10), + fld_priority: 'medium', + }, + verified: false, + createdAt: now(), + updatedAt: now(), + createdBy: user.id, + }; + data.workspaces.push(personalWorkspace, teamWorkspace); + data.pages.push(rootPage); + data.databases.push(starterDatabase); + data.databaseRows.push(starterRow); + queueNotification(data, { + userId: user.id, + type: 'welcome', + title: 'Welcome to NoteFlow', + body: 'Your workspaces are ready. Start with Home or create a page from a template.', + category: 'workspace', + batchKey: 'welcome', + }); + createActivity(data, { actorUserId: user.id, kind: 'workspace.created', message: 'Created initial workspaces', workspaceId: personalWorkspace.id }); +} + +function ensureUserRecordDefaults(user) { + user.avatarColor ||= '#4f46e5'; + user.title ||= 'Workspace builder'; + user.bio ||= ''; + user.notificationPreferences ||= { + email: true, + mentions: true, + comments: true, + assignments: true, + shares: true, + reminders: true, + digest: true, + }; + user.identity ||= { + authMethods: ['password'], + lastAuditAt: now(), + deviceMetadata: [], + samlExternalId: '', + scimExternalId: '', + }; + user.twoFactorEnabled ||= false; +} + +function createSession(data, user, req) { + const session = { + id: id('ses'), + userId: user.id, + createdAt: now(), + lastSeenAt: now(), + userAgent: req.headers['user-agent'] || 'unknown', + ipAddress: req.headers['x-forwarded-for'] || req.socket?.remoteAddress || 'unknown', + label: `Session ${new Date().toLocaleString()}`, + }; + data.sessions.push(session); + user.identity ||= {}; + user.identity.deviceMetadata ||= []; + user.identity.deviceMetadata = [ + { + sessionId: session.id, + userAgent: session.userAgent, + ipAddress: session.ipAddress, + lastSeenAt: session.lastSeenAt, + }, + ...user.identity.deviceMetadata.filter((entry) => entry.sessionId !== session.id), + ].slice(0, 12); + user.identity.lastAuditAt = now(); + return session; +} + +function recentByForUser(page, userId) { + return (page.recentBy || []).find((item) => item.userId === userId) || null; +} + +function updateRecentPage(page, userId) { + page.recentBy ||= []; + page.recentBy = page.recentBy.filter((item) => item.userId !== userId); + page.recentBy.unshift({ userId, visitedAt: now() }); + page.recentBy = page.recentBy.slice(0, 20); +} + +function normalizeBlocks(blocks) { + return (Array.isArray(blocks) ? blocks : []).map((block, index) => ({ + id: block.id || id('blk'), + type: block.type || 'paragraph', + text: block.text || '', + checked: Boolean(block.checked), + url: block.url || '', + data: block.data || null, + syncKey: block.syncKey || '', + color: block.color || '', + background: block.background || '', + level: block.level || 0, + order: index, + })); +} + +function createHistorySnapshot(page, userId) { + page.history ||= []; + page.history.unshift({ + id: id('ver'), + createdAt: now(), + userId, + title: page.title, + blocks: structuredClone(page.blocks || []), + meta: { + icon: page.icon, + cover: page.cover, + slug: page.slug, + fullWidth: page.fullWidth, + smallText: page.smallText, + verified: page.verified, + published: page.published, + seo: page.seo, + }, + }); + page.history = page.history.slice(0, 25); +} + +function syncSyncedBlocks(data) { + const syncMap = new Map(); + for (const page of data.pages) { + for (const block of page.blocks || []) { + if (block.type === 'synced' && block.syncKey) { + if (!syncMap.has(block.syncKey)) syncMap.set(block.syncKey, { text: block.text, url: block.url, data: block.data }); + } + } + } + for (const page of data.pages) { + for (const block of page.blocks || []) { + if (block.type === 'synced' && block.syncKey && syncMap.has(block.syncKey)) { + const source = syncMap.get(block.syncKey); + block.text = source.text; + block.url = source.url; + block.data = source.data; + } + } + } +} + +function findBacklinks(data, targetPage) { + const titleNeedle = `[[${targetPage.title}]]`.toLowerCase(); + return data.pages + .filter((page) => page.id !== targetPage.id && !page.deletedAt) + .filter((page) => (page.blocks || []).some((block) => `${block.text || ''} ${block.url || ''}`.toLowerCase().includes(titleNeedle))) + .map((page) => ({ id: page.id, title: page.title, workspaceId: page.workspaceId })); +} + +function computeCommentsSummary(data, pageId) { + const pageComments = data.comments.filter((comment) => comment.pageId === pageId); + return { + total: pageComments.length, + unresolved: pageComments.filter((comment) => !comment.resolved).length, + }; +} + +function workspaceUsageBytes(data, workspaceId) { + return data.files.filter((file) => file.workspaceId === workspaceId).reduce((sum, file) => sum + (file.size || 0), 0); +} + +function signedFileToken(fileId) { + return crypto.createHash('sha256').update(`${fileId}:${new Date().toISOString().slice(0, 13)}`).digest('hex'); +} + +function renderBlockHtml(block) { + const esc = escapeHtml(block.text || ''); + const link = block.url ? escapeHtml(block.url) : ''; + switch (block.type) { + case 'heading1': return `

${esc}

`; + case 'heading2': return `

${esc}

`; + case 'heading3': return `

${esc}

`; + case 'bullet': return ``; + case 'numbered': return `
  1. ${esc}
`; + case 'todo': return ``; + case 'quote': return `
${esc}
`; + case 'callout': return `
💡 ${esc}
`; + case 'divider': return '
'; + case 'code': return `
${esc}
`; + case 'equation': return `
${esc}
`; + case 'bookmark': return `${link || esc}`; + case 'image': return link ? `${esc}` : `

${esc}

`; + case 'video': return link ? `` : `

${esc}

`; + case 'audio': return link ? `` : `

${esc}

`; + case 'pdf': return link ? `` : `

${esc}

`; + case 'embed': return link ? `` : `

${esc}

`; + case 'table': return `
${esc}
`; + case 'columns': return `
${esc}
`; + case 'toggle': return `
${esc || 'Toggle'}

${esc}

`; + default: return `

${esc}

`; + } +} + +function renderPublicPage(page, data) { + const blocks = (page.blocks || []).map(renderBlockHtml).join('\n'); + return ` + + + + + ${escapeHtml(page.seo?.title || page.title)} + + + + +
+ ${page.cover ? `cover` : ''} +
${escapeHtml(page.icon || '📄')}Published with NoteFlow
+ ${blocks} +
+
+

Backlinks

+ +
+
+ +`; +} + +function escapeHtml(value) { + return String(value || '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +function markdownToBlocks(content) { + const lines = String(content || '').replace(/\r/g, '').split('\n'); + return lines.filter((line, index, arr) => !(line === '' && arr[index - 1] === '')).map((line) => { + if (line.startsWith('# ')) return { id: id('blk'), type: 'heading1', text: line.slice(2) }; + if (line.startsWith('## ')) return { id: id('blk'), type: 'heading2', text: line.slice(3) }; + if (line.startsWith('### ')) return { id: id('blk'), type: 'heading3', text: line.slice(4) }; + if (line.startsWith('- [ ] ')) return { id: id('blk'), type: 'todo', text: line.slice(6), checked: false }; + if (line.startsWith('- [x] ')) return { id: id('blk'), type: 'todo', text: line.slice(6), checked: true }; + if (line.startsWith('- ')) return { id: id('blk'), type: 'bullet', text: line.slice(2) }; + if (line.match(/^\d+\. /)) return { id: id('blk'), type: 'numbered', text: line.replace(/^\d+\. /, '') }; + if (line.startsWith('> ')) return { id: id('blk'), type: 'quote', text: line.slice(2) }; + if (line.startsWith('```')) return { id: id('blk'), type: 'code', text: '' }; + if (!line.trim()) return { id: id('blk'), type: 'divider', text: '' }; + return { id: id('blk'), type: 'paragraph', text: line }; + }); +} + +function htmlToBlocks(content) { + const textOnly = String(content || '').replace(/<[^>]+>/g, '\n').split('\n').map((line) => line.trim()).filter(Boolean).join('\n'); + return markdownToBlocks(textOnly); +} + +function csvToTasks(content, workspaceId, pageId) { + const lines = String(content || '').trim().split(/\r?\n/).filter(Boolean); + const rows = lines.slice(1); + return rows.map((row) => { + const [title, status = 'todo', priority = 'medium', dueDate = ''] = row.split(',').map((cell) => cell.trim()); + return { + id: id('tsk'), + workspaceId, + pageId, + title, + description: '', + assigneeUserId: null, + dueDate, + priority, + status, + recurring: '', + reminderAt: '', + dependencies: [], + subItems: [], + milestone: false, + progress: 0, + linkedPageId: pageId, + createdAt: now(), + updatedAt: now(), + }; + }); +} + +function blocksToMarkdown(page) { + return (page.blocks || []).map((block) => { + switch (block.type) { + case 'heading1': return `# ${block.text}`; + case 'heading2': return `## ${block.text}`; + case 'heading3': return `### ${block.text}`; + case 'bullet': return `- ${block.text}`; + case 'numbered': return `1. ${block.text}`; + case 'todo': return `- [${block.checked ? 'x' : ' '}] ${block.text}`; + case 'quote': return `> ${block.text}`; + case 'code': return `\`\`\`\n${block.text}\n\`\`\``; + case 'divider': return '---'; + default: return block.text || ''; + } + }).join('\n\n'); +} + +function blocksToHtml(page) { + return `${(page.blocks || []).map(renderBlockHtml).join('')}`; +} + +function tasksToCsv(tasks) { + return ['title,status,priority,dueDate,assigneeUserId,progress'].concat( + tasks.map((task) => [task.title, task.status, task.priority, task.dueDate || '', task.assigneeUserId || '', task.progress || 0].map((value) => `"${String(value).replaceAll('"', '""')}"`).join(',')), + ).join('\n'); +} + +function buildSimplePdf(textContent) { + const text = String(textContent || '').replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)'); + const lines = text.split('\n').slice(0, 40); + let y = 760; + const commands = ['BT', '/F1 12 Tf']; + for (const line of lines) { + commands.push(`72 ${y} Td (${line.slice(0, 90)}) Tj`); + y -= 16; + } + commands.push('ET'); + const stream = commands.join('\n'); + const objects = []; + const pushObj = (body) => objects.push(body); + pushObj('<< /Type /Catalog /Pages 2 0 R >>'); + pushObj('<< /Type /Pages /Kids [3 0 R] /Count 1 >>'); + pushObj('<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>'); + pushObj(`<< /Length ${Buffer.byteLength(stream)} >>\nstream\n${stream}\nendstream`); + pushObj('<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>'); + let pdf = '%PDF-1.4\n'; + const offsets = [0]; + objects.forEach((obj, index) => { + offsets.push(Buffer.byteLength(pdf)); + pdf += `${index + 1} 0 obj\n${obj}\nendobj\n`; + }); + const xrefOffset = Buffer.byteLength(pdf); + pdf += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`; + for (let i = 1; i < offsets.length; i += 1) pdf += `${String(offsets[i]).padStart(10, '0')} 00000 n \n`; + pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF`; + return Buffer.from(pdf, 'utf8'); +} + +function workspaceTree(data, workspaceId, userId) { + const pages = data.pages.filter((page) => page.workspaceId === workspaceId && !page.deletedAt && canAccessPage(data, page, userId, 'view')); + return pages.map((page) => ({ + ...page, + backlinks: findBacklinks(data, page), + })); +} + +function databaseTitleField(database) { + return (database?.fields || []).find((field) => field.type === 'title') || database?.fields?.[0] || null; +} + +function databaseToCsv(database, rows) { + const fields = database?.fields || []; + const header = fields.map((field) => field.name); + return [header.join(',')].concat( + rows.map((row) => fields.map((field) => `"${String(row.values?.[field.id] ?? '').replaceAll('"', '""')}"`).join(',')), + ).join('\n'); +} + +function databaseToMarkdown(database, rows) { + const fields = database?.fields || []; + const header = `| ${fields.map((field) => field.name).join(' | ')} |`; + const divider = `| ${fields.map(() => '---').join(' | ')} |`; + const body = rows.map((row) => `| ${fields.map((field) => String(row.values?.[field.id] ?? '')).join(' | ')} |`); + return [`# ${database.title}`, '', header, divider, ...body].join('\n'); +} + +function search(data, userId, params) { + const q = String(params.q || '').trim().toLowerCase(); + const type = params.type || ''; + const workspaceId = params.workspaceId || ''; + const dateFrom = params.dateFrom || ''; + const dateTo = params.dateTo || ''; + const matchesDate = (value) => { + if (!value) return true; + if (dateFrom && value < dateFrom) return false; + if (dateTo && value > `${dateTo}T23:59:59.999Z`) return false; + return true; + }; + const pages = data.pages + .filter((page) => !page.deletedAt && canAccessPage(data, page, userId, 'view')) + .filter((page) => !workspaceId || page.workspaceId === workspaceId) + .filter((page) => !type || type === 'page') + .filter((page) => matchesDate(page.updatedAt)) + .map((page) => { + const haystack = `${page.title} ${(page.blocks || []).map((block) => block.text).join(' ')}`.toLowerCase(); + const score = q ? (page.title.toLowerCase().includes(q) ? 10 : 0) + (haystack.includes(q) ? 5 : 0) : 1; + return { + id: page.id, + type: 'page', + workspaceId: page.workspaceId, + title: page.title, + snippet: haystack.includes(q) ? (page.blocks.find((block) => String(block.text || '').toLowerCase().includes(q))?.text || page.title) : page.title, + score, + updatedAt: page.updatedAt, + }; + }); + const comments = data.comments + .filter((comment) => { + const page = data.pages.find((item) => item.id === comment.pageId); + return page && canAccessPage(data, page, userId, 'view'); + }) + .filter((comment) => !type || type === 'comment') + .filter((comment) => matchesDate(comment.createdAt)) + .map((comment) => ({ + id: comment.id, + type: 'comment', + workspaceId: data.pages.find((page) => page.id === comment.pageId)?.workspaceId, + title: `Comment on ${data.pages.find((page) => page.id === comment.pageId)?.title || 'page'}`, + snippet: comment.text, + score: q && comment.text.toLowerCase().includes(q) ? 4 : 0, + updatedAt: comment.createdAt, + })); + const tasks = data.tasks + .filter((task) => canAccessWorkspace(data, task.workspaceId, userId)) + .filter((task) => !workspaceId || task.workspaceId === workspaceId) + .filter((task) => !type || type === 'task') + .filter((task) => matchesDate(task.updatedAt)) + .map((task) => ({ + id: task.id, + type: 'task', + workspaceId: task.workspaceId, + title: task.title, + snippet: task.description, + score: q && `${task.title} ${task.description}`.toLowerCase().includes(q) ? 5 : 0, + updatedAt: task.updatedAt, + })); + const databases = data.databases + .filter((database) => canAccessDatabase(data, database, userId, 'view')) + .filter((database) => !workspaceId || database.workspaceId === workspaceId) + .filter((database) => !type || type === 'database') + .filter((database) => matchesDate(database.updatedAt)) + .map((database) => ({ + id: database.id, + type: 'database', + workspaceId: database.workspaceId, + title: database.title, + snippet: database.description || (database.fields || []).map((field) => field.name).join(', '), + score: q && `${database.title} ${database.description || ''}`.toLowerCase().includes(q) ? 6 : 0, + updatedAt: database.updatedAt, + })); + const databaseRows = data.databaseRows + .filter((row) => { + const database = getDatabase(data, row.databaseId); + return database && canAccessDatabase(data, database, userId, 'view'); + }) + .filter((row) => !workspaceId || row.workspaceId === workspaceId) + .filter((row) => !type || type === 'database_row') + .filter((row) => matchesDate(row.updatedAt)) + .map((row) => { + const database = getDatabase(data, row.databaseId); + const titleField = databaseTitleField(database); + const rowTitle = String(row.values?.[titleField?.id] ?? `Row ${row.id.slice(-4)}`); + const haystack = Object.values(row.values || {}).join(' ').toLowerCase(); + return { + id: row.id, + parentId: row.databaseId, + type: 'database_row', + workspaceId: row.workspaceId, + title: rowTitle, + snippet: `${database?.title || 'Database'} · ${haystack}`, + score: q && `${rowTitle} ${haystack}`.toLowerCase().includes(q) ? 7 : 0, + updatedAt: row.updatedAt, + }; + }); + const files = data.files + .filter((file) => canAccessWorkspace(data, file.workspaceId, userId)) + .filter((file) => !workspaceId || file.workspaceId === workspaceId) + .filter((file) => !type || type === 'file') + .map((file) => ({ + id: file.id, + type: 'file', + workspaceId: file.workspaceId, + title: file.name, + snippet: file.preview || file.type, + score: q && `${file.name} ${file.preview || ''}`.toLowerCase().includes(q) ? 3 : 0, + updatedAt: file.updatedAt, + })); + const results = [...pages, ...comments, ...tasks, ...databases, ...databaseRows, ...files] + .filter((item) => !q || item.score > 0) + .sort((a, b) => b.score - a.score || String(b.updatedAt).localeCompare(String(a.updatedAt))); + data.recentSearches = [{ userId, query: params.q || '', createdAt: now() }] + .concat((data.recentSearches || []).filter((entry) => !(entry.userId === userId && entry.query === (params.q || '')))) + .slice(0, 25); + return results; +} + +function emitEvent(type, payload) { + const event = `event: ${type}\ndata: ${JSON.stringify(payload)}\n\n`; + for (const client of SSE_CLIENTS.values()) { + const matchesWorkspace = !payload.workspaceId || client.workspaceId === payload.workspaceId; + const matchesPage = !payload.pageId || client.pageId === payload.pageId || !client.pageId; + if (matchesWorkspace && matchesPage) client.res.write(event); + } +} + +function currentPresence(pageId) { + const pagePresence = PRESENCE.get(pageId) || new Map(); + const expiry = Date.now() - 30000; + for (const [userId, entry] of pagePresence.entries()) { + if (new Date(entry.lastSeenAt).getTime() < expiry) pagePresence.delete(userId); + } + PRESENCE.set(pageId, pagePresence); + return Array.from(pagePresence.values()); +} + +function buildBootstrap(data, user) { + const accessibleWorkspaceIds = getAccessibleWorkspaceIds(data, user?.id); + const workspaces = data.workspaces.filter((workspace) => accessibleWorkspaceIds.includes(workspace.id) || workspace.ownerId === 'system'); + const pages = data.pages + .filter((page) => { + if (!user) return page.published || getWorkspace(data, page.workspaceId)?.ownerId === 'system'; + if (page.deletedAt) return canAccessPage(data, page, user.id, 'restore'); + return canAccessPage(data, page, user.id, 'view'); + }) + .map((page) => ({ + ...page, + backlinks: findBacklinks(data, page), + commentsSummary: computeCommentsSummary(data, page.id), + })); + const directoryUserIds = new Set( + workspaces + .flatMap((workspace) => workspace.members || []) + .map((member) => member.userId) + .concat(user?.id ? [user.id] : []), + ); + return { + user: publicUser(user), + workspaces, + pages, + databases: user ? data.databases.filter((database) => canAccessDatabase(data, database, user.id, 'view')) : data.databases.filter((database) => getWorkspace(data, database.workspaceId)?.ownerId === 'system'), + databaseRows: user ? data.databaseRows.filter((row) => { + const database = getDatabase(data, row.databaseId); + return database && canAccessDatabase(data, database, user.id, 'view'); + }) : data.databaseRows.filter((row) => getWorkspace(data, row.workspaceId)?.ownerId === 'system'), + comments: user ? data.comments.filter((comment) => { + const page = data.pages.find((item) => item.id === comment.pageId); + return page && canAccessPage(data, page, user.id, 'view'); + }) : [], + tasks: user ? data.tasks.filter((task) => accessibleWorkspaceIds.includes(task.workspaceId)) : [], + files: user ? data.files.filter((file) => accessibleWorkspaceIds.includes(file.workspaceId)).map((file) => ({ ...file, signedUrl: `/files/${file.id}?token=${signedFileToken(file.id)}` })) : [], + notifications: user ? data.notifications.filter((notification) => notification.userId === user.id) : [], + invitations: user ? data.invitations.filter((invitation) => { + const workspace = getWorkspace(data, invitation.workspaceId); + if (!workspace) return false; + return workspace.ownerId === user.id || invitation.email === user.email || getWorkspaceRole(data, workspace.id, user.id) === 'owner'; + }) : [], + activity: user ? data.activity.filter((entry) => !entry.workspaceId || accessibleWorkspaceIds.includes(entry.workspaceId)) : [], + templates: data.templates, + recentSearches: user ? (data.recentSearches || []).filter((entry) => entry.userId === user.id) : [], + emailOutbox: user ? data.emailOutbox.filter((entry) => entry.to === user.email).slice(0, 20) : [], + sessions: user ? data.sessions.filter((session) => session.userId === user.id) : [], + directory: data.users.filter((entry) => directoryUserIds.has(entry.id)).map(publicUser), + }; +} + +async function handleApi(req, res, pathname, data, sessionInfo) { + const user = sessionInfo?.user || null; + + if (pathname === '/api/bootstrap' && req.method === 'GET') { + saveDb(data); + return json(res, 200, buildBootstrap(data, user)); + } + + if (pathname === '/api/auth/register' && req.method === 'POST') { + const body = await readBody(req); + if (!body.email || !body.password || !body.name) return json(res, 400, { error: 'name, email, and password are required' }); + if (data.users.some((item) => item.email.toLowerCase() === body.email.toLowerCase())) return json(res, 400, { error: 'Email already exists' }); + const newUser = { + id: id('usr'), + name: body.name, + email: body.email.toLowerCase(), + passwordHash: hashPassword(body.password), + avatarColor: body.avatarColor || '#4f46e5', + title: 'Workspace builder', + bio: '', + createdAt: now(), + notificationPreferences: {}, + identity: { authMethods: ['password'], lastAuditAt: now(), deviceMetadata: [] }, + twoFactorEnabled: false, + twoFactorSecret: '', + recoveryCodes: [], + }; + ensureUserRecordDefaults(newUser); + ensureAuthMethod(newUser, 'password'); + data.users.push(newUser); + bootstrapWorkspaceForUser(data, newUser); + acceptPendingInvitations(data, newUser); + const session = createSession(data, newUser, req); + setCookie(res, SESSION_COOKIE, session.id); + saveDb(data); + return json(res, 201, { user: publicUser(newUser) }); + } + + if (pathname === '/api/auth/login' && req.method === 'POST') { + const body = await readBody(req); + const existing = data.users.find((item) => item.email === String(body.email || '').toLowerCase()); + if (!existing || !verifyPassword(body.password || '', existing.passwordHash)) return json(res, 401, { error: 'Invalid credentials' }); + if (existing.twoFactorEnabled) { + const code = String(body.code || ''); + if (!verifyTotpCode(existing.twoFactorSecret, code)) return json(res, 401, { error: '2FA code required or invalid' }); + } + ensureAuthMethod(existing, 'password'); + acceptPendingInvitations(data, existing); + const session = createSession(data, existing, req); + setCookie(res, SESSION_COOKIE, session.id); + saveDb(data); + return json(res, 200, { user: publicUser(existing) }); + } + + if (pathname === '/api/auth/sso' && req.method === 'POST') { + const body = await readBody(req); + const workspace = getWorkspace(data, body.workspaceId); + if (!workspace) return json(res, 404, { error: 'Workspace not found' }); + if (!workspace.settings?.samlEnabled) return json(res, 400, { error: 'SAML/SSO is not enabled for this workspace' }); + const email = String(body.email || '').toLowerCase(); + if (!email) return json(res, 400, { error: 'email is required' }); + const emailDomain = email.split('@')[1] || ''; + if (workspace.settings.domainRestriction && emailDomain !== workspace.settings.domainRestriction) { + return json(res, 403, { error: `SSO restricted to ${workspace.settings.domainRestriction}` }); + } + let existing = data.users.find((item) => item.email === email); + if (!existing) { + existing = { + id: id('usr'), + name: body.name || email.split('@')[0], + email, + passwordHash: hashPassword(crypto.randomUUID()), + avatarColor: '#0f766e', + title: 'Enterprise member', + bio: '', + createdAt: now(), + notificationPreferences: {}, + identity: { authMethods: ['saml'], lastAuditAt: now(), deviceMetadata: [], samlExternalId: '', scimExternalId: '' }, + twoFactorEnabled: false, + twoFactorSecret: '', + recoveryCodes: [], + }; + ensureUserRecordDefaults(existing); + data.users.push(existing); + bootstrapWorkspaceForUser(data, existing); + } + ensureAuthMethod(existing, 'saml'); + existing.identity.samlExternalId = body.externalId || existing.identity.samlExternalId || `saml-${workspace.id}-${email}`; + acceptPendingInvitations(data, existing, existing.id, 'workspace.sso-login'); + const session = createSession(data, existing, req); + setCookie(res, SESSION_COOKIE, session.id); + createActivity(data, { + actorUserId: existing.id, + workspaceId: workspace.id, + kind: 'workspace.sso-login', + message: `${existing.email} signed in with SSO to ${workspace.name}`, + }); + saveDb(data); + return json(res, 200, { user: publicUser(existing), workspaceId: workspace.id }); + } + + if (pathname === '/api/auth/oauth' && req.method === 'POST') { + const body = await readBody(req); + const provider = ['google', 'github'].includes(body.provider) ? body.provider : 'google'; + const email = `${provider}-${Date.now()}@demo.noteflow.local`; + const demoUser = { + id: id('usr'), + name: `${provider[0].toUpperCase()}${provider.slice(1)} User`, + email, + passwordHash: hashPassword(crypto.randomUUID()), + avatarColor: provider === 'google' ? '#ea4335' : '#111827', + title: `${provider} linked account`, + bio: `Signed in with ${provider}`, + createdAt: now(), + notificationPreferences: {}, + identity: { authMethods: [provider], lastAuditAt: now(), deviceMetadata: [] }, + twoFactorEnabled: false, + twoFactorSecret: '', + recoveryCodes: [], + }; + ensureUserRecordDefaults(demoUser); + ensureAuthMethod(demoUser, provider); + data.users.push(demoUser); + bootstrapWorkspaceForUser(data, demoUser); + acceptPendingInvitations(data, demoUser); + const session = createSession(data, demoUser, req); + setCookie(res, SESSION_COOKIE, session.id); + saveDb(data); + return json(res, 200, { user: publicUser(demoUser) }); + } + + if (pathname === '/api/auth/magic-link/request' && req.method === 'POST') { + const body = await readBody(req); + const token = id('magic'); + data.magicLinks ||= []; + data.magicLinks.push({ token, email: String(body.email || '').toLowerCase(), expiresAt: new Date(Date.now() + 15 * 60 * 1000).toISOString() }); + saveDb(data); + return json(res, 200, { token, note: 'Local dev returns the magic token directly.' }); + } + + if (pathname === '/api/auth/magic-link/consume' && req.method === 'POST') { + const body = await readBody(req); + const record = (data.magicLinks || []).find((item) => item.token === body.token && item.expiresAt > now()); + if (!record) return json(res, 400, { error: 'Invalid or expired token' }); + let existing = data.users.find((item) => item.email === record.email); + if (!existing) { + existing = { + id: id('usr'), + name: record.email.split('@')[0], + email: record.email, + passwordHash: hashPassword(crypto.randomUUID()), + avatarColor: '#16a34a', + title: 'Magic link account', + bio: '', + createdAt: now(), + notificationPreferences: {}, + identity: { authMethods: ['magic-link'], lastAuditAt: now(), deviceMetadata: [] }, + twoFactorEnabled: false, + twoFactorSecret: '', + recoveryCodes: [], + }; + ensureUserRecordDefaults(existing); + data.users.push(existing); + bootstrapWorkspaceForUser(data, existing); + } + ensureAuthMethod(existing, 'magic-link'); + acceptPendingInvitations(data, existing); + const session = createSession(data, existing, req); + setCookie(res, SESSION_COOKIE, session.id); + saveDb(data); + return json(res, 200, { user: publicUser(existing) }); + } + + if (pathname === '/api/auth/logout' && req.method === 'POST') { + if (sessionInfo?.session) data.sessions = data.sessions.filter((item) => item.id !== sessionInfo.session.id); + clearCookie(res, SESSION_COOKIE); + saveDb(data); + return json(res, 200, { ok: true }); + } + + if (pathname === '/api/auth/sessions' && req.method === 'GET') { + if (!user) return json(res, 401, { error: 'Unauthorized' }); + return json(res, 200, data.sessions.filter((session) => session.userId === user.id)); + } + + if (pathname === '/api/auth/2fa/setup' && req.method === 'POST') { + if (!user) return json(res, 401, { error: 'Unauthorized' }); + user.twoFactorSecret = crypto.randomBytes(10).toString('hex'); + user.recoveryCodes = Array.from({ length: 5 }, () => crypto.randomBytes(3).toString('hex')); + saveDb(data); + return json(res, 200, { + secret: user.twoFactorSecret, + currentCode: generateTotpCode(user.twoFactorSecret), + recoveryCodes: user.recoveryCodes, + note: 'For local dev, the current TOTP code is returned directly.', + }); + } + + if (pathname === '/api/auth/2fa/verify' && req.method === 'POST') { + if (!user) return json(res, 401, { error: 'Unauthorized' }); + const body = await readBody(req); + if (!verifyTotpCode(user.twoFactorSecret, body.code)) return json(res, 400, { error: 'Invalid code' }); + user.twoFactorEnabled = true; + user.identity.lastAuditAt = now(); + saveDb(data); + return json(res, 200, { ok: true }); + } + + if (pathname === '/api/profile' && req.method === 'PUT') { + if (!user) return json(res, 401, { error: 'Unauthorized' }); + const body = await readBody(req); + user.name = body.name || user.name; + user.title = body.title ?? user.title; + user.bio = body.bio ?? user.bio; + user.avatarColor = body.avatarColor || user.avatarColor; + user.identity.lastAuditAt = now(); + saveDb(data); + emitEvent('profile.updated', { workspaceId: null, actorUserId: user.id }); + return json(res, 200, { user: publicUser(user) }); + } + + if (pathname === '/api/workspaces' && req.method === 'POST') { + if (!user) return json(res, 401, { error: 'Unauthorized' }); + const body = await readBody(req); + const workspace = { + id: id('ws'), + name: body.name || 'Untitled workspace', + kind: body.kind === 'team' ? 'team' : 'personal', + ownerId: user.id, + createdAt: now(), + settings: { + storageQuotaMb: Number(body.storageQuotaMb || 250), + domainRestriction: body.domainRestriction || '', + allowGuests: body.allowGuests !== false, + publicSharing: body.publicSharing !== false, + databasePermissions: body.databasePermissions || 'workspace-role', + samlEnabled: Boolean(body.samlEnabled), + scimEnabled: Boolean(body.scimEnabled), + }, + members: [{ userId: user.id, role: 'owner', invitedAt: now(), acceptedAt: now() }], + }; + data.workspaces.push(workspace); + saveDb(data); + createActivity(data, { actorUserId: user.id, workspaceId: workspace.id, kind: 'workspace.created', message: `Created workspace ${workspace.name}` }); + emitEvent('workspace.created', { workspaceId: workspace.id, actorUserId: user.id }); + return json(res, 201, workspace); + } + + const workspaceSettingsMatch = pathname.match(/^\/api\/workspaces\/([^/]+)\/settings$/); + if (workspaceSettingsMatch) { + if (!user) return json(res, 401, { error: 'Unauthorized' }); + const workspace = getWorkspace(data, workspaceSettingsMatch[1]); + if (!workspace || getWorkspaceRole(data, workspace.id, user.id) !== 'owner') return json(res, 403, { error: 'Forbidden' }); + if (req.method === 'GET') return json(res, 200, workspace); + if (req.method === 'PUT') { + const body = await readBody(req); + workspace.settings = { + ...workspace.settings, + storageQuotaMb: Number(body.storageQuotaMb ?? workspace.settings.storageQuotaMb ?? 250), + domainRestriction: body.domainRestriction ?? workspace.settings.domainRestriction ?? '', + allowGuests: body.allowGuests ?? workspace.settings.allowGuests ?? true, + publicSharing: body.publicSharing ?? workspace.settings.publicSharing ?? true, + databasePermissions: body.databasePermissions ?? workspace.settings.databasePermissions ?? 'workspace-role', + samlEnabled: body.samlEnabled ?? workspace.settings.samlEnabled ?? false, + scimEnabled: body.scimEnabled ?? workspace.settings.scimEnabled ?? false, + }; + createActivity(data, { + actorUserId: user.id, + workspaceId: workspace.id, + kind: 'workspace.settings.updated', + message: `Updated security and sharing settings for ${workspace.name}`, + }); + saveDb(data); + emitEvent('workspace.settings.updated', { workspaceId: workspace.id, actorUserId: user.id }); + return json(res, 200, workspace); + } + } + + const workspaceInvitationsMatch = pathname.match(/^\/api\/workspaces\/([^/]+)\/invitations$/); + if (workspaceInvitationsMatch && req.method === 'GET') { + if (!user) return json(res, 401, { error: 'Unauthorized' }); + const workspace = getWorkspace(data, workspaceInvitationsMatch[1]); + if (!workspace || getWorkspaceRole(data, workspace.id, user.id) !== 'owner') return json(res, 403, { error: 'Forbidden' }); + return json(res, 200, data.invitations.filter((invitation) => invitation.workspaceId === workspace.id)); + } + + const workspaceActivityMatch = pathname.match(/^\/api\/workspaces\/([^/]+)\/activity$/); + if (workspaceActivityMatch && req.method === 'GET') { + if (!user) return json(res, 401, { error: 'Unauthorized' }); + const workspace = getWorkspace(data, workspaceActivityMatch[1]); + if (!workspace || !canAccessWorkspace(data, workspace.id, user.id)) return json(res, 403, { error: 'Forbidden' }); + return json(res, 200, data.activity.filter((entry) => entry.workspaceId === workspace.id)); + } + + const workspaceInviteMatch = pathname.match(/^\/api\/workspaces\/([^/]+)\/invite$/); + if (workspaceInviteMatch && req.method === 'POST') { + if (!user) return json(res, 401, { error: 'Unauthorized' }); + const workspace = getWorkspace(data, workspaceInviteMatch[1]); + if (!workspace || getWorkspaceRole(data, workspace.id, user.id) !== 'owner') return json(res, 403, { error: 'Forbidden' }); + const body = await readBody(req); + const invitation = { id: id('inv'), workspaceId: workspace.id, email: String(body.email || '').toLowerCase(), role: body.role || 'viewer', invitedBy: user.id, createdAt: now(), acceptedAt: '' }; + data.invitations.push(invitation); + const invitee = data.users.find((item) => item.email === invitation.email); + if (invitee) { + upsertWorkspaceMember(workspace, invitee.id, invitation.role); + invitation.acceptedAt = now(); + queueNotification(data, { userId: invitee.id, type: 'workspace-invite', title: `Invited to ${workspace.name}`, body: `${user.name} added you as ${invitation.role}.`, category: 'workspace' }); + } + createActivity(data, { + actorUserId: user.id, + workspaceId: workspace.id, + kind: 'workspace.invited', + message: `Invited ${invitation.email} to ${workspace.name} as ${invitation.role}`, + }); + saveDb(data); + return json(res, 200, invitation); + } + + const workspaceMemberRoleMatch = pathname.match(/^\/api\/workspaces\/([^/]+)\/members\/([^/]+)$/); + if (workspaceMemberRoleMatch && req.method === 'PUT') { + if (!user) return json(res, 401, { error: 'Unauthorized' }); + const workspace = getWorkspace(data, workspaceMemberRoleMatch[1]); + if (!workspace || getWorkspaceRole(data, workspace.id, user.id) !== 'owner') return json(res, 403, { error: 'Forbidden' }); + const body = await readBody(req); + const member = getMembership(workspace, workspaceMemberRoleMatch[2]); + if (!member) return json(res, 404, { error: 'Member not found' }); + member.role = body.role || member.role; + saveDb(data); + return json(res, 200, member); + } + + const workspaceDomainMatch = pathname.match(/^\/api\/workspaces\/([^/]+)\/domain$/); + if (workspaceDomainMatch && req.method === 'PUT') { + if (!user) return json(res, 401, { error: 'Unauthorized' }); + const workspace = getWorkspace(data, workspaceDomainMatch[1]); + if (!workspace || getWorkspaceRole(data, workspace.id, user.id) !== 'owner') return json(res, 403, { error: 'Forbidden' }); + const body = await readBody(req); + workspace.settings.domainRestriction = body.domain || ''; + workspace.settings.samlEnabled = Boolean(body.samlEnabled); + workspace.settings.scimEnabled = Boolean(body.scimEnabled); + saveDb(data); + return json(res, 200, workspace); + } + + const workspaceScimUsersMatch = pathname.match(/^\/api\/workspaces\/([^/]+)\/scim\/users$/); + if (workspaceScimUsersMatch && req.method === 'POST') { + if (!user) return json(res, 401, { error: 'Unauthorized' }); + const workspace = getWorkspace(data, workspaceScimUsersMatch[1]); + if (!workspace || getWorkspaceRole(data, workspace.id, user.id) !== 'owner') return json(res, 403, { error: 'Forbidden' }); + if (!workspace.settings?.scimEnabled) return json(res, 400, { error: 'SCIM provisioning is not enabled for this workspace' }); + const body = await readBody(req); + const email = String(body.email || '').toLowerCase(); + if (!email || !body.externalId) return json(res, 400, { error: 'email and externalId are required' }); + let provisioned = data.users.find((item) => item.identity?.scimExternalId === body.externalId || item.email === email); + if (!provisioned) { + provisioned = { + id: id('usr'), + name: body.name || email.split('@')[0], + email, + passwordHash: hashPassword(crypto.randomUUID()), + avatarColor: body.avatarColor || '#7c3aed', + title: body.title || 'Provisioned member', + bio: '', + createdAt: now(), + notificationPreferences: {}, + identity: { authMethods: ['scim'], lastAuditAt: now(), deviceMetadata: [], samlExternalId: '', scimExternalId: '' }, + twoFactorEnabled: false, + twoFactorSecret: '', + recoveryCodes: [], + }; + ensureUserRecordDefaults(provisioned); + data.users.push(provisioned); + } + ensureAuthMethod(provisioned, 'scim'); + provisioned.name = body.name || provisioned.name; + provisioned.title = body.title || provisioned.title; + provisioned.identity.scimExternalId = body.externalId; + upsertWorkspaceMember(workspace, provisioned.id, body.role || 'viewer'); + createActivity(data, { + actorUserId: user.id, + workspaceId: workspace.id, + kind: 'workspace.scim-provisioned', + message: `Provisioned ${provisioned.email} via SCIM`, + }); + saveDb(data); + emitEvent('workspace.scim-provisioned', { workspaceId: workspace.id, actorUserId: user.id, targetUserId: provisioned.id }); + return json(res, 201, { user: publicUser(provisioned), workspaceId: workspace.id }); + } + + if (pathname === '/api/databases' && req.method === 'GET') { + if (!user) return json(res, 401, { error: 'Unauthorized' }); + return json(res, 200, data.databases.filter((database) => canAccessDatabase(data, database, user.id, 'view'))); + } + + if (pathname === '/api/databases' && req.method === 'POST') { + if (!user) return json(res, 401, { error: 'Unauthorized' }); + const body = await readBody(req); + if (!canAccessWorkspace(data, body.workspaceId, user.id)) return json(res, 403, { error: 'Forbidden' }); + const database = { + id: id('db'), + workspaceId: body.workspaceId, + pageId: body.pageId || null, + title: body.title || 'Untitled database', + description: body.description || '', + icon: body.icon || '🗂️', + fields: Array.isArray(body.fields) && body.fields.length ? body.fields : [ + { id: 'fld_name', name: 'Name', type: 'title' }, + { id: 'fld_status', name: 'Status', type: 'status', options: ['Backlog', 'In Progress', 'Done'] }, + { id: 'fld_due', name: 'Due', type: 'date' }, + ], + views: Array.isArray(body.views) && body.views.length ? body.views : [ + { id: 'view_table', name: 'Table', type: 'table' }, + { id: 'view_board', name: 'Board', type: 'board' }, + { id: 'view_calendar', name: 'Calendar', type: 'calendar' }, + ], + permissions: Array.isArray(body.permissions) ? body.permissions : [], + createdAt: now(), + updatedAt: now(), + createdBy: user.id, + }; + data.databases.push(database); + saveDb(data); + createActivity(data, { actorUserId: user.id, workspaceId: database.workspaceId, kind: 'database.created', message: `Created database ${database.title}` }); + emitEvent('database.created', { workspaceId: database.workspaceId, databaseId: database.id, actorUserId: user.id }); + return json(res, 201, database); + } + + const databaseMatch = pathname.match(/^\/api\/databases\/([^/]+)$/); + if (databaseMatch && req.method === 'PUT') { + if (!user) return json(res, 401, { error: 'Unauthorized' }); + const database = getDatabase(data, databaseMatch[1]); + if (!database || !canAccessDatabase(data, database, user.id, 'edit')) return json(res, 403, { error: 'Forbidden' }); + const body = await readBody(req); + database.title = body.title ?? database.title; + database.description = body.description ?? database.description; + database.icon = body.icon ?? database.icon; + database.pageId = body.pageId !== undefined ? body.pageId : database.pageId; + database.fields = Array.isArray(body.fields) && body.fields.length ? body.fields : database.fields; + database.views = Array.isArray(body.views) && body.views.length ? body.views : database.views; + database.permissions = Array.isArray(body.permissions) ? body.permissions : database.permissions; + database.updatedAt = now(); + saveDb(data); + emitEvent('database.updated', { workspaceId: database.workspaceId, databaseId: database.id, actorUserId: user.id }); + return json(res, 200, database); + } + + const databaseRowsMatch = pathname.match(/^\/api\/databases\/([^/]+)\/rows$/); + if (databaseRowsMatch && req.method === 'POST') { + if (!user) return json(res, 401, { error: 'Unauthorized' }); + const database = getDatabase(data, databaseRowsMatch[1]); + if (!database || !canAccessDatabase(data, database, user.id, 'edit')) return json(res, 403, { error: 'Forbidden' }); + const body = await readBody(req); + const row = { + id: id('row'), + databaseId: database.id, + workspaceId: database.workspaceId, + pageId: body.pageId || database.pageId || null, + values: body.values || {}, + verified: Boolean(body.verified), + createdAt: now(), + updatedAt: now(), + createdBy: user.id, + }; + data.databaseRows.push(row); + database.updatedAt = now(); + const personValue = Object.values(row.values || {}).find((value) => data.users.some((entry) => entry.id === value)); + if (personValue) queueNotification(data, { userId: personValue, type: 'database-assignment', title: `Assigned in ${database.title}`, body: String(row.values?.[databaseTitleField(database)?.id] || 'Database row'), category: 'database' }); + saveDb(data); + emitEvent('database.row.created', { workspaceId: database.workspaceId, databaseId: database.id, rowId: row.id, actorUserId: user.id }); + return json(res, 201, row); + } + + const databaseRowMatch = pathname.match(/^\/api\/databases\/([^/]+)\/rows\/([^/]+)$/); + if (databaseRowMatch && req.method === 'PUT') { + if (!user) return json(res, 401, { error: 'Unauthorized' }); + const database = getDatabase(data, databaseRowMatch[1]); + if (!database || !canAccessDatabase(data, database, user.id, 'edit')) return json(res, 403, { error: 'Forbidden' }); + const row = data.databaseRows.find((item) => item.id === databaseRowMatch[2] && item.databaseId === database.id); + if (!row) return json(res, 404, { error: 'Row not found' }); + const body = await readBody(req); + row.values = body.values ? { ...row.values, ...body.values } : row.values; + row.pageId = body.pageId !== undefined ? body.pageId : row.pageId; + row.verified = body.verified ?? row.verified; + row.updatedAt = now(); + database.updatedAt = now(); + saveDb(data); + emitEvent('database.row.updated', { workspaceId: database.workspaceId, databaseId: database.id, rowId: row.id, actorUserId: user.id }); + return json(res, 200, row); + } + + if (pathname === '/api/pages' && req.method === 'POST') { + if (!user) return json(res, 401, { error: 'Unauthorized' }); + const body = await readBody(req); + if (!canAccessWorkspace(data, body.workspaceId, user.id)) return json(res, 403, { error: 'Forbidden' }); + const template = data.templates.find((item) => item.id === body.templateId) || null; + const page = { + id: id('pg'), + workspaceId: body.workspaceId, + parentId: body.parentId || null, + title: body.title || template?.name || 'Untitled', + icon: body.icon || template?.icon || '📄', + cover: body.cover || '', + slug: body.slug || `${(body.title || template?.name || 'untitled').toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${Math.random().toString(36).slice(2, 6)}`, + customUrl: '', + kind: body.kind || 'page', + locked: false, + fullWidth: false, + smallText: false, + verified: false, + deletedAt: null, + favoriteBy: [], + recentBy: [], + published: false, + seo: { title: body.title || template?.name || 'Untitled', description: '' }, + share: { enabled: false, token: id('shr'), expiresAt: '', allowedDomain: '' }, + permissions: [], + blocks: normalizeBlocks(body.blocks || template?.blocks || [{ id: id('blk'), type: 'paragraph', text: '' }]), + history: [], + commentsEnabled: true, + commentsSummary: { total: 0, unresolved: 0 }, + templateId: template?.id || null, + createdAt: now(), + updatedAt: now(), + createdBy: user.id, + }; + data.pages.push(page); + updateRecentPage(page, user.id); + saveDb(data); + createActivity(data, { actorUserId: user.id, workspaceId: page.workspaceId, pageId: page.id, kind: 'page.created', message: `Created page ${page.title}` }); + emitEvent('page.created', { workspaceId: page.workspaceId, pageId: page.id, actorUserId: user.id }); + return json(res, 201, page); + } + + const pageMatch = pathname.match(/^\/api\/pages\/([^/]+)$/); + if (pageMatch && req.method === 'PUT') { + if (!user) return json(res, 401, { error: 'Unauthorized' }); + const page = data.pages.find((item) => item.id === pageMatch[1]); + if (!page || !canAccessPage(data, page, user.id, 'edit')) return json(res, 403, { error: 'Forbidden' }); + const body = await readBody(req); + createHistorySnapshot(page, user.id); + page.title = body.title ?? page.title; + page.icon = body.icon ?? page.icon; + page.cover = body.cover ?? page.cover; + page.parentId = body.parentId !== undefined ? body.parentId : page.parentId; + page.slug = body.slug ?? page.slug; + page.customUrl = body.customUrl ?? page.customUrl; + page.locked = body.locked ?? page.locked; + page.fullWidth = body.fullWidth ?? page.fullWidth; + page.smallText = body.smallText ?? page.smallText; + page.verified = body.verified ?? page.verified; + page.kind = body.kind ?? page.kind; + page.permissions = Array.isArray(body.permissions) ? body.permissions : page.permissions; + page.seo = body.seo ? { ...page.seo, ...body.seo } : page.seo; + if (body.blocks) page.blocks = normalizeBlocks(body.blocks); + page.updatedAt = now(); + syncSyncedBlocks(data); + saveDb(data); + createActivity(data, { actorUserId: user.id, workspaceId: page.workspaceId, pageId: page.id, kind: 'page.updated', message: `Updated page ${page.title}` }); + emitEvent('page.updated', { workspaceId: page.workspaceId, pageId: page.id, actorUserId: user.id }); + return json(res, 200, page); + } + + const pageDuplicateMatch = pathname.match(/^\/api\/pages\/([^/]+)\/duplicate$/); + if (pageDuplicateMatch && req.method === 'POST') { + if (!user) return json(res, 401, { error: 'Unauthorized' }); + const page = data.pages.find((item) => item.id === pageDuplicateMatch[1]); + if (!page || !canAccessPage(data, page, user.id, 'view')) return json(res, 403, { error: 'Forbidden' }); + const copy = structuredClone(page); + copy.id = id('pg'); + copy.title = `${page.title} (Copy)`; + copy.slug = `${page.slug}-copy-${Math.random().toString(36).slice(2, 5)}`; + copy.createdAt = now(); + copy.updatedAt = now(); + copy.createdBy = user.id; + copy.history = []; + copy.deletedAt = null; + data.pages.push(copy); + saveDb(data); + emitEvent('page.created', { workspaceId: copy.workspaceId, pageId: copy.id, actorUserId: user.id }); + return json(res, 201, copy); + } + + const pageTrashMatch = pathname.match(/^\/api\/pages\/([^/]+)\/(trash|restore|favorite|recent|publish|share)$/); + if (pageTrashMatch && req.method === 'POST') { + if (!user) return json(res, 401, { error: 'Unauthorized' }); + const page = data.pages.find((item) => item.id === pageTrashMatch[1]); + const action = pageTrashMatch[2]; + const mode = action === 'recent' ? 'view' : action === 'restore' ? 'restore' : 'edit'; + if (!page || !canAccessPage(data, page, user.id, mode)) return json(res, 403, { error: 'Forbidden' }); + const body = await readBody(req).catch(() => ({})); + const workspace = getWorkspace(data, page.workspaceId); + if (action === 'trash') page.deletedAt = now(); + if (action === 'restore') page.deletedAt = null; + if (action === 'favorite') { + page.favoriteBy ||= []; + page.favoriteBy = page.favoriteBy.includes(user.id) ? page.favoriteBy.filter((item) => item !== user.id) : page.favoriteBy.concat(user.id); + } + if (action === 'recent') updateRecentPage(page, user.id); + if (action === 'publish') { + const nextPublished = body.published ?? !page.published; + if (nextPublished && workspace?.settings?.publicSharing === false) return json(res, 400, { error: 'Workspace public sharing is disabled' }); + page.published = body.published ?? !page.published; + page.slug = body.slug || page.slug; + page.seo = { ...page.seo, ...(body.seo || {}) }; + page.share.expiresAt = body.expiresAt || page.share.expiresAt; + page.share.allowedDomain = body.allowedDomain ?? page.share.allowedDomain; + } + if (action === 'share') { + const nextShared = body.enabled ?? !page.share.enabled; + if (nextShared && workspace?.settings?.publicSharing === false) return json(res, 400, { error: 'Workspace public sharing is disabled' }); + if (nextShared && workspace?.settings?.allowGuests === false && !(body.allowedDomain || page.share.allowedDomain)) { + return json(res, 400, { error: 'Guest sharing is disabled for this workspace. Add an allowed domain.' }); + } + page.share.enabled = body.enabled ?? !page.share.enabled; + page.share.expiresAt = body.expiresAt || page.share.expiresAt; + page.share.allowedDomain = body.allowedDomain ?? page.share.allowedDomain; + page.share.token ||= id('shr'); + } + page.updatedAt = now(); + saveDb(data); + emitEvent(`page.${action}`, { workspaceId: page.workspaceId, pageId: page.id, actorUserId: user.id }); + return json(res, 200, page); + } + + const commentsMatch = pathname.match(/^\/api\/pages\/([^/]+)\/comments$/); + if (commentsMatch) { + const page = data.pages.find((item) => item.id === commentsMatch[1]); + if (!page) return json(res, 404, { error: 'Page not found' }); + if (req.method === 'GET') { + if (!user || !canAccessPage(data, page, user.id, 'view')) return json(res, 403, { error: 'Forbidden' }); + return json(res, 200, data.comments.filter((comment) => comment.pageId === page.id)); + } + if (req.method === 'POST') { + if (!user || !canAccessPage(data, page, user.id, 'comment')) return json(res, 403, { error: 'Forbidden' }); + const body = await readBody(req); + const comment = { + id: id('cmt'), + pageId: page.id, + parentCommentId: body.parentCommentId || null, + blockId: body.blockId || null, + authorUserId: user.id, + text: body.text || '', + mentions: Array.isArray(body.mentions) ? body.mentions : [], + resolved: false, + createdAt: now(), + }; + data.comments.push(comment); + page.commentsSummary = computeCommentsSummary(data, page.id); + for (const mentionUserId of comment.mentions) { + queueNotification(data, { userId: mentionUserId, type: 'mention', title: `${user.name} mentioned you`, body: comment.text, category: 'comment', batchKey: `mention:${page.id}` }); + } + saveDb(data); + emitEvent('comment.created', { workspaceId: page.workspaceId, pageId: page.id, commentId: comment.id, actorUserId: user.id }); + return json(res, 201, comment); + } + } + + const commentResolveMatch = pathname.match(/^\/api\/comments\/([^/]+)\/resolve$/); + if (commentResolveMatch && req.method === 'POST') { + if (!user) return json(res, 401, { error: 'Unauthorized' }); + const comment = data.comments.find((item) => item.id === commentResolveMatch[1]); + const page = data.pages.find((item) => item.id === comment?.pageId); + if (!comment || !page || !canAccessPage(data, page, user.id, 'comment')) return json(res, 403, { error: 'Forbidden' }); + comment.resolved = !comment.resolved; + page.commentsSummary = computeCommentsSummary(data, page.id); + saveDb(data); + emitEvent('comment.resolved', { workspaceId: page.workspaceId, pageId: page.id, commentId: comment.id, actorUserId: user.id }); + return json(res, 200, comment); + } + + const pageHistoryMatch = pathname.match(/^\/api\/pages\/([^/]+)\/history(?:\/([^/]+)\/restore)?$/); + if (pageHistoryMatch) { + if (!user) return json(res, 401, { error: 'Unauthorized' }); + const page = data.pages.find((item) => item.id === pageHistoryMatch[1]); + if (!page || !canAccessPage(data, page, user.id, pageHistoryMatch[2] ? 'edit' : 'view')) return json(res, 403, { error: 'Forbidden' }); + if (req.method === 'GET') return json(res, 200, page.history || []); + if (req.method === 'POST' && pageHistoryMatch[2]) { + const version = (page.history || []).find((item) => item.id === pageHistoryMatch[2]); + if (!version) return json(res, 404, { error: 'Version not found' }); + createHistorySnapshot(page, user.id); + page.title = version.title; + page.blocks = structuredClone(version.blocks); + Object.assign(page, version.meta || {}); + page.updatedAt = now(); + saveDb(data); + emitEvent('page.restored', { workspaceId: page.workspaceId, pageId: page.id, actorUserId: user.id }); + return json(res, 200, page); + } + } + + if (pathname === '/api/search' && req.method === 'GET') { + if (!user) return json(res, 401, { error: 'Unauthorized' }); + const url = new URL(req.url, `http://${req.headers.host}`); + const results = search(data, user.id, Object.fromEntries(url.searchParams.entries())); + saveDb(data); + return json(res, 200, results); + } + + if (pathname === '/api/tasks' && req.method === 'GET') { + if (!user) return json(res, 401, { error: 'Unauthorized' }); + return json(res, 200, data.tasks.filter((task) => canAccessWorkspace(data, task.workspaceId, user.id))); + } + + if (pathname === '/api/tasks' && req.method === 'POST') { + if (!user) return json(res, 401, { error: 'Unauthorized' }); + const body = await readBody(req); + if (!canAccessWorkspace(data, body.workspaceId, user.id)) return json(res, 403, { error: 'Forbidden' }); + const task = { + id: id('tsk'), + workspaceId: body.workspaceId, + pageId: body.pageId || null, + title: body.title || 'Untitled task', + description: body.description || '', + assigneeUserId: body.assigneeUserId || null, + dueDate: body.dueDate || '', + priority: body.priority || 'medium', + status: body.status || 'todo', + recurring: body.recurring || '', + reminderAt: body.reminderAt || '', + dependencies: Array.isArray(body.dependencies) ? body.dependencies : [], + subItems: Array.isArray(body.subItems) ? body.subItems : [], + milestone: Boolean(body.milestone), + progress: Number(body.progress || 0), + linkedPageId: body.linkedPageId || body.pageId || null, + createdAt: now(), + updatedAt: now(), + }; + data.tasks.push(task); + if (task.assigneeUserId) queueNotification(data, { userId: task.assigneeUserId, type: 'assignment', title: `Assigned: ${task.title}`, body: task.description, category: 'task', batchKey: `assignment:${task.assigneeUserId}` }); + saveDb(data); + emitEvent('task.created', { workspaceId: task.workspaceId, pageId: task.pageId, actorUserId: user.id }); + return json(res, 201, task); + } + + const taskMatch = pathname.match(/^\/api\/tasks\/([^/]+)$/); + if (taskMatch && req.method === 'PUT') { + if (!user) return json(res, 401, { error: 'Unauthorized' }); + const task = data.tasks.find((item) => item.id === taskMatch[1]); + if (!task || !canAccessWorkspace(data, task.workspaceId, user.id)) return json(res, 403, { error: 'Forbidden' }); + const body = await readBody(req); + Object.assign(task, { + title: body.title ?? task.title, + description: body.description ?? task.description, + assigneeUserId: body.assigneeUserId ?? task.assigneeUserId, + dueDate: body.dueDate ?? task.dueDate, + priority: body.priority ?? task.priority, + status: body.status ?? task.status, + recurring: body.recurring ?? task.recurring, + reminderAt: body.reminderAt ?? task.reminderAt, + dependencies: Array.isArray(body.dependencies) ? body.dependencies : task.dependencies, + subItems: Array.isArray(body.subItems) ? body.subItems : task.subItems, + milestone: body.milestone ?? task.milestone, + progress: body.progress ?? task.progress, + linkedPageId: body.linkedPageId ?? task.linkedPageId, + updatedAt: now(), + }); + saveDb(data); + emitEvent('task.updated', { workspaceId: task.workspaceId, pageId: task.pageId, actorUserId: user.id }); + return json(res, 200, task); + } + + if (pathname === '/api/tasks.ics' && req.method === 'GET') { + if (!user) return json(res, 401, { error: 'Unauthorized' }); + const url = new URL(req.url, `http://${req.headers.host}`); + const workspaceId = url.searchParams.get('workspaceId'); + const tasks = data.tasks.filter((task) => (!workspaceId || task.workspaceId === workspaceId) && canAccessWorkspace(data, task.workspaceId, user.id)); + const ics = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//NoteFlow//EN']; + for (const task of tasks.filter((item) => item.dueDate)) { + ics.push('BEGIN:VEVENT'); + ics.push(`UID:${task.id}@noteflow.local`); + ics.push(`DTSTAMP:${new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z')}`); + ics.push(`DTSTART;VALUE=DATE:${task.dueDate.replaceAll('-', '')}`); + ics.push(`SUMMARY:${task.title}`); + ics.push(`DESCRIPTION:${(task.description || '').replace(/\n/g, ' ')}`); + ics.push('END:VEVENT'); + } + ics.push('END:VCALENDAR'); + return text(res, 200, ics.join('\r\n'), 'text/calendar; charset=utf-8'); + } + + if (pathname === '/api/files' && req.method === 'POST') { + if (!user) return json(res, 401, { error: 'Unauthorized' }); + const body = await readBody(req); + if (!canAccessWorkspace(data, body.workspaceId, user.id)) return json(res, 403, { error: 'Forbidden' }); + const workspace = getWorkspace(data, body.workspaceId); + const buffer = Buffer.from(String(body.data || ''), 'base64'); + const usedBytes = workspaceUsageBytes(data, body.workspaceId); + if (usedBytes + buffer.length > (workspace.settings.storageQuotaMb || 100) * 1024 * 1024) return json(res, 400, { error: 'Storage quota exceeded' }); + const file = { + id: id('fil'), + workspaceId: body.workspaceId, + pageId: body.pageId || null, + name: body.name || 'upload.bin', + type: body.type || 'application/octet-stream', + size: buffer.length, + storagePath: '', + preview: buffer.toString('utf8', 0, Math.min(buffer.length, 120)), + versions: [], + createdAt: now(), + updatedAt: now(), + uploadedBy: user.id, + }; + const ext = path.extname(file.name) || '.bin'; + const filePath = path.join(UPLOAD_DIR, `${file.id}${ext}`); + fs.writeFileSync(filePath, buffer); + file.storagePath = filePath; + data.files.push(file); + saveDb(data); + emitEvent('file.uploaded', { workspaceId: file.workspaceId, pageId: file.pageId, actorUserId: user.id }); + return json(res, 201, { ...file, signedUrl: `/files/${file.id}?token=${signedFileToken(file.id)}` }); + } + + const replaceFileMatch = pathname.match(/^\/api\/files\/([^/]+)\/replace$/); + if (replaceFileMatch && req.method === 'POST') { + if (!user) return json(res, 401, { error: 'Unauthorized' }); + const file = data.files.find((item) => item.id === replaceFileMatch[1]); + if (!file || !canAccessWorkspace(data, file.workspaceId, user.id)) return json(res, 403, { error: 'Forbidden' }); + const body = await readBody(req); + const buffer = Buffer.from(String(body.data || ''), 'base64'); + file.versions.unshift({ replacedAt: now(), size: file.size, preview: file.preview }); + file.versions = file.versions.slice(0, 10); + file.size = buffer.length; + file.preview = buffer.toString('utf8', 0, Math.min(buffer.length, 120)); + file.updatedAt = now(); + fs.writeFileSync(file.storagePath, buffer); + saveDb(data); + emitEvent('file.replaced', { workspaceId: file.workspaceId, pageId: file.pageId, actorUserId: user.id }); + return json(res, 200, { ...file, signedUrl: `/files/${file.id}?token=${signedFileToken(file.id)}` }); + } + + const signedFileMatch = pathname.match(/^\/api\/files\/([^/]+)\/signed$/); + if (signedFileMatch && req.method === 'GET') { + if (!user) return json(res, 401, { error: 'Unauthorized' }); + const file = data.files.find((item) => item.id === signedFileMatch[1]); + if (!file || !canAccessWorkspace(data, file.workspaceId, user.id)) return json(res, 403, { error: 'Forbidden' }); + return json(res, 200, { url: `/files/${file.id}?token=${signedFileToken(file.id)}` }); + } + + if (pathname === '/api/import' && req.method === 'POST') { + if (!user) return json(res, 401, { error: 'Unauthorized' }); + const body = await readBody(req); + if (!canAccessWorkspace(data, body.workspaceId, user.id)) return json(res, 403, { error: 'Forbidden' }); + if (body.format === 'csv') { + const tasks = csvToTasks(body.content, body.workspaceId, body.parentId || null); + data.tasks.push(...tasks); + saveDb(data); + return json(res, 201, { imported: tasks.length, target: 'tasks' }); + } + const blocks = body.format === 'html' ? htmlToBlocks(body.content) : markdownToBlocks(body.content); + const page = { + id: id('pg'), + workspaceId: body.workspaceId, + parentId: body.parentId || null, + title: body.title || body.fileName || `Imported ${body.format.toUpperCase()}`, + icon: '📥', + cover: '', + slug: `import-${Math.random().toString(36).slice(2, 7)}`, + customUrl: '', + kind: 'page', + locked: false, + fullWidth: false, + smallText: false, + verified: false, + deletedAt: null, + favoriteBy: [], + recentBy: [], + published: false, + seo: { title: body.title || 'Imported page', description: '' }, + share: { enabled: false, token: id('shr'), expiresAt: '', allowedDomain: '' }, + permissions: [], + blocks, + history: [], + commentsEnabled: true, + commentsSummary: { total: 0, unresolved: 0 }, + templateId: null, + createdAt: now(), + updatedAt: now(), + createdBy: user.id, + }; + data.pages.push(page); + saveDb(data); + return json(res, 201, { imported: 1, target: 'page', page }); + } + + const exportPageMatch = pathname.match(/^\/api\/export\/page\/([^/]+)$/); + if (exportPageMatch && req.method === 'GET') { + if (!user) return json(res, 401, { error: 'Unauthorized' }); + const url = new URL(req.url, `http://${req.headers.host}`); + const format = url.searchParams.get('format') || 'markdown'; + const page = data.pages.find((item) => item.id === exportPageMatch[1]); + if (!page || !canAccessPage(data, page, user.id, 'view')) return json(res, 403, { error: 'Forbidden' }); + if (format === 'markdown') return text(res, 200, blocksToMarkdown(page), 'text/markdown; charset=utf-8'); + if (format === 'html') return text(res, 200, blocksToHtml(page), 'text/html; charset=utf-8'); + if (format === 'json') return json(res, 200, page); + if (format === 'pdf') { + const pdf = buildSimplePdf(`${page.title}\n\n${blocksToMarkdown(page)}`); + res.writeHead(200, { 'Content-Type': 'application/pdf', 'Content-Length': pdf.length }); + return res.end(pdf); + } + if (format === 'csv') { + const tasks = data.tasks.filter((task) => task.pageId === page.id || task.linkedPageId === page.id); + return text(res, 200, tasksToCsv(tasks), 'text/csv; charset=utf-8'); + } + return json(res, 400, { error: 'Unsupported export format' }); + } + + const exportWorkspaceMatch = pathname.match(/^\/api\/export\/workspace\/([^/]+)$/); + if (exportWorkspaceMatch && req.method === 'GET') { + if (!user) return json(res, 401, { error: 'Unauthorized' }); + if (!canAccessWorkspace(data, exportWorkspaceMatch[1], user.id)) return json(res, 403, { error: 'Forbidden' }); + const payload = { + workspace: getWorkspace(data, exportWorkspaceMatch[1]), + pages: data.pages.filter((page) => page.workspaceId === exportWorkspaceMatch[1]), + databases: data.databases.filter((database) => database.workspaceId === exportWorkspaceMatch[1]), + databaseRows: data.databaseRows.filter((row) => row.workspaceId === exportWorkspaceMatch[1]), + tasks: data.tasks.filter((task) => task.workspaceId === exportWorkspaceMatch[1]), + files: data.files.filter((file) => file.workspaceId === exportWorkspaceMatch[1]), + comments: data.comments.filter((comment) => data.pages.find((page) => page.id === comment.pageId)?.workspaceId === exportWorkspaceMatch[1]), + }; + return json(res, 200, payload); + } + + const exportDatabaseMatch = pathname.match(/^\/api\/export\/database\/([^/]+)$/); + if (exportDatabaseMatch && req.method === 'GET') { + if (!user) return json(res, 401, { error: 'Unauthorized' }); + const database = getDatabase(data, exportDatabaseMatch[1]); + if (!database || !canAccessDatabase(data, database, user.id, 'view')) return json(res, 403, { error: 'Forbidden' }); + const url = new URL(req.url, `http://${req.headers.host}`); + const format = url.searchParams.get('format') || 'json'; + const rows = data.databaseRows.filter((row) => row.databaseId === database.id); + if (format === 'json') return json(res, 200, { database, rows }); + if (format === 'csv') return text(res, 200, databaseToCsv(database, rows), 'text/csv; charset=utf-8'); + if (format === 'markdown') return text(res, 200, databaseToMarkdown(database, rows), 'text/markdown; charset=utf-8'); + return json(res, 400, { error: 'Unsupported export format' }); + } + + if (pathname === '/api/events' && req.method === 'GET') { + if (!user) return json(res, 401, { error: 'Unauthorized' }); + const url = new URL(req.url, `http://${req.headers.host}`); + const workspaceId = url.searchParams.get('workspaceId') || ''; + const pageId = url.searchParams.get('pageId') || ''; + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + }); + const clientId = id('sse'); + SSE_CLIENTS.set(clientId, { res, userId: user.id, workspaceId, pageId }); + res.write(`event: hello\ndata: ${JSON.stringify({ userId: user.id, workspaceId, pageId, now: now() })}\n\n`); + req.on('close', () => { + SSE_CLIENTS.delete(clientId); + }); + return; + } + + if (pathname === '/api/presence' && req.method === 'POST') { + if (!user) return json(res, 401, { error: 'Unauthorized' }); + const body = await readBody(req); + const pageId = body.pageId; + const page = data.pages.find((item) => item.id === pageId); + if (!page || !canAccessPage(data, page, user.id, 'view')) return json(res, 403, { error: 'Forbidden' }); + const pagePresence = PRESENCE.get(pageId) || new Map(); + pagePresence.set(user.id, { + userId: user.id, + name: user.name, + avatarColor: user.avatarColor, + cursor: body.cursor || { x: 0, y: 0 }, + lastSeenAt: now(), + }); + PRESENCE.set(pageId, pagePresence); + const snapshot = currentPresence(pageId); + emitEvent('presence.updated', { workspaceId: page.workspaceId, pageId, presence: snapshot }); + return json(res, 200, snapshot); + } + + const notificationMatch = pathname.match(/^\/api\/notifications\/([^/]+)\/read$/); + if (notificationMatch && req.method === 'POST') { + if (!user) return json(res, 401, { error: 'Unauthorized' }); + const notification = data.notifications.find((item) => item.id === notificationMatch[1] && item.userId === user.id); + if (!notification) return json(res, 404, { error: 'Notification not found' }); + notification.read = true; + saveDb(data); + return json(res, 200, notification); + } + + if (pathname === '/api/preferences/notifications' && req.method === 'PUT') { + if (!user) return json(res, 401, { error: 'Unauthorized' }); + const body = await readBody(req); + user.notificationPreferences = { ...user.notificationPreferences, ...body }; + saveDb(data); + return json(res, 200, user.notificationPreferences); + } + + return json(res, 404, { error: 'Not found' }); +} + +function generateTotpCode(secret) { + const step = Math.floor(Date.now() / 30000); + const hmac = crypto.createHmac('sha1', secret).update(String(step)).digest('hex'); + return hmac.slice(-6).replace(/[^0-9]/g, '0').padEnd(6, '0').slice(0, 6); +} + +function verifyTotpCode(secret, code) { + if (!secret || !code) return false; + const normalize = String(code).trim(); + const current = generateTotpCode(secret); + const previous = (() => { + const oldNow = Date.now; + Date.now = () => oldNow() - 30000; + const value = generateTotpCode(secret); + Date.now = oldNow; + return value; + })(); + return normalize === current || normalize === previous; +} + +function serveStatic(res, filePath) { + if (!fs.existsSync(filePath)) return false; + const ext = path.extname(filePath).toLowerCase(); + const type = { + '.html': 'text/html; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.js': 'application/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.svg': 'image/svg+xml', + '.txt': 'text/plain; charset=utf-8', + }[ext] || 'application/octet-stream'; + const buffer = fs.readFileSync(filePath); + res.writeHead(200, { 'Content-Type': type, 'Cache-Control': 'no-store' }); + res.end(buffer); + return true; +} + +const server = http.createServer(async (req, res) => { + try { + const data = loadDb(); + const sessionInfo = getSession(req, data); + const url = new URL(req.url, `http://${req.headers.host}`); + const pathname = decodeURIComponent(url.pathname); + + if (pathname.startsWith('/api/')) return await handleApi(req, res, pathname, data, sessionInfo); + + const fileDownloadMatch = pathname.match(/^\/files\/([^/]+)$/); + if (fileDownloadMatch) { + const file = data.files.find((item) => item.id === fileDownloadMatch[1]); + if (!file) return json(res, 404, { error: 'File not found' }); + const token = url.searchParams.get('token'); + if (token !== signedFileToken(file.id)) return json(res, 403, { error: 'Invalid signed URL' }); + res.writeHead(200, { + 'Content-Type': file.type, + 'Content-Disposition': `inline; filename="${file.name.replaceAll('"', '')}"`, + 'Cache-Control': 'public, max-age=3600, immutable', + }); + return fs.createReadStream(file.storagePath).pipe(res); + } + + const publicPageMatch = pathname.match(/^\/(?:pub|p)\/([^/]+)$/); + if (publicPageMatch) { + const slug = publicPageMatch[1]; + const page = data.pages.find((item) => item.slug === slug || item.customUrl === `/${slug}`); + if (!page || !page.published) return html(res, 404, '

Published page not found

'); + if (page.share.expiresAt && page.share.expiresAt < now()) return html(res, 403, '

Share link expired

'); + if (page.share.allowedDomain) { + const viewer = sessionInfo?.user; + if (!viewer || !viewer.email.endsWith(`@${page.share.allowedDomain}`)) return html(res, 403, `

Access restricted to ${escapeHtml(page.share.allowedDomain)}

`); + } + return html(res, 200, renderPublicPage(page, data)); + } + + if (pathname === '/' || pathname === '/index.html') { + return serveStatic(res, path.join(PUBLIC_DIR, 'index.html')); + } + + if (serveStatic(res, path.join(PUBLIC_DIR, pathname))) return; + return redirect(res, '/'); + } catch (error) { + console.error(error); + return json(res, 500, { error: error.message, stack: error.stack }); + } +}); + +if (process.argv[1] && path.resolve(process.argv[1]) === __filename) { + server.listen(PORT, () => { + console.log(`NoteFlow running at http://localhost:${PORT}`); + }); +} + +export { + defaultData, + markdownToBlocks, + htmlToBlocks, + csvToTasks, + blocksToMarkdown, + buildSimplePdf, + search, + normalizeBlocks, + server, +}; diff --git a/deliverable/noteflow/tasks/changedoc.md b/deliverable/noteflow/tasks/changedoc.md new file mode 100644 index 000000000..69a3fb0d1 --- /dev/null +++ b/deliverable/noteflow/tasks/changedoc.md @@ -0,0 +1,51 @@ +# Change Document + +**Sources reviewed:** [agent1.2] + +## Summary +Delivered the final NoteFlow app as a zero-dependency full-stack Node.js workspace platform with a browser SPA, seeded demo data, tests, and an enterprise collaboration/admin layer. The final delivery keeps the broad Notion-style surface from the prior base and includes workspace/page management, a block editor, databases, tasks, search, files, notifications, authentication, import/export, realtime presence, and admin/governance workflows such as SSO/SAML demo auth, SCIM provisioning, invitation acceptance, audit activity, 2FA setup, and page/database permission management. + +## Decisions + +### DEC-001: Keep the zero-dependency full-stack Node delivery from agent1.1/agent1.2 +**Origin:** agent1.2 (kept) +**Choice:** Retain the single-process Node server plus static SPA architecture. +**Why:** The task required a very broad product surface in one deliverable. This architecture keeps startup friction low, ships cleanly with built-in Node APIs only, and lets the app cover pages, databases, tasks, search, files, auth, and collaboration in one runnable project. +**Alternatives considered:** +- Replatform to a heavier framework: rejected because it would reduce shipped scope and increase setup complexity for this delivery. +**Implementation:** +- `server.mjs` → HTTP server, routing, persistence, SSE presence/events, auth/session handling, import/export helpers, search, file APIs, workspace/page/database/task endpoints +- `public/index.html` → SPA shell +- `public/styles.css` → application layout and feature styling +- `public/app.js` → client-side state management, page editor UI, databases/tasks/search/files/notifications/admin experiences +- `package.json` → runnable start/dev/test scripts + +### DEC-002: Preserve first-class databases and extend them with permission management rather than replacing the model +**Origin:** agent1.2 (kept) → [SELF] (modified) +**Choice:** Keep databases/rows/views as a core primitive and extend them with admin-managed access grants. +**Why:** Notion-style collaboration depends on structured databases being a first-class workspace object, not an afterthought. Extending the existing database model with governance controls closes a major gap between a document app and a real team workspace platform. +**Alternatives considered:** +- Leave databases unchanged: rejected because it would leave collaboration and governance incomplete for team use. +**Implementation:** +- `server.mjs` → database CRUD routes, row CRUD routes, workspace settings, permission-bearing database records returned through `/api/bootstrap` +- `public/app.js` → database rendering, row editing, view switching, and `renderAdminPanel()` flows for database permission grants +- `tests/noteflow.test.mjs` → database creation, row update, search, export, and enterprise coverage regression tests + +### DEC-003: Add an enterprise admin/security slice as the highest-leverage missing product area +**Origin:** [SELF] — NEW +**Choice:** Introduce workspace settings, audit feed, invitation lifecycle visibility, SSO/SAML demo login, SCIM provisioning, device/session metadata, 2FA activation, and explicit page/database permission grants. +**Why:** The broad collaboration surface was already strong, but the largest remaining mismatch with the original brief was enterprise governance and identity administration. Making these flows visible and usable in both API and UI materially improves fidelity to the requested NoteFlow platform. +**Alternatives considered:** +- Keep enterprise behaviors implicit or backend-only: rejected because the brief asked for production-style collaboration, identity, and permissions features that should be operable from the product. +**Implementation:** +- `server.mjs` → `ensureAuthMethod()`, `upsertWorkspaceMember()`, `acceptPendingInvitations()`, `/api/auth/sso`, `/api/workspaces/:id/settings`, `/api/workspaces/:id/invitations`, `/api/workspaces/:id/activity`, `/api/workspaces/:id/scim/users`, session/device metadata, audit logging, and sharing control enforcement +- `public/app.js` → `renderAdminPanel()`, workspace settings form, SSO entry flow, SCIM provisioning form, invitation tracking, page/database permission forms, activity feed rendering, and `setupTwoFactor()` +- `README.md` → shipped feature summary including enterprise admin/security capabilities +- `tests/noteflow.test.mjs` → regression coverage for workspace settings, SSO invite acceptance, audit logging, and SCIM membership sync + +## Deliberation Trail + +### [SELF] (synthesized from agent1.2) +- DEC-001: Kept the existing zero-dependency Node architecture because it remained the fastest way to preserve breadth and ship a fully runnable product. +- DEC-002: Kept first-class databases from agent1.2 and extended them so governance applies to databases as well as pages. +- DEC-003: NEW — added the enterprise collaboration/admin slice because it was the clearest remaining gap against the original NoteFlow brief. diff --git a/deliverable/noteflow/tests/noteflow.test.mjs b/deliverable/noteflow/tests/noteflow.test.mjs new file mode 100644 index 000000000..c33e689b0 --- /dev/null +++ b/deliverable/noteflow/tests/noteflow.test.mjs @@ -0,0 +1,428 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { setTimeout as delay } from 'node:timers/promises'; + +import { + blocksToMarkdown, + buildSimplePdf, + csvToTasks, + defaultData, + htmlToBlocks, + markdownToBlocks, + search, + server, +} from '../server.mjs'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const DATA_FILE = path.join(ROOT, 'data', 'noteflow-db.json'); +const UPLOAD_DIR = path.join(ROOT, 'uploads'); +const PORT = 3107; +const BASE = `http://127.0.0.1:${PORT}`; + +function cleanupRuntimeFiles() { + if (fs.existsSync(DATA_FILE)) fs.rmSync(DATA_FILE, { force: true }); + if (fs.existsSync(UPLOAD_DIR)) { + for (const file of fs.readdirSync(UPLOAD_DIR)) fs.rmSync(path.join(UPLOAD_DIR, file), { force: true }); + } +} + +async function waitForServer() { + for (let i = 0; i < 50; i += 1) { + try { + const response = await fetch(`${BASE}/api/bootstrap`); + if (response.ok) return; + } catch {} + await delay(100); + } + throw new Error('Server did not start in time'); +} + +test('content helpers parse and export blocks', () => { + const blocks = markdownToBlocks('# Title\n\n- [ ] Todo\n> Quote'); + assert.equal(blocks[0].type, 'heading1'); + assert.equal(blocks[1].type, 'divider'); + assert.equal(blocks[2].type, 'todo'); + const htmlBlocks = htmlToBlocks('

Hello

Body

'); + assert.equal(htmlBlocks[0].type, 'paragraph'); + const markdown = blocksToMarkdown({ blocks: [{ type: 'heading1', text: 'Hello' }, { type: 'todo', text: 'Ship', checked: true }] }); + assert.match(markdown, /# Hello/); + assert.match(markdown, /- \[x\] Ship/); + const pdf = buildSimplePdf('Hello PDF'); + assert.equal(pdf.subarray(0, 8).toString('utf8'), '%PDF-1.4'); + const tasks = csvToTasks('title,status,priority,dueDate\nLaunch,todo,high,2026-05-05', 'ws_1', 'pg_1'); + assert.equal(tasks[0].title, 'Launch'); +}); + +test('search ranks accessible page results', () => { + const data = defaultData(); + const user = { id: 'usr_test', email: 'a@example.com' }; + data.users.push({ ...user, name: 'A', passwordHash: 'x', notificationPreferences: {}, identity: {}, twoFactorEnabled: false }); + data.workspaces.push({ id: 'ws_test', name: 'Team', kind: 'team', ownerId: user.id, members: [{ userId: user.id, role: 'owner' }], settings: {} }); + data.pages.push({ + id: 'pg_test', workspaceId: 'ws_test', parentId: null, title: 'Launch Plan', icon: '🚀', cover: '', slug: 'launch-plan', customUrl: '', kind: 'page', locked: false, fullWidth: false, smallText: false, verified: false, deletedAt: null, + favoriteBy: [], recentBy: [], published: false, seo: { title: 'Launch Plan', description: '' }, share: { enabled: false, token: 'shr', expiresAt: '', allowedDomain: '' }, permissions: [], + blocks: [{ id: 'blk_1', type: 'paragraph', text: 'Prepare launch checklist' }], history: [], commentsEnabled: true, commentsSummary: { total: 0, unresolved: 0 }, templateId: null, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), createdBy: user.id, + }); + const results = search(data, user.id, { q: 'launch' }); + assert.equal(results[0].title, 'Launch Plan'); +}); + +test('HTTP flows cover register, page creation, comments, tasks, files, search, publish, and export', async (t) => { + cleanupRuntimeFiles(); + await new Promise((resolve) => server.listen(PORT, '127.0.0.1', resolve)); + t.after(() => server.close()); + await waitForServer(); + + let cookie = ''; + const request = async (pathname, options = {}) => { + const response = await fetch(`${BASE}${pathname}`, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...(cookie ? { Cookie: cookie } : {}), + ...(options.headers || {}), + }, + }); + const setCookie = response.headers.get('set-cookie'); + if (setCookie) cookie = setCookie.split(';')[0]; + return response; + }; + + const publicBootstrap = await request('/api/bootstrap'); + assert.equal(publicBootstrap.status, 200); + const publicData = await publicBootstrap.json(); + assert.ok(publicData.pages.length >= 1); + + const register = await request('/api/auth/register', { + method: 'POST', + body: JSON.stringify({ name: 'Test User', email: 'test@example.com', password: 'secret123' }), + }); + assert.equal(register.status, 201); + + const authedBootstrap = await request('/api/bootstrap'); + const authedData = await authedBootstrap.json(); + assert.equal(authedData.user.email, 'test@example.com'); + const ownedWorkspace = authedData.workspaces.find((workspace) => workspace.ownerId === authedData.user.id && workspace.kind === 'personal'); + assert.ok(ownedWorkspace); + + const createPage = await request('/api/pages', { + method: 'POST', + body: JSON.stringify({ workspaceId: ownedWorkspace.id, title: 'Launch Notes' }), + }); + const createPageBody = await createPage.text(); + assert.equal(createPage.status, 201, createPageBody); + const page = JSON.parse(createPageBody); + + const updatePage = await request(`/api/pages/${page.id}`, { + method: 'PUT', + body: JSON.stringify({ + title: 'Launch Notes', + blocks: [ + { id: 'blk_sync_a', type: 'synced', text: 'Shared content', syncKey: 'shared-1' }, + { id: 'blk_2', type: 'heading1', text: 'Launch heading' }, + { id: 'blk_3', type: 'paragraph', text: 'Prepare launch checklist and publish docs.' }, + ], + }), + }); + assert.equal(updatePage.status, 200); + + const comment = await request(`/api/pages/${page.id}/comments`, { + method: 'POST', + body: JSON.stringify({ text: 'Looks good for launch.' }), + }); + assert.equal(comment.status, 201); + + const task = await request('/api/tasks', { + method: 'POST', + body: JSON.stringify({ workspaceId: ownedWorkspace.id, pageId: page.id, title: 'Ship launch', description: 'Coordinate milestone', priority: 'high', status: 'todo' }), + }); + assert.equal(task.status, 201); + + const upload = await request('/api/files', { + method: 'POST', + body: JSON.stringify({ workspaceId: ownedWorkspace.id, pageId: page.id, name: 'launch.txt', type: 'text/plain', data: Buffer.from('launch asset').toString('base64') }), + }); + assert.equal(upload.status, 201); + + const searchResponse = await request(`/api/search?q=launch&workspaceId=${ownedWorkspace.id}`); + const searchResults = await searchResponse.json(); + assert.ok(searchResults.some((result) => result.title.includes('Launch Notes'))); + + const publish = await request(`/api/pages/${page.id}/publish`, { + method: 'POST', + body: JSON.stringify({ published: true, slug: 'launch-notes-public' }), + }); + assert.equal(publish.status, 200); + + const pdf = await request(`/api/export/page/${page.id}?format=pdf`); + assert.equal(pdf.status, 200); + assert.equal(pdf.headers.get('content-type'), 'application/pdf'); + + const publicPage = await fetch(`${BASE}/p/launch-notes-public`); + const publicHtml = await publicPage.text(); + assert.equal(publicPage.status, 200); + assert.match(publicHtml, /Launch Notes/); +}); + +test('HTTP flows cover databases, rows, search, and export', async (t) => { + cleanupRuntimeFiles(); + await new Promise((resolve) => server.listen(PORT, '127.0.0.1', resolve)); + t.after(() => server.close()); + await waitForServer(); + + let cookie = ''; + const request = async (pathname, options = {}) => { + const response = await fetch(`${BASE}${pathname}`, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...(cookie ? { Cookie: cookie } : {}), + ...(options.headers || {}), + }, + }); + const setCookie = response.headers.get('set-cookie'); + if (setCookie) cookie = setCookie.split(';')[0]; + return response; + }; + + const register = await request('/api/auth/register', { + method: 'POST', + body: JSON.stringify({ name: 'DB User', email: 'db@example.com', password: 'secret123' }), + }); + assert.equal(register.status, 201); + + const bootstrap = await request('/api/bootstrap'); + const data = await bootstrap.json(); + const ownedWorkspace = data.workspaces.find((workspace) => workspace.ownerId === data.user.id && workspace.kind === 'personal'); + assert.ok(ownedWorkspace); + + const createDatabase = await request('/api/databases', { + method: 'POST', + body: JSON.stringify({ + workspaceId: ownedWorkspace.id, + title: 'Product Roadmap', + description: 'Track initiatives', + icon: '🗺️', + fields: [ + { id: 'fld_name', name: 'Name', type: 'title' }, + { id: 'fld_status', name: 'Status', type: 'status', options: ['Backlog', 'In Progress', 'Done'] }, + { id: 'fld_owner', name: 'Owner', type: 'person' }, + { id: 'fld_due', name: 'Due', type: 'date' }, + ], + views: [ + { id: 'view_table', name: 'Table', type: 'table' }, + { id: 'view_board', name: 'Board', type: 'board', groupBy: 'fld_status' }, + ], + }), + }); + const createDatabaseBody = await createDatabase.text(); + assert.equal(createDatabase.status, 201, createDatabaseBody); + const database = JSON.parse(createDatabaseBody); + assert.equal(database.title, 'Product Roadmap'); + + const createRow = await request(`/api/databases/${database.id}/rows`, { + method: 'POST', + body: JSON.stringify({ + values: { + fld_name: 'Launch v1', + fld_status: 'In Progress', + fld_owner: data.user.id, + fld_due: '2026-05-10', + }, + pageId: null, + }), + }); + const createRowBody = await createRow.text(); + assert.equal(createRow.status, 201, createRowBody); + const row = JSON.parse(createRowBody); + assert.equal(row.values.fld_name, 'Launch v1'); + + const updateRow = await request(`/api/databases/${database.id}/rows/${row.id}`, { + method: 'PUT', + body: JSON.stringify({ + values: { + fld_name: 'Launch v1', + fld_status: 'Done', + fld_owner: data.user.id, + fld_due: '2026-05-12', + }, + verified: true, + }), + }); + assert.equal(updateRow.status, 200); + + const databaseSearch = await request(`/api/search?q=launch&type=database_row&workspaceId=${ownedWorkspace.id}`); + const results = await databaseSearch.json(); + assert.ok(results.some((result) => result.title.includes('Launch v1'))); + + const exportCsv = await request(`/api/export/database/${database.id}?format=csv`); + assert.equal(exportCsv.status, 200); + const csv = await exportCsv.text(); + assert.match(csv, /Launch v1/); + assert.match(csv, /Done/); + + const bootstrapAfter = await request('/api/bootstrap'); + const afterData = await bootstrapAfter.json(); + assert.ok(afterData.databases.some((entry) => entry.id === database.id)); + assert.ok(afterData.databaseRows.some((entry) => entry.id === row.id)); +}); + + +test('enterprise collaboration flows cover workspace settings, invite acceptance via SSO, and audit activity', async (t) => { + cleanupRuntimeFiles(); + await new Promise((resolve) => server.listen(PORT, '127.0.0.1', resolve)); + t.after(() => server.close()); + await waitForServer(); + + const makeClient = () => { + let cookie = ''; + return async (pathname, options = {}) => { + const response = await fetch(`${BASE}${pathname}`, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...(cookie ? { Cookie: cookie } : {}), + ...(options.headers || {}), + }, + }); + const setCookie = response.headers.get('set-cookie'); + if (setCookie) cookie = setCookie.split(';')[0]; + return response; + }; + }; + + const owner = makeClient(); + const teammate = makeClient(); + + const register = await owner('/api/auth/register', { + method: 'POST', + body: JSON.stringify({ name: 'Owner User', email: 'owner@example.com', password: 'secret123' }), + }); + assert.equal(register.status, 201); + + const bootstrap = await owner('/api/bootstrap'); + const data = await bootstrap.json(); + const teamWorkspace = data.workspaces.find((workspace) => workspace.ownerId === data.user.id && workspace.kind === 'team'); + assert.ok(teamWorkspace); + + const updateSettings = await owner(`/api/workspaces/${teamWorkspace.id}/settings`, { + method: 'PUT', + body: JSON.stringify({ + domainRestriction: 'example.com', + samlEnabled: true, + scimEnabled: true, + allowGuests: false, + publicSharing: false, + storageQuotaMb: 42, + }), + }); + assert.equal(updateSettings.status, 200); + const settingsBody = await updateSettings.json(); + assert.equal(settingsBody.settings.domainRestriction, 'example.com'); + assert.equal(settingsBody.settings.samlEnabled, true); + assert.equal(settingsBody.settings.scimEnabled, true); + assert.equal(settingsBody.settings.storageQuotaMb, 42); + + const invite = await owner(`/api/workspaces/${teamWorkspace.id}/invite`, { + method: 'POST', + body: JSON.stringify({ email: 'teammate@example.com', role: 'editor' }), + }); + assert.equal(invite.status, 200); + + const sso = await teammate('/api/auth/sso', { + method: 'POST', + body: JSON.stringify({ workspaceId: teamWorkspace.id, email: 'teammate@example.com', name: 'Teammate User', externalId: 'saml-ext-1' }), + }); + assert.equal(sso.status, 200); + const ssoBody = await sso.json(); + assert.equal(ssoBody.user.email, 'teammate@example.com'); + assert.equal(ssoBody.user.identity.samlExternalId, 'saml-ext-1'); + assert.ok(ssoBody.user.identity.authMethods.includes('saml')); + + const teammateBootstrap = await teammate('/api/bootstrap'); + const teammateData = await teammateBootstrap.json(); + assert.ok(teammateData.workspaces.some((workspace) => workspace.id === teamWorkspace.id)); + + const invitations = await owner(`/api/workspaces/${teamWorkspace.id}/invitations`); + assert.equal(invitations.status, 200); + const invitationList = await invitations.json(); + assert.ok(invitationList.some((entry) => entry.email === 'teammate@example.com' && entry.acceptedAt)); + + const activity = await owner(`/api/workspaces/${teamWorkspace.id}/activity`); + assert.equal(activity.status, 200); + const activityEntries = await activity.json(); + assert.ok(activityEntries.some((entry) => entry.kind === 'workspace.settings.updated')); + assert.ok(activityEntries.some((entry) => entry.kind === 'workspace.invited')); + assert.ok(activityEntries.some((entry) => entry.kind === 'workspace.sso-login')); +}); + +test('enterprise provisioning covers SCIM user creation and membership sync', async (t) => { + cleanupRuntimeFiles(); + await new Promise((resolve) => server.listen(PORT, '127.0.0.1', resolve)); + t.after(() => server.close()); + await waitForServer(); + + let cookie = ''; + const request = async (pathname, options = {}) => { + const response = await fetch(`${BASE}${pathname}`, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...(cookie ? { Cookie: cookie } : {}), + ...(options.headers || {}), + }, + }); + const setCookie = response.headers.get('set-cookie'); + if (setCookie) cookie = setCookie.split(';')[0]; + return response; + }; + + const register = await request('/api/auth/register', { + method: 'POST', + body: JSON.stringify({ name: 'SCIM Owner', email: 'scim-owner@example.com', password: 'secret123' }), + }); + assert.equal(register.status, 201); + + const bootstrap = await request('/api/bootstrap'); + const data = await bootstrap.json(); + const teamWorkspace = data.workspaces.find((workspace) => workspace.ownerId === data.user.id && workspace.kind === 'team'); + assert.ok(teamWorkspace); + + const updateSettings = await request(`/api/workspaces/${teamWorkspace.id}/settings`, { + method: 'PUT', + body: JSON.stringify({ scimEnabled: true, domainRestriction: 'example.com' }), + }); + assert.equal(updateSettings.status, 200); + + const provision = await request(`/api/workspaces/${teamWorkspace.id}/scim/users`, { + method: 'POST', + body: JSON.stringify({ + email: 'provisioned@example.com', + name: 'Provisioned Person', + externalId: 'scim-42', + role: 'viewer', + title: 'Analyst', + }), + }); + assert.equal(provision.status, 201); + const provisioned = await provision.json(); + assert.equal(provisioned.user.email, 'provisioned@example.com'); + assert.equal(provisioned.user.identity.scimExternalId, 'scim-42'); + + const invitations = await request(`/api/workspaces/${teamWorkspace.id}/invitations`); + assert.equal(invitations.status, 200); + + const activity = await request(`/api/workspaces/${teamWorkspace.id}/activity`); + assert.equal(activity.status, 200); + const activityEntries = await activity.json(); + assert.ok(activityEntries.some((entry) => entry.kind === 'workspace.scim-provisioned')); + + const refreshed = await request('/api/bootstrap'); + const refreshedData = await refreshed.json(); + const refreshedWorkspace = refreshedData.workspaces.find((workspace) => workspace.id === teamWorkspace.id); + assert.ok(refreshedWorkspace.members.some((member) => member.role === 'viewer')); + assert.ok(refreshedData.directory.some((entry) => entry.email === 'provisioned@example.com')); +}); diff --git a/deliverable/sorting-visualization.svg b/deliverable/sorting-visualization.svg new file mode 100644 index 000000000..e02ddd23f --- /dev/null +++ b/deliverable/sorting-visualization.svg @@ -0,0 +1,218 @@ + + Sorting Visualization: How Bubble Sort Works + An educational animated SVG that shows bubble sort comparing adjacent bars and swapping them until the list is sorted. + + + + + + + + + + + + How Bubble Sort Works + Compare neighboring values. If the left one is larger, swap them so bigger values "bubble" right. + + + + + + + + + Bars represent values in the list: [5, 1, 4, 2, 3] + value + + + + + + sorted side + + + + + + + + + + current comparison + + + + + + + + + 5 + + + + + + + + + + + 1 + + + + + + + + + + + 4 + + + + + + + + + + + 2 + + + + + + + + + + + 3 + + + + 0 + 1 + 2 + 3 + 4 + array index + + + Legend + + Unsorted value + + Values being compared + + Confirmed sorted position + + + Algorithm idea + repeat passes through the list + compare each adjacent pair + if left > right, swap them + largest unsorted item moves right + + + Why it teaches well + You can see each local decision, + then watch the whole list improve. + + + + + Start with [5, 1, 4, 2, 3]. Bubble sort begins on the left. + + + + Compare 5 and 1: 5 is larger, so swap them. + + + + Compare 5 and 4: swap again, pushing 5 farther right. + + + + Compare 5 and 2: swap again. + + + + Compare 5 and 3: swap. Now 5 reaches its final sorted spot. + + + + Next pass: 1 and 4 are already in order, so no swap is needed. + + + + Compare 4 and 2: 4 is larger, so swap them. + + + + Compare 4 and 3: swap. Now 4 is fixed too. + + + + Final check: the remaining values are already ordered. + + + + Sorted list: [1, 2, 3, 4, 5]. The animation then loops for review. + + + + diff --git a/deliverable/stayflow_agents/deliverable/README.md b/deliverable/stayflow_agents/deliverable/README.md new file mode 100644 index 000000000..535ab93b0 --- /dev/null +++ b/deliverable/stayflow_agents/deliverable/README.md @@ -0,0 +1,53 @@ +# StayFlow Agents Prototype + +A self-contained short-stay rental marketplace demo with: +- Guest, Guest Agent, Host, Host Agent, and Admin roles +- A visible six-stage agent loop: monitor → detect → propose → approve → act → confirm +- Full lifecycle support: inquiry → booking negotiation → check-in → stay → check-out → review +- Agent-to-agent negotiation threads visible in plain language +- Explicit one-tap approvals before any action executes +- Local persistence for returning guests, recurring hosts, listings, bookings, messages, and disputes + +## Run locally + +From the workspace root: + +```bash +python3 -m http.server 8000 --directory deliverable +``` + +Or, if you `cd deliverable` first: + +```bash +python3 -m http.server 8000 +``` + +Then open: + +- http://localhost:8000 + +## Verify + +From the workspace root: + +```bash +node --test tests/prototype.test.mjs tests/ui-smoke.test.mjs +``` + +## Demo path + +1. Approve the seeded guest proposals. +2. Approve the host counter-offers. +3. Approve the guest counter acceptances. +4. Advance to the check-in day and approve check-in. +5. Report an in-stay issue from the booking card. +6. Reject the host’s recovery proposal to trigger admin escalation. +7. Approve the admin mediation. +8. Advance to checkout and approve checkout + review. + +## Files + +- `index.html` — app shell +- `styles.css` — UI styling +- `app.mjs` — browser UI + persistence wiring +- `state-engine.mjs` — pure workflow engine shared by the UI and tests diff --git a/deliverable/stayflow_agents/deliverable/app.mjs b/deliverable/stayflow_agents/deliverable/app.mjs new file mode 100644 index 000000000..b7c7e1616 --- /dev/null +++ b/deliverable/stayflow_agents/deliverable/app.mjs @@ -0,0 +1,632 @@ +import { + addListing, + addTripRequest, + advanceDay, + approveProposal, + createInitialState, + getDayLabel, + getProposalQueue, + hydrateState, + rejectProposal, + reportStayIssue, + runAllAgentLoops, + searchListings, + serializeState, + summarizeBooking, +} from './state-engine.mjs'; + +const STORAGE_KEY = 'stayflow-rental-marketplace-v1'; +const app = document.querySelector('#app'); + +const uiState = { + autoMonitor: true, + searchCriteria: { city: '', maxNightlyRate: '', partySize: '' }, + toast: 'Prototype loaded. Every action still requires one-tap human approval.', + lastAutoScan: null, +}; + +function loadState() { + const saved = window.localStorage.getItem(STORAGE_KEY); + if (!saved) { + const fresh = createInitialState(); + runAllAgentLoops(fresh); + return fresh; + } + const restored = hydrateState(saved); + runAllAgentLoops(restored); + return restored; +} + +let state = loadState(); + +function saveState(message) { + window.localStorage.setItem(STORAGE_KEY, serializeState(state)); + if (message) uiState.toast = message; +} + +function resetState() { + state = createInitialState(); + runAllAgentLoops(state); + saveState('Demo reset to seeded state.'); + render(); +} + +function currency(value) { + return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(value || 0); +} + +function escapeHtml(value = '') { + return String(value) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +function commit(message, rerun = true) { + if (rerun) runAllAgentLoops(state); + saveState(message); + render(); +} + +function groupedQueues() { + return { + guest: getProposalQueue(state, 'guest'), + host: getProposalQueue(state, 'host'), + admin: getProposalQueue(state, 'admin'), + }; +} + +function stats() { + return { + pending: getProposalQueue(state).length, + activeBookings: state.bookings.filter((booking) => ['booked', 'checked_in', 'negotiating', 'inquiry', 'checked_out'].includes(booking.stage)).length, + disputes: state.bookings.filter((booking) => booking.dispute?.status === 'open').length, + listings: state.listings.filter((listing) => listing.active !== false).length, + }; +} + +function renderProposal(proposal) { + const accent = proposal.humanRole; + const trace = proposal.loopTrace || {}; + return ` +
+
+ ${escapeHtml(proposal.status)} + ${escapeHtml(proposal.agentName)} → ${escapeHtml(proposal.humanName)} +
+

${escapeHtml(proposal.title)}

+

${escapeHtml(proposal.description)}

+
+ Monitor: ${escapeHtml(trace.monitor || '—')} + Detect: ${escapeHtml(trace.detect || '—')} + Propose: ${escapeHtml(trace.propose || '—')} +
+
+ + +
+
+ `; +} + +function renderQueue(role, label, proposals) { + return ` +
+
+

${label}

+ ${proposals.length} pending +
+ ${proposals.length ? proposals.map(renderProposal).join('') : '
No pending approvals. The agent loop will surface the next action automatically.
'} +
+ `; +} + +function renderListing(listing) { + const host = state.hosts.find((candidate) => candidate.id === listing.hostId); + return ` +
+
+ ${listing.active ? 'active' : 'inactive'} + Host: ${escapeHtml(host?.name || 'Unknown')} + ${escapeHtml(listing.city)} +
+

${escapeHtml(listing.title)}

+
${currency(listing.nightlyRate)} / night
+
+ Cleaning ${currency(listing.cleaningFee)} + Sleeps ${listing.maxGuests} + Strategy: ${escapeHtml(listing.availabilityStrategy)} +
+

${escapeHtml(listing.features.join(' • '))}

+
+ + +
+
+ `; +} + +function renderTrip(trip) { + const booking = state.bookings.find((candidate) => candidate.tripRequestId === trip.id); + return ` +
+
+ ${escapeHtml(trip.status || 'planning')} + ${escapeHtml(trip.city)} +
+

${escapeHtml(trip.label)}

+

Day ${trip.startDay} → ${trip.endDay} · Budget ${currency(trip.budget)} · Wants ${escapeHtml(trip.mustHave.join(', '))}

+

Check-in preference: ${escapeHtml(trip.checkInPreference)} · ${escapeHtml(trip.specialRequest)}

+ ${booking ? `

Linked booking: ${escapeHtml(booking.id)}

` : ''} +
+ `; +} + +function renderIssuePanel(booking) { + if (booking.stage !== 'checked_in' || booking.issue) return ''; + return ` +
+
+ + + +
+
+ `; +} + +function renderBooking(booking) { + const item = summarizeBooking(state, booking); + const issueMarkup = booking.issue + ? `
Issue: ${escapeHtml(booking.issue.summary)} · Status ${escapeHtml(booking.issue.status)}${booking.issue.resolution ? ` · ${escapeHtml(booking.issue.resolution)}` : ''}
` + : ''; + const disputeMarkup = booking.dispute?.status === 'open' + ? `
Dispute open with admin mediation pending.
` + : ''; + const adjustmentMarkup = booking.financials.adjustments.length + ? `

Adjustments: ${booking.financials.adjustments.map((adj) => `${currency(adj.amount)} for ${escapeHtml(adj.reason)}`).join(' · ')}

` + : '

No refunds or credits yet.

'; + return ` +
+
+ ${escapeHtml(booking.stage)} + ${escapeHtml(item.listingTitle || booking.listingId)} + Guest: ${escapeHtml(item.guestName || booking.guestId)} + Host: ${escapeHtml(item.hostName || booking.hostId)} +
+

${escapeHtml(item.listingTitle || 'Booking')}

+

Day ${booking.startDay} (${getDayLabel(state, booking.startDay)}) → Day ${booking.endDay} (${getDayLabel(state, booking.endDay)}) · ${currency(booking.nightlyRate)} nightly · ${currency(booking.financials.payout || booking.total)} total

+

Requested check-in: ${escapeHtml(booking.requestedCheckIn || '—')} ${booking.confirmedCheckIn ? `· Confirmed: ${escapeHtml(booking.confirmedCheckIn)}` : ''}

+ ${issueMarkup} + ${disputeMarkup} + ${adjustmentMarkup} + ${renderIssuePanel(booking)} +
+ ${booking.thread.map((message) => ` +
+
${escapeHtml(message.from)} · day ${message.day}
+
${escapeHtml(message.text)}
+
+ `).join('')} +
+
+ `; +} + +function renderDispute(booking) { + return ` +
+
+ ${escapeHtml(booking.dispute.status)} + ${escapeHtml(state.listings.find((listing) => listing.id === booking.listingId)?.title || booking.listingId)} +
+

${escapeHtml(booking.issue?.summary || 'No issue summary')}

+

Guest and host agents could not resolve this case. Admin rules decide the next proposal.

+
+ `; +} + +function renderActivity(item) { + return ` +
+
Day ${item.day} · ${escapeHtml(item.type)}
+
${escapeHtml(item.message)}
+
+ `; +} + +function currentSearchResults() { + return searchListings(state, uiState.searchCriteria); +} + +function render() { + const queues = groupedQueues(); + const summary = stats(); + const searchResults = currentSearchResults(); + const openDisputes = state.bookings.filter((booking) => booking.dispute?.status === 'open'); + const bookings = [...state.bookings].sort((a, b) => a.startDay - b.startDay); + const lastScan = uiState.lastAutoScan ? new Date(uiState.lastAutoScan).toLocaleTimeString() : 'not yet'; + + app.innerHTML = ` +
+
+
+
+ Demo day ${state.currentDay} (${getDayLabel(state)}) + Persistent state stored in your browser + One tap only every action waits for explicit approval +
+

StayFlow Agents

+

+ A working short-stay marketplace prototype where the Guest Agent and Host Agent both monitor, + detect, propose, wait for explicit human approval, act immediately after approval, and confirm + the result. Agent-to-agent negotiation, disputes, pricing, turnovers, reviews, and repeat state + all live in one persistent demo. +

+
+ 1. Monitor + 2. Detect + 3. Propose + 4. Approve + 5. Act + 6. Confirm +
+
+
+
+
Pending approvals

${summary.pending}

+
Active bookings

${summary.activeBookings}

+
Open disputes

${summary.disputes}

+
Active listings

${summary.listings}

+
+
+

Live prototype status

+

${escapeHtml(uiState.toast || '')}

+

Auto-monitor: ${uiState.autoMonitor ? 'On' : 'Off'} · Last scan: ${escapeHtml(lastScan)}

+
+
+
+ +
+
+ + + +
+
+ + +
+
+ +
+ ${renderQueue('guest', 'Guest approvals', queues.guest)} + ${renderQueue('host', 'Host approvals', queues.host)} + ${renderQueue('admin', 'Admin approvals', queues.admin)} +
+ +
+
+
+

Marketplace search + guest trip planner

+ Returning guest state persists between visits. +
+
+
+ + + +
+
+
+ ${searchResults.map(renderListing).join('') || '
No listings match the current guest search.
'} +
+
+

Add a new guest trip request

+
+
+ + + + + + + + +
+ + +
+ +
+
+
+ ${state.guests[0].tripRequests.map(renderTrip).join('')} +
+
+ +
+
+

Host listing studio + dynamic pricing

+ Hosts can manage multiple properties at once. +
+
+
+ + + + + + +
+ + +
+ +
+
+
+ ${state.listings.map(renderListing).join('')} +
+
+ +
+
+

Bookings, negotiation threads, and stay monitoring

+ All guest ↔ host agent exchanges stay visible in plain language. +
+ ${bookings.length ? bookings.map(renderBooking).join('') : '
No bookings yet. Approve a guest proposal to start inquiry → booking → check-in → stay → checkout → review.
'} +
+ +
+
+

Admin rules, disputes, and audit trail

+ Admins step in only when agents cannot resolve the issue. +
+
+
+ + + +
+
+ +
+
+
+ ${openDisputes.length ? openDisputes.map(renderDispute).join('') : '
No active disputes. Reject a host recovery proposal during a stay to see the admin path.
'} +
+
+

Marketplace activity

+
+ ${state.activityLog.slice(0, 18).map(renderActivity).join('')} +
+
+
+ + +
+ `; +} + +function splitList(value) { + return value + .split(',') + .map((item) => item.trim()) + .filter(Boolean); +} + +function fastForwardLifecycle() { + const pendingGuest = getProposalQueue(state, 'guest').filter((proposal) => proposal.action.type === 'create_inquiry'); + pendingGuest.forEach((proposal) => approveProposal(state, proposal.id)); + runAllAgentLoops(state); + + getProposalQueue(state, 'host').forEach((proposal) => { + if (proposal.action.type === 'counter_inquiry') approveProposal(state, proposal.id); + }); + runAllAgentLoops(state); + + getProposalQueue(state, 'guest').forEach((proposal) => { + if (proposal.action.type === 'accept_counter') approveProposal(state, proposal.id); + }); + + if (state.bookings[0]) { + if (state.currentDay < state.bookings[0].startDay) { + advanceDay(state, state.bookings[0].startDay - state.currentDay); + } + runAllAgentLoops(state); + } + + commit('Fast-forwarded seeded trips to the first check-in window.'); +} + +app.addEventListener('click', (event) => { + const button = event.target.closest('button[data-action]'); + if (!button) return; + const { action, id } = button.dataset; + + if (action === 'approve-proposal') { + approveProposal(state, id); + commit('Proposal approved and executed.'); + return; + } + + if (action === 'reject-proposal') { + rejectProposal(state, id); + commit('Proposal rejected. The agent loop will respond with a revised next step.'); + return; + } + + if (action === 'run-loops') { + runAllAgentLoops(state); + commit('Ran monitor → detect → propose across guest, host, and admin agents.', false); + return; + } + + if (action === 'advance-day') { + advanceDay(state, 1); + commit(`Advanced to day ${state.currentDay}.`); + return; + } + + if (action === 'toggle-auto') { + uiState.autoMonitor = !uiState.autoMonitor; + render(); + return; + } + + if (action === 'reset-state') { + resetState(); + return; + } + + if (action === 'seed-full-lifecycle') { + fastForwardLifecycle(); + return; + } + + if (action === 'toggle-listing') { + const listing = state.listings.find((candidate) => candidate.id === id); + listing.active = !listing.active; + commit(`${listing.title} is now ${listing.active ? 'active' : 'inactive'}.`, false); + return; + } + + if (action === 'cycle-strategy') { + const listing = state.listings.find((candidate) => candidate.id === id); + const modes = ['normal', 'underbooked', 'premium']; + const index = modes.indexOf(listing.availabilityStrategy); + listing.availabilityStrategy = modes[(index + 1) % modes.length]; + commit(`${listing.title} availability strategy is now ${listing.availabilityStrategy}.`, true); + } +}); + +app.addEventListener('submit', (event) => { + event.preventDefault(); + const form = event.target; + + if (form.id === 'trip-form') { + const data = new FormData(form); + addTripRequest(state, { + guestId: state.guests[0].id, + label: data.get('label'), + city: data.get('city'), + startDay: data.get('startDay'), + endDay: data.get('endDay'), + budget: data.get('budget'), + partySize: data.get('partySize'), + vibe: data.get('vibe'), + mustHave: splitList(data.get('mustHave') || ''), + checkInPreference: data.get('checkInPreference'), + specialRequest: data.get('specialRequest'), + }); + form.reset(); + commit('Added a new trip request and re-ran the guest agent for matching.', true); + return; + } + + if (form.id === 'listing-form') { + const data = new FormData(form); + addListing(state, { + hostId: data.get('hostId'), + title: data.get('title'), + city: data.get('city'), + nightlyRate: data.get('nightlyRate'), + cleaningFee: data.get('cleaningFee'), + maxGuests: data.get('maxGuests'), + tags: splitList(data.get('tags') || ''), + features: splitList(data.get('features') || ''), + }); + form.reset(); + commit('Listing created and host portfolio updated.', true); + return; + } + + if (form.id === 'rules-form') { + const data = new FormData(form); + state.rules.disputeCreditCap = Number(data.get('disputeCreditCap')); + commit('Admin rule updated for future dispute proposals.', false); + return; + } + + if (form.dataset.action === 'report-issue') { + const data = new FormData(form); + reportStayIssue(state, { + bookingId: form.dataset.bookingId, + summary: data.get('summary'), + severity: data.get('severity'), + }); + commit('Reported an in-stay issue and queued the host response.', true); + } +}); + +app.addEventListener('input', (event) => { + const form = event.target.closest('#search-form'); + if (!form) return; + const data = new FormData(form); + uiState.searchCriteria = { + city: data.get('city') || '', + maxNightlyRate: data.get('maxNightlyRate') || '', + partySize: data.get('partySize') || '', + }; + render(); +}); + +window.setInterval(() => { + if (!uiState.autoMonitor) return; + const before = getProposalQueue(state).length; + runAllAgentLoops(state); + const after = getProposalQueue(state).length; + uiState.lastAutoScan = Date.now(); + if (after !== before) { + saveState('Auto-monitor detected a new action and queued it for approval.'); + render(); + } +}, 12000); + +saveState('Prototype loaded. Every action still requires one-tap human approval.'); +render(); diff --git a/deliverable/stayflow_agents/deliverable/index.html b/deliverable/stayflow_agents/deliverable/index.html new file mode 100644 index 000000000..c4ffb27f6 --- /dev/null +++ b/deliverable/stayflow_agents/deliverable/index.html @@ -0,0 +1,13 @@ + + + + + + StayFlow Agents — Rental Marketplace Prototype + + + +
+ + + diff --git a/deliverable/stayflow_agents/deliverable/state-engine.mjs b/deliverable/stayflow_agents/deliverable/state-engine.mjs new file mode 100644 index 000000000..511a0463e --- /dev/null +++ b/deliverable/stayflow_agents/deliverable/state-engine.mjs @@ -0,0 +1,908 @@ +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +function deepClone(value) { + return JSON.parse(JSON.stringify(value)); +} + +function addDays(baseDate, dayOffset) { + const base = new Date(baseDate); + base.setUTCDate(base.getUTCDate() + dayOffset); + return base.toISOString().slice(0, 10); +} + +function nightlyTotal(booking) { + return (booking.nightlyRate || 0) * ((booking.endDay || 0) - (booking.startDay || 0)); +} + +function buildLoopTrace({ actor, monitor, detect, propose }) { + return { + actor, + monitor, + detect, + propose, + approve: 'Awaiting one-tap human approval.', + act: 'Action will execute immediately after approval.', + confirm: 'State and messages will update on success.', + }; +} + +function ensureCounters(state) { + state.counters ||= { proposal: 1, booking: 1, message: 1, listing: 100, dispute: 1, adjustment: 1, review: 1, trip: 100 }; + return state.counters; +} + +function nextId(state, prefix) { + const counters = ensureCounters(state); + counters[prefix] ||= 1; + const id = `${prefix}-${counters[prefix]++}`; + return id; +} + +function logActivity(state, type, message, meta = {}) { + state.activityLog.unshift({ + id: nextId(state, 'message'), + day: state.currentDay, + type, + message, + meta, + at: Date.now() + Math.floor(Math.random() * 1000), + }); +} + +function getGuest(state, guestId) { + return state.guests.find((guest) => guest.id === guestId); +} + +function getHost(state, hostId) { + return state.hosts.find((host) => host.id === hostId); +} + +function getListing(state, listingId) { + return state.listings.find((listing) => listing.id === listingId); +} + +function getBooking(state, bookingId) { + return state.bookings.find((booking) => booking.id === bookingId); +} + +function getTripRequest(state, tripRequestId) { + for (const guest of state.guests) { + const found = guest.tripRequests.find((trip) => trip.id === tripRequestId); + if (found) return found; + } + return undefined; +} + +function proposalExists(state, matcher) { + return state.proposals.some((proposal) => proposal.status === 'pending' && matcher(proposal)); +} + +function bookingForTrip(state, tripRequestId) { + return state.bookings.find((booking) => booking.tripRequestId === tripRequestId && !['cancelled', 'completed'].includes(booking.stage)); +} + +function scoreListing(tripRequest, listing) { + let score = 0; + if (tripRequest.city === listing.city) score += 5; + score += Math.max(0, 4 - Math.abs((tripRequest.budget || 0) - listing.nightlyRate) / 50); + if ((listing.maxGuests || 1) >= (tripRequest.partySize || 1)) score += 2; + for (const feature of tripRequest.mustHave || []) { + if (listing.features.includes(feature)) score += 1.5; + } + if (listing.tags.includes(tripRequest.vibe)) score += 1.25; + return score; +} + +function matchTripToListing(state, tripRequest) { + const candidates = state.listings + .filter((listing) => listing.city === tripRequest.city && listing.active !== false) + .map((listing) => ({ listing, score: scoreListing(tripRequest, listing) })) + .sort((a, b) => b.score - a.score); + return candidates[0]?.listing; +} + +function createProposal(state, draft) { + const proposal = { + id: nextId(state, 'proposal'), + createdDay: state.currentDay, + status: 'pending', + ...draft, + }; + state.proposals.unshift(proposal); + logActivity( + state, + 'proposal_created', + `${proposal.agentName} proposed an action for ${proposal.humanName}: ${proposal.title}`, + { proposalId: proposal.id, humanRole: proposal.humanRole, bookingId: proposal.bookingId || null }, + ); + return proposal; +} + +function addThreadMessage(state, booking, from, text, visibility = ['guest', 'host']) { + booking.thread ||= []; + booking.thread.push({ + id: nextId(state, 'message'), + day: state.currentDay, + from, + text, + visibility, + }); +} + +function calculatePayout(booking, listing) { + const nights = Math.max(1, booking.endDay - booking.startDay); + return booking.nightlyRate * nights + (listing.cleaningFee || 0); +} + +function createBookingFromTrip(state, tripRequest, listing) { + const booking = { + id: nextId(state, 'booking'), + guestId: tripRequest.guestId, + hostId: listing.hostId, + listingId: listing.id, + tripRequestId: tripRequest.id, + stage: 'inquiry', + lifecycle: 'inquiry', + startDay: tripRequest.startDay, + endDay: tripRequest.endDay, + requestedCheckIn: tripRequest.checkInPreference, + proposedNightlyRate: Math.min(tripRequest.budget, listing.nightlyRate), + nightlyRate: listing.nightlyRate, + total: listing.nightlyRate * (tripRequest.endDay - tripRequest.startDay), + partySize: tripRequest.partySize, + specialRequest: tripRequest.specialRequest, + thread: [], + issue: null, + dispute: { status: 'none' }, + financials: { payout: 0, adjustments: [] }, + review: { guestSubmitted: false, hostSubmitted: false }, + turnover: { status: 'pending' }, + }; + state.bookings.push(booking); + tripRequest.status = 'inquiry_sent'; + tripRequest.bookingId = booking.id; + addThreadMessage( + state, + booking, + 'guestAgent', + `Inquiry created for ${listing.title}. Requested ${tripRequest.checkInPreference} check-in and noted: ${tripRequest.specialRequest}.`, + ); + addThreadMessage( + state, + booking, + 'system', + `Both humans can review this plain-language thread before approving next actions.`, + ); + logActivity(state, 'booking_created', `Guest inquiry opened for ${listing.title}.`, { bookingId: booking.id, listingId: listing.id }); + return booking; +} + +function addAdjustment(state, booking, amount, reason, actor = 'admin') { + booking.financials.adjustments.push({ + id: nextId(state, 'adjustment'), + amount, + reason, + actor, + day: state.currentDay, + }); +} + +function approveGuestBookingProposal(state, proposal) { + const tripRequest = getTripRequest(state, proposal.action.tripRequestId); + const listing = getListing(state, proposal.action.listingId); + if (!tripRequest || !listing) return; + const booking = createBookingFromTrip(state, tripRequest, listing); + booking.humanApprovalHistory = [{ by: 'guest', decision: 'approved', proposalId: proposal.id, day: state.currentDay }]; + logActivity(state, 'proposal_approved', `Guest approved booking inquiry for ${listing.title}.`, { proposalId: proposal.id, bookingId: booking.id }); +} + +function approveHostCounterProposal(state, proposal) { + const booking = getBooking(state, proposal.bookingId); + const listing = getListing(state, booking?.listingId); + if (!booking || !listing) return; + const counterNightlyRate = proposal.action.counterNightlyRate; + booking.stage = 'negotiating'; + booking.lifecycle = 'negotiation'; + booking.pendingCounter = { + nightlyRate: counterNightlyRate, + checkInTime: proposal.action.checkInTime, + note: proposal.action.note, + }; + addThreadMessage( + state, + booking, + 'hostAgent', + `Counter-offer: ${proposal.action.note} ${proposal.action.checkInTime} check-in at $${counterNightlyRate}/night.`, + ); + booking.humanApprovalHistory ||= []; + booking.humanApprovalHistory.push({ by: 'host', decision: 'approved', proposalId: proposal.id, day: state.currentDay }); + logActivity(state, 'proposal_approved', `Host approved a counter-offer for booking ${booking.id}.`, { proposalId: proposal.id, bookingId: booking.id }); +} + +function approveGuestCounterAcceptance(state, proposal) { + const booking = getBooking(state, proposal.bookingId); + const listing = getListing(state, booking?.listingId); + if (!booking || !listing || !booking.pendingCounter) return; + booking.stage = 'booked'; + booking.lifecycle = 'booked'; + booking.nightlyRate = booking.pendingCounter.nightlyRate; + booking.confirmedCheckIn = booking.pendingCounter.checkInTime; + booking.total = booking.nightlyRate * (booking.endDay - booking.startDay); + booking.financials.payout = calculatePayout(booking, listing); + booking.pendingCounter = null; + const tripRequest = getTripRequest(state, booking.tripRequestId); + if (tripRequest) tripRequest.status = 'booked'; + addThreadMessage( + state, + booking, + 'guestAgent', + `Guest accepted the host counter-offer. Booking is now confirmed.`, + ); + logActivity(state, 'booking_confirmed', `Booking ${booking.id} is confirmed.`, { bookingId: booking.id }); +} + +function approveCheckIn(state, proposal) { + const booking = getBooking(state, proposal.bookingId); + if (!booking) return; + booking.stage = 'checked_in'; + booking.lifecycle = 'stay'; + booking.checkedInDay = state.currentDay; + addThreadMessage(state, booking, 'guestAgent', `Guest checked in using the approved arrival plan.`, ['guest', 'host']); + logActivity(state, 'check_in', `Guest checked in for booking ${booking.id}.`, { bookingId: booking.id }); +} + +function approveIssueResolution(state, proposal) { + const booking = getBooking(state, proposal.bookingId); + if (!booking || !booking.issue) return; + booking.issue.status = 'resolved'; + booking.issue.resolvedDay = state.currentDay; + booking.issue.resolution = proposal.action.resolution; + addAdjustment(state, booking, proposal.action.creditAmount, proposal.action.resolution, 'host'); + addThreadMessage(state, booking, 'hostAgent', `Resolution applied: ${proposal.action.resolution}. Credit: $${proposal.action.creditAmount}.`); + logActivity(state, 'issue_resolved', `Host resolved issue for booking ${booking.id}.`, { bookingId: booking.id }); +} + +function approveDisputeEscalation(state, proposal) { + const booking = getBooking(state, proposal.bookingId); + if (!booking || !booking.issue) return; + booking.dispute = { + id: nextId(state, 'dispute'), + status: 'open', + openedDay: state.currentDay, + summary: booking.issue.summary, + }; + booking.issue.status = 'escalated'; + addThreadMessage(state, booking, 'guestAgent', 'Escalating the unresolved issue to marketplace admin for mediation.'); + logActivity(state, 'dispute_opened', `Guest escalated booking ${booking.id} to admin.`, { bookingId: booking.id, disputeId: booking.dispute.id }); +} + +function approveAdminResolution(state, proposal) { + const booking = getBooking(state, proposal.bookingId); + if (!booking || !booking.issue) return; + booking.dispute.status = 'closed'; + booking.dispute.closedDay = state.currentDay; + booking.issue.status = 'resolved'; + booking.issue.resolution = proposal.action.resolution; + booking.issue.resolvedDay = state.currentDay; + addAdjustment(state, booking, proposal.action.creditAmount, proposal.action.resolution, 'admin'); + addThreadMessage(state, booking, 'admin', `Admin mediation closed the dispute: ${proposal.action.resolution}. Credit approved: $${proposal.action.creditAmount}.`); + logActivity(state, 'dispute_closed', `Admin resolved dispute for booking ${booking.id}.`, { bookingId: booking.id }); +} + +function approveCheckout(state, proposal) { + const booking = getBooking(state, proposal.bookingId); + if (!booking) return; + booking.stage = 'checked_out'; + booking.lifecycle = 'review'; + booking.checkedOutDay = state.currentDay; + booking.turnover.status = 'needed'; + addThreadMessage(state, booking, 'system', 'Guest checked out. Turnover coordination can begin.'); + logActivity(state, 'check_out', `Guest checked out of booking ${booking.id}.`, { bookingId: booking.id }); +} + +function approveReview(state, proposal) { + const booking = getBooking(state, proposal.bookingId); + if (!booking) return; + booking.review.guestSubmitted = true; + booking.review.guestReview = { + id: nextId(state, 'review'), + rating: 5, + summary: 'Smooth recovery and clear communication after an issue.', + day: state.currentDay, + }; + booking.stage = 'completed'; + booking.lifecycle = 'completed'; + const tripRequest = getTripRequest(state, booking.tripRequestId); + if (tripRequest) tripRequest.status = 'completed'; + addThreadMessage(state, booking, 'guestAgent', 'Guest review posted and profile preferences updated from outcome.'); + logActivity(state, 'review_submitted', `Guest submitted a review for booking ${booking.id}.`, { bookingId: booking.id }); +} + +function approveTurnover(state, proposal) { + const booking = getBooking(state, proposal.bookingId); + if (!booking) return; + booking.turnover.status = 'scheduled'; + booking.turnover.scheduledDay = state.currentDay; + logActivity(state, 'turnover_scheduled', `Turnover scheduled for booking ${booking.id}.`, { bookingId: booking.id }); +} + +function approvePriceAdjustment(state, proposal) { + const listing = getListing(state, proposal.action.listingId); + if (!listing) return; + listing.nightlyRate = proposal.action.newNightlyRate; + listing.lastPriceUpdateDay = state.currentDay; + logActivity(state, 'price_updated', `Host approved pricing update for ${listing.title}.`, { listingId: listing.id }); +} + +function rejectIssueResolution(state, proposal) { + const booking = getBooking(state, proposal.bookingId); + if (!booking || !booking.issue) return; + booking.issue.lastHostProposalRejected = true; + booking.issue.status = 'open'; + addThreadMessage(state, booking, 'system', 'Host rejected the proposed resolution; the agent will revise next steps.'); + logActivity(state, 'proposal_rejected', `Host rejected issue resolution proposal for booking ${booking.id}.`, { proposalId: proposal.id, bookingId: booking.id }); +} + +function genericReject(state, proposal) { + logActivity(state, 'proposal_rejected', `${proposal.humanName} rejected: ${proposal.title}`, { proposalId: proposal.id, bookingId: proposal.bookingId || null }); +} + +export function createInitialState() { + const baseDate = '2026-05-04T00:00:00.000Z'; + return { + version: 1, + createdAt: Date.now(), + baseDate, + currentDay: 1, + counters: { proposal: 1, booking: 1, message: 1, listing: 100, dispute: 1, adjustment: 1, review: 1, trip: 100 }, + guests: [ + { + id: 'guest-1', + name: 'Maya Chen', + notes: 'Returning guest who values fast wifi, clear check-in, and flexible problem handling.', + preferences: { prefersQuiet: true, defaultBudget: 320 }, + tripRequests: [ + { + id: 'trip-1', + guestId: 'guest-1', + label: 'Austin design sprint', + city: 'Austin', + startDay: 2, + endDay: 5, + budget: 265, + partySize: 1, + vibe: 'work', + mustHave: ['fast wifi', 'self check-in'], + checkInPreference: '9:00 PM', + specialRequest: 'Quiet desk setup for late product review.', + status: 'planning', + }, + { + id: 'trip-2', + guestId: 'guest-1', + label: 'Malibu family reset', + city: 'Malibu', + startDay: 4, + endDay: 7, + budget: 410, + partySize: 2, + vibe: 'coastal', + mustHave: ['parking', 'ocean view'], + checkInPreference: '4:30 PM', + specialRequest: 'Need child-friendly arrival instructions.', + status: 'planning', + }, + ], + }, + ], + hosts: [ + { id: 'host-1', name: 'Elena Ruiz', propertyIds: ['listing-1', 'listing-2'] }, + { id: 'host-2', name: 'Marcus Lee', propertyIds: ['listing-3'] }, + ], + admins: [{ id: 'admin-1', name: 'Jordan Kim' }], + rules: { + disputeCreditCap: 180, + guestApprovalRequired: true, + hostApprovalRequired: true, + adminApprovalRequired: true, + }, + listings: [ + { + id: 'listing-1', + hostId: 'host-1', + title: 'Harbor Loft', + city: 'Austin', + neighborhood: 'East Austin', + nightlyRate: 275, + cleaningFee: 45, + maxGuests: 2, + tags: ['work', 'design'], + features: ['fast wifi', 'self check-in', 'desk', 'coffee'], + active: true, + availabilityStrategy: 'normal', + occupancyTarget: 0.7, + }, + { + id: 'listing-2', + hostId: 'host-1', + title: 'Ocean View Bungalow', + city: 'Malibu', + neighborhood: 'Point Dume', + nightlyRate: 395, + cleaningFee: 65, + maxGuests: 4, + tags: ['coastal', 'family'], + features: ['parking', 'ocean view', 'smart lock', 'washer'], + active: true, + availabilityStrategy: 'normal', + occupancyTarget: 0.8, + }, + { + id: 'listing-3', + hostId: 'host-2', + title: 'Garden Studio', + city: 'Austin', + neighborhood: 'South Congress', + nightlyRate: 225, + cleaningFee: 35, + maxGuests: 2, + tags: ['work', 'budget'], + features: ['fast wifi', 'parking', 'patio'], + active: true, + availabilityStrategy: 'normal', + occupancyTarget: 0.6, + }, + ], + bookings: [], + proposals: [], + activityLog: [ + { + id: 'seed-1', + day: 1, + type: 'system', + message: 'Seeded demo state loaded with returning guest, multi-property host, and marketplace admin.', + at: Date.now(), + }, + ], + }; +} + +export function addTripRequest(state, input) { + const guest = getGuest(state, input.guestId || state.guests[0].id); + const trip = { + id: nextId(state, 'trip'), + guestId: guest.id, + label: input.label, + city: input.city, + startDay: Number(input.startDay), + endDay: Number(input.endDay), + budget: Number(input.budget), + partySize: Number(input.partySize || 1), + vibe: input.vibe || 'work', + mustHave: (input.mustHave || []).filter(Boolean), + checkInPreference: input.checkInPreference || '5:00 PM', + specialRequest: input.specialRequest || 'Standard arrival details requested.', + status: 'planning', + }; + guest.tripRequests.push(trip); + logActivity(state, 'trip_request_added', `New guest trip request added for ${trip.city}.`, { tripRequestId: trip.id }); + return trip; +} + +export function addListing(state, input) { + const host = getHost(state, input.hostId || state.hosts[0].id); + const listing = { + id: nextId(state, 'listing'), + hostId: host.id, + title: input.title, + city: input.city, + neighborhood: input.neighborhood || 'Custom', + nightlyRate: Number(input.nightlyRate), + cleaningFee: Number(input.cleaningFee || 40), + maxGuests: Number(input.maxGuests || 2), + tags: (input.tags || []).filter(Boolean), + features: (input.features || []).filter(Boolean), + active: true, + availabilityStrategy: input.availabilityStrategy || 'normal', + occupancyTarget: Number(input.occupancyTarget || 0.7), + }; + state.listings.push(listing); + host.propertyIds.push(listing.id); + logActivity(state, 'listing_added', `Host added listing ${listing.title}.`, { listingId: listing.id }); + return listing; +} + +export function searchListings(state, criteria = {}) { + return state.listings + .filter((listing) => listing.active !== false) + .filter((listing) => !criteria.city || listing.city === criteria.city) + .filter((listing) => !criteria.maxNightlyRate || listing.nightlyRate <= Number(criteria.maxNightlyRate)) + .filter((listing) => !criteria.partySize || listing.maxGuests >= Number(criteria.partySize)) + .map((listing) => ({ ...listing })); +} + +export function advanceDay(state, days = 1) { + state.currentDay += Number(days); + for (const booking of state.bookings) { + if (booking.stage === 'booked' && state.currentDay > booking.startDay) { + booking.lifecycle = 'arrival_window'; + } + } + logActivity(state, 'time_advanced', `Simulation advanced to day ${state.currentDay} (${addDays(state.baseDate, state.currentDay - 1)}).`, { currentDay: state.currentDay }); +} + +export function reportStayIssue(state, { bookingId, summary, severity = 'medium' }) { + const booking = getBooking(state, bookingId); + if (!booking) return; + booking.issue = { + status: 'open', + summary, + severity, + reportedDay: state.currentDay, + resolution: null, + lastHostProposalRejected: false, + }; + addThreadMessage(state, booking, 'guestAgent', `Issue detected during stay: ${summary}. Severity: ${severity}.`); + logActivity(state, 'issue_reported', `Issue reported for booking ${booking.id}.`, { bookingId: booking.id }); +} + +export function runGuestAgentLoop(state) { + for (const guest of state.guests) { + for (const tripRequest of guest.tripRequests) { + if (tripRequest.status === 'planning' && !bookingForTrip(state, tripRequest.id)) { + const bestListing = matchTripToListing(state, tripRequest); + if (bestListing && !proposalExists(state, (proposal) => proposal.action.tripRequestId === tripRequest.id && proposal.action.type === 'create_inquiry')) { + createProposal(state, { + humanRole: 'guest', + humanId: guest.id, + humanName: guest.name, + agentName: 'Guest Agent', + title: `Book ${bestListing.title} for ${tripRequest.label}`, + description: `Best match found in ${tripRequest.city}: ${bestListing.title} at $${bestListing.nightlyRate}/night with ${tripRequest.mustHave.join(', ')}.`, + action: { + type: 'create_inquiry', + tripRequestId: tripRequest.id, + listingId: bestListing.id, + }, + loopTrace: buildLoopTrace({ + actor: 'Guest Agent', + monitor: 'Watched open trip requests, preferences, and listing inventory.', + detect: `Detected an unbooked trip request for ${tripRequest.label}.`, + propose: `Proposed sending a ready-to-go inquiry to ${bestListing.title}.`, + }), + }); + } + } + } + } + + for (const booking of state.bookings) { + const guest = getGuest(state, booking.guestId); + if (booking.stage === 'negotiating' && booking.pendingCounter && !proposalExists(state, (proposal) => proposal.bookingId === booking.id && proposal.action.type === 'accept_counter')) { + createProposal(state, { + humanRole: 'guest', + humanId: guest.id, + humanName: guest.name, + agentName: 'Guest Agent', + bookingId: booking.id, + title: `Accept host counter for ${getListing(state, booking.listingId).title}`, + description: `Host can honor the request with ${booking.pendingCounter.checkInTime} check-in at $${booking.pendingCounter.nightlyRate}/night.`, + action: { type: 'accept_counter' }, + loopTrace: buildLoopTrace({ + actor: 'Guest Agent', + monitor: 'Watched booking negotiations and host replies.', + detect: 'Detected a new host counter-offer requiring guest approval.', + propose: 'Proposed accepting the revised terms and confirming the stay.', + }), + }); + } + + if (booking.stage === 'booked' && state.currentDay >= booking.startDay && !proposalExists(state, (proposal) => proposal.bookingId === booking.id && proposal.action.type === 'check_in_guest')) { + createProposal(state, { + humanRole: 'guest', + humanId: guest.id, + humanName: guest.name, + agentName: 'Guest Agent', + bookingId: booking.id, + title: `Check in to ${getListing(state, booking.listingId).title}`, + description: `Arrival window is open. Check in using the approved ${booking.confirmedCheckIn || booking.requestedCheckIn} plan.`, + action: { type: 'check_in_guest' }, + loopTrace: buildLoopTrace({ + actor: 'Guest Agent', + monitor: 'Watched confirmed bookings and arrival windows.', + detect: 'Detected that check-in is due today.', + propose: 'Proposed executing the confirmed check-in plan.', + }), + }); + } + + if (booking.issue?.status === 'open' && booking.issue.lastHostProposalRejected && booking.dispute.status === 'none' && !proposalExists(state, (proposal) => proposal.bookingId === booking.id && proposal.action.type === 'escalate_dispute')) { + createProposal(state, { + humanRole: 'guest', + humanId: guest.id, + humanName: guest.name, + agentName: 'Guest Agent', + bookingId: booking.id, + title: `Escalate ${getListing(state, booking.listingId).title} issue to admin`, + description: `The host did not approve the proposed recovery for “${booking.issue.summary}”. Escalate to admin mediation?`, + action: { type: 'escalate_dispute' }, + loopTrace: buildLoopTrace({ + actor: 'Guest Agent', + monitor: 'Watched in-stay issues, host decisions, and dispute thresholds.', + detect: 'Detected an unresolved issue after a failed recovery path.', + propose: 'Proposed escalating to marketplace admin for mediation.', + }), + }); + } + + if (booking.stage === 'checked_in' && state.currentDay >= booking.endDay && !proposalExists(state, (proposal) => proposal.bookingId === booking.id && proposal.action.type === 'check_out_guest')) { + createProposal(state, { + humanRole: 'guest', + humanId: guest.id, + humanName: guest.name, + agentName: 'Guest Agent', + bookingId: booking.id, + title: `Check out of ${getListing(state, booking.listingId).title}`, + description: 'Stay has reached checkout day. Complete checkout and trigger final settlement.', + action: { type: 'check_out_guest' }, + loopTrace: buildLoopTrace({ + actor: 'Guest Agent', + monitor: 'Watched active stays and checkout windows.', + detect: 'Detected the stay has reached departure day.', + propose: 'Proposed checking out and starting the post-stay flow.', + }), + }); + } + + if (booking.stage === 'checked_out' && !booking.review.guestSubmitted && !proposalExists(state, (proposal) => proposal.bookingId === booking.id && proposal.action.type === 'submit_review')) { + createProposal(state, { + humanRole: 'guest', + humanId: guest.id, + humanName: guest.name, + agentName: 'Guest Agent', + bookingId: booking.id, + title: `Submit review for ${getListing(state, booking.listingId).title}`, + description: 'Review window is open. Post a review and update future travel preferences from the outcome.', + action: { type: 'submit_review' }, + loopTrace: buildLoopTrace({ + actor: 'Guest Agent', + monitor: 'Watched completed stays, refunds, and review windows.', + detect: 'Detected a post-stay review opportunity.', + propose: 'Proposed submitting a review and learning from the result.', + }), + }); + } + } +} + +export function runHostAgentLoop(state) { + for (const booking of state.bookings) { + const host = getHost(state, booking.hostId); + const listing = getListing(state, booking.listingId); + if (booking.stage === 'inquiry' && !proposalExists(state, (proposal) => proposal.bookingId === booking.id && proposal.action.type === 'counter_inquiry')) { + const counterNightlyRate = Math.max(booking.proposedNightlyRate, listing.nightlyRate - 10); + createProposal(state, { + humanRole: 'host', + humanId: host.id, + humanName: host.name, + agentName: 'Host Agent', + bookingId: booking.id, + title: `Respond to inquiry for ${listing.title}`, + description: `Suggest ${counterNightlyRate}/night and ${booking.requestedCheckIn} self check-in with tailored arrival instructions.`, + action: { + type: 'counter_inquiry', + counterNightlyRate, + checkInTime: booking.requestedCheckIn, + note: 'Arrival request works with a slightly adjusted rate and digital guide.', + }, + loopTrace: buildLoopTrace({ + actor: 'Host Agent', + monitor: 'Watched new inquiries, pricing, and special requests.', + detect: 'Detected a guest inquiry requiring a host response.', + propose: 'Proposed a ready-to-send counter-offer and arrival plan.', + }), + }); + } + + if (booking.stage === 'checked_in' && booking.issue?.status === 'open' && booking.dispute.status === 'none' && !proposalExists(state, (proposal) => proposal.bookingId === booking.id && proposal.action.type === 'offer_issue_resolution')) { + const creditAmount = booking.issue.severity === 'high' ? 90 : 45; + createProposal(state, { + humanRole: 'host', + humanId: host.id, + humanName: host.name, + agentName: 'Host Agent', + bookingId: booking.id, + title: `Resolve stay issue at ${listing.title}`, + description: `Offer a ${creditAmount} credit and rapid support for “${booking.issue.summary}”.`, + action: { + type: 'offer_issue_resolution', + creditAmount, + resolution: `Applied a $${creditAmount} goodwill credit and escalated vendor support.`, + }, + loopTrace: buildLoopTrace({ + actor: 'Host Agent', + monitor: 'Watched active stays, guest reports, and service alerts.', + detect: 'Detected an in-stay issue affecting the guest experience.', + propose: 'Proposed a concrete service recovery package for host approval.', + }), + }); + } + + if (booking.stage === 'checked_out' && booking.turnover.status === 'needed' && !proposalExists(state, (proposal) => proposal.bookingId === booking.id && proposal.action.type === 'schedule_turnover')) { + createProposal(state, { + humanRole: 'host', + humanId: host.id, + humanName: host.name, + agentName: 'Host Agent', + bookingId: booking.id, + title: `Schedule turnover for ${listing.title}`, + description: 'Coordinate cleaning and restocking before the next arrival.', + action: { type: 'schedule_turnover' }, + loopTrace: buildLoopTrace({ + actor: 'Host Agent', + monitor: 'Watched checkout completions and back-to-back availability.', + detect: 'Detected turnover work needed between bookings.', + propose: 'Proposed scheduling cleaners and restocking tasks.', + }), + }); + } + } + + for (const listing of state.listings) { + const host = getHost(state, listing.hostId); + const shouldAdjust = listing.availabilityStrategy === 'underbooked' || state.currentDay % 3 === 0; + if (shouldAdjust && !proposalExists(state, (proposal) => proposal.action.type === 'adjust_price' && proposal.action.listingId === listing.id)) { + const newNightlyRate = Math.max(120, Math.round(listing.nightlyRate * 0.92)); + createProposal(state, { + humanRole: 'host', + humanId: host.id, + humanName: host.name, + agentName: 'Host Agent', + title: `Adjust nightly price for ${listing.title}`, + description: `Occupancy looks soft. Lower the nightly rate from $${listing.nightlyRate} to $${newNightlyRate}?`, + action: { type: 'adjust_price', listingId: listing.id, newNightlyRate }, + loopTrace: buildLoopTrace({ + actor: 'Host Agent', + monitor: 'Watched listing occupancy, pace, and rate competitiveness.', + detect: 'Detected underbooking pressure on upcoming dates.', + propose: 'Proposed a dynamic pricing adjustment for host approval.', + }), + }); + } + } +} + +export function runAdminAgentLoop(state) { + for (const booking of state.bookings) { + const admin = state.admins[0]; + if (booking.dispute?.status === 'open' && !proposalExists(state, (proposal) => proposal.bookingId === booking.id && proposal.humanRole === 'admin')) { + const creditAmount = Math.min(state.rules.disputeCreditCap, booking.issue?.severity === 'high' ? 120 : 60); + createProposal(state, { + humanRole: 'admin', + humanId: admin.id, + humanName: admin.name, + agentName: 'Admin Agent', + bookingId: booking.id, + title: `Mediate dispute for ${getListing(state, booking.listingId).title}`, + description: `Issue summary: ${booking.issue?.summary}. Apply a neutral mediation credit of $${creditAmount}?`, + action: { + type: 'admin_resolution', + creditAmount, + resolution: `Admin issued a $${creditAmount} marketplace credit after reviewing the unresolved stay issue.`, + }, + loopTrace: buildLoopTrace({ + actor: 'Admin Agent', + monitor: 'Watched open disputes, failed negotiations, and marketplace rules.', + detect: 'Detected a dispute that guest and host agents could not close.', + propose: 'Proposed a rule-based mediation outcome for admin approval.', + }), + }); + } + } +} + +export function runAllAgentLoops(state) { + runGuestAgentLoop(state); + runHostAgentLoop(state); + runAdminAgentLoop(state); + return state; +} + +export function approveProposal(state, proposalId) { + const proposal = state.proposals.find((candidate) => candidate.id === proposalId); + if (!proposal || proposal.status !== 'pending') return state; + proposal.status = 'approved'; + proposal.decidedDay = state.currentDay; + + switch (proposal.action.type) { + case 'create_inquiry': + approveGuestBookingProposal(state, proposal); + break; + case 'counter_inquiry': + approveHostCounterProposal(state, proposal); + break; + case 'accept_counter': + approveGuestCounterAcceptance(state, proposal); + break; + case 'check_in_guest': + approveCheckIn(state, proposal); + break; + case 'offer_issue_resolution': + approveIssueResolution(state, proposal); + break; + case 'escalate_dispute': + approveDisputeEscalation(state, proposal); + break; + case 'admin_resolution': + approveAdminResolution(state, proposal); + break; + case 'check_out_guest': + approveCheckout(state, proposal); + break; + case 'submit_review': + approveReview(state, proposal); + break; + case 'schedule_turnover': + approveTurnover(state, proposal); + break; + case 'adjust_price': + approvePriceAdjustment(state, proposal); + break; + default: + logActivity(state, 'proposal_approved', `Approved proposal ${proposal.title}.`, { proposalId }); + break; + } + + return state; +} + +export function rejectProposal(state, proposalId) { + const proposal = state.proposals.find((candidate) => candidate.id === proposalId); + if (!proposal || proposal.status !== 'pending') return state; + proposal.status = 'rejected'; + proposal.decidedDay = state.currentDay; + + switch (proposal.action.type) { + case 'offer_issue_resolution': + rejectIssueResolution(state, proposal); + break; + default: + genericReject(state, proposal); + break; + } + + return state; +} + +export function serializeState(state) { + return JSON.stringify(state); +} + +export function hydrateState(serialized) { + return typeof serialized === 'string' ? JSON.parse(serialized) : deepClone(serialized); +} + +export function getDayLabel(state, day = state.currentDay) { + return addDays(state.baseDate, day - 1); +} + +export function getProposalQueue(state, humanRole) { + return state.proposals.filter((proposal) => proposal.status === 'pending' && (!humanRole || proposal.humanRole === humanRole)); +} + +export function getVisibleThread(state, bookingId) { + const booking = getBooking(state, bookingId); + return booking?.thread || []; +} + +export function summarizeBooking(state, booking) { + const listing = getListing(state, booking.listingId); + const guest = getGuest(state, booking.guestId); + return { + ...booking, + listingTitle: listing?.title, + guestName: guest?.name, + hostName: getHost(state, booking.hostId)?.name, + totalValue: nightlyTotal(booking), + }; +} diff --git a/deliverable/stayflow_agents/deliverable/styles.css b/deliverable/stayflow_agents/deliverable/styles.css new file mode 100644 index 000000000..f9125f44c --- /dev/null +++ b/deliverable/stayflow_agents/deliverable/styles.css @@ -0,0 +1,364 @@ +:root { + --bg: #07111f; + --bg-soft: #0e1c31; + --panel: rgba(16, 29, 49, 0.92); + --panel-2: rgba(20, 36, 61, 0.96); + --line: rgba(154, 180, 214, 0.18); + --text: #eef4ff; + --muted: #9bb0d2; + --accent: #6ae3ff; + --accent-2: #7af0b4; + --warn: #ffbf69; + --danger: #ff7b9c; + --guest: #6ae3ff; + --host: #7af0b4; + --admin: #ffbf69; + --shadow: 0 22px 50px rgba(0, 0, 0, 0.28); + --radius: 18px; +} + +* { box-sizing: border-box; } +html, body { margin: 0; padding: 0; min-height: 100%; } +body { + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + color: var(--text); + background: + radial-gradient(circle at top left, rgba(106, 227, 255, 0.16), transparent 26%), + radial-gradient(circle at top right, rgba(122, 240, 180, 0.12), transparent 20%), + linear-gradient(180deg, #040b15 0%, #081322 36%, #091522 100%); +} + +a { color: inherit; } +button, input, select, textarea { + font: inherit; +} + +.shell { + width: min(1440px, calc(100% - 32px)); + margin: 0 auto; + padding: 28px 0 64px; +} + +.hero { + display: grid; + grid-template-columns: 1.35fr .9fr; + gap: 20px; + margin-bottom: 18px; +} + +.hero-card, +.panel, +.queue-column { + background: var(--panel); + border: 1px solid var(--line); + box-shadow: var(--shadow); + border-radius: var(--radius); + backdrop-filter: blur(14px); +} + +.hero-card { + padding: 26px; +} + +.hero h1 { + margin: 0 0 10px; + font-size: clamp(2rem, 4vw, 3.2rem); + line-height: 1.05; +} + +.hero p { + margin: 0; + color: var(--muted); + max-width: 65ch; +} + +.hero-badges, +.stage-strip, +.metric-row, +.listing-meta, +.inline-actions, +.badge-row, +.thread-tags { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.badge, +.metric-card span, +.stage-pill, +.tag, +.status-pill, +.thread-tag { + border: 1px solid var(--line); + border-radius: 999px; + padding: 7px 12px; + font-size: 0.86rem; + color: var(--muted); + background: rgba(255,255,255,0.03); +} + +.badge strong, +.metric-card strong { color: var(--text); } + +.stage-pill { + color: var(--text); + background: rgba(106, 227, 255, 0.08); +} + +.hero-side { + display: grid; + gap: 14px; +} + +.metric-row { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 14px; +} + +.metric-card { + padding: 18px; + background: var(--panel-2); + border: 1px solid var(--line); + border-radius: 16px; +} + +.metric-card h3 { + margin: 10px 0 4px; + font-size: 1.85rem; +} + +.controls { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + gap: 12px; + margin-bottom: 20px; +} + +.control-group { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +button { + border: 0; + border-radius: 12px; + padding: 11px 15px; + cursor: pointer; + transition: transform .15s ease, opacity .15s ease, background .15s ease; +} +button:hover { transform: translateY(-1px); } +button:active { transform: translateY(0); } +button.primary { background: linear-gradient(135deg, #63dfff, #45c8ff); color: #032033; font-weight: 700; } +button.secondary { background: rgba(255,255,255,0.08); color: var(--text); border: 1px solid var(--line); } +button.approve { background: rgba(122, 240, 180, 0.18); color: #caffea; border: 1px solid rgba(122, 240, 180, 0.35); } +button.reject { background: rgba(255, 123, 156, 0.14); color: #ffdbe6; border: 1px solid rgba(255, 123, 156, 0.3); } +button.warn { background: rgba(255, 191, 105, 0.17); color: #ffedcb; border: 1px solid rgba(255, 191, 105, 0.33); } +button.ghost { background: transparent; color: var(--muted); border: 1px dashed var(--line); } +button.small { padding: 8px 12px; font-size: .88rem; } +button.full { width: 100%; } + +.queue-grid, +.grid { + display: grid; + gap: 18px; +} + +.queue-grid { + grid-template-columns: repeat(3, 1fr); + margin-bottom: 18px; +} + +.queue-column { padding: 18px; } +.queue-column header { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 12px; } +.queue-column h2, +.panel h2 { margin: 0; font-size: 1.1rem; } + +.queue-column.guest { border-top: 3px solid var(--guest); } +.queue-column.host { border-top: 3px solid var(--host); } +.queue-column.admin { border-top: 3px solid var(--admin); } + +.proposal-card, +.booking-card, +.listing-card, +.trip-card, +.activity-item, +.dispute-card { + padding: 16px; + background: rgba(255,255,255,0.03); + border: 1px solid var(--line); + border-radius: 16px; +} + +.proposal-card + .proposal-card, +.booking-card + .booking-card, +.listing-card + .listing-card, +.trip-card + .trip-card, +.activity-item + .activity-item, +.dispute-card + .dispute-card { + margin-top: 12px; +} + +.proposal-card h3, +.booking-card h3, +.listing-card h3, +.trip-card h3, +.dispute-card h3 { + margin: 0 0 6px; + font-size: 1rem; +} + +.proposal-meta, +.muted, +.micro, +.helper { + color: var(--muted); +} + +.micro { font-size: .83rem; } +.helper { font-size: .9rem; line-height: 1.5; } + +.grid { + grid-template-columns: 1.05fr 1fr; + align-items: start; +} + +.panel { + padding: 20px; +} + +.panel-header { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 12px; + margin-bottom: 16px; +} + +.form-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 12px; +} + +.form-grid.compact { + grid-template-columns: repeat(3, 1fr); +} + +label { + display: grid; + gap: 6px; + font-size: .9rem; + color: var(--muted); +} + +input, +select, +textarea { + width: 100%; + border-radius: 12px; + border: 1px solid rgba(154, 180, 214, 0.22); + background: rgba(5, 11, 21, 0.65); + color: var(--text); + padding: 11px 12px; +} + +textarea { min-height: 90px; resize: vertical; } + +hr.sep { + border: 0; + border-top: 1px solid var(--line); + margin: 18px 0; +} + +.listing-card .price { + font-size: 1.35rem; + margin: 8px 0; +} + +.status-pill[data-status="booked"], +.status-pill[data-status="checked_in"], +.status-pill[data-status="completed"], +.status-pill[data-status="resolved"] { + color: #d9fff0; + background: rgba(122, 240, 180, 0.12); +} + +.status-pill[data-status="inquiry"], +.status-pill[data-status="negotiating"], +.status-pill[data-status="open"], +.status-pill[data-status="needed"] { + color: #ffeac9; + background: rgba(255, 191, 105, 0.12); +} + +.status-pill[data-status="escalated"], +.status-pill[data-status="closed"], +.status-pill[data-status="rejected"] { + color: #ffdbe6; + background: rgba(255, 123, 156, 0.12); +} + +.thread { + display: grid; + gap: 10px; + margin-top: 12px; +} + +.thread-message { + padding: 12px 14px; + border-left: 3px solid rgba(106, 227, 255, 0.35); + background: rgba(255,255,255,0.03); + border-radius: 12px; +} + +.thread-message.hostAgent { border-left-color: rgba(122, 240, 180, 0.55); } +.thread-message.admin { border-left-color: rgba(255, 191, 105, 0.55); } +.thread-message.system { border-left-color: rgba(154, 180, 214, 0.4); } + +.two-col { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 12px; +} + +.alert { + padding: 14px 16px; + border-radius: 14px; + border: 1px solid rgba(255, 191, 105, 0.32); + background: rgba(255, 191, 105, 0.08); + color: #ffe7c2; +} + +.empty { + padding: 16px; + border: 1px dashed var(--line); + border-radius: 14px; + color: var(--muted); +} + +footer.note { + margin-top: 18px; + color: var(--muted); + text-align: center; + font-size: .9rem; +} + +@media (max-width: 1100px) { + .hero, + .queue-grid, + .grid { grid-template-columns: 1fr; } + .metric-row { grid-template-columns: repeat(4, 1fr); } +} + +@media (max-width: 760px) { + .shell { width: min(100% - 20px, 1440px); } + .metric-row, + .form-grid, + .form-grid.compact, + .two-col { grid-template-columns: 1fr; } +} diff --git a/deliverable/stayflow_agents/tasks/changedoc.md b/deliverable/stayflow_agents/tasks/changedoc.md new file mode 100644 index 000000000..ab6739055 --- /dev/null +++ b/deliverable/stayflow_agents/tasks/changedoc.md @@ -0,0 +1,94 @@ +# Change Document + +**Based on:** original — final consolidated deliverable + +## Summary +Delivered a self-contained prototype of a short-stay rental marketplace with dedicated Guest Agent, Host Agent, and Admin Agent workflows. The app demonstrates the full inquiry → negotiation → booking → check-in → stay monitoring → dispute escalation → check-out → review lifecycle, requires explicit one-tap human approval before every action, preserves state across sessions with browser persistence, supports multiple guest stays and multiple host properties, and keeps all agent-to-agent exchanges visible in plain language. + +## Decisions + +### DEC-001: Build a self-contained browser prototype +**Origin:** agent1.1 — NEW +**Choice:** Implement the product as a static HTML/CSS/JavaScript app with local persistence instead of a separate backend service. +**Why:** A static browser prototype is the fastest way to demonstrate the full marketplace lifecycle, approval UX, and repeated sessions while remaining easy to run locally and verify. +**Alternatives considered:** +- Full backend/API stack: rejected because it adds infrastructure overhead without improving the prototype goals. +- CLI simulation: rejected because approval taps, proposal queues, and visible negotiation are clearer in a UI. +**Implementation:** +- `deliverable/index.html` — application shell +- `deliverable/styles.css` — interface styling for proposal queues, marketplace panels, and lifecycle cards +- `deliverable/app.mjs` — browser bootstrap, persistence wiring, rendering, event handling, auto-monitor loop +- `deliverable/README.md` — local run and demo instructions + +### DEC-002: Make the six-stage agent loop explicit in every proposal +**Origin:** agent1.1 — NEW +**Choice:** Every agent action is represented as a proposal card backed by the same monitor → detect → propose → approve → act → confirm loop. +**Why:** The task centers on agent behavior with human approval. Making the loop visible proves that no action executes until a human explicitly approves it. +**Alternatives considered:** +- Hidden background automation with generic notifications: rejected because it obscures how the agents operate and weakens the demo. +**Implementation:** +- `deliverable/state-engine.mjs` — `buildLoopTrace()`, `runGuestAgentLoop()`, `runHostAgentLoop()`, `runAdminAgentLoop()` +- `deliverable/app.mjs` — `renderProposal()`, grouped approval queues, approve/reject button wiring + +### DEC-003: Separate pure state transitions from the DOM layer +**Origin:** agent1.1 — NEW +**Choice:** Keep marketplace rules, booking transitions, approvals, disputes, pricing, turnovers, and serialization in a standalone state engine imported by the UI. +**Why:** Separating state logic from rendering keeps the implementation easier to test, reason about, and extend. +**Alternatives considered:** +- Single monolithic browser script: rejected because it would make lifecycle logic harder to verify and reuse. +**Implementation:** +- `deliverable/state-engine.mjs` — core state creation, agent loops, approval/rejection handlers, persistence helpers +- `deliverable/app.mjs` — UI-only orchestration and browser interaction layer +- `tests/prototype.test.mjs` — state-engine lifecycle coverage +- `tests/ui-smoke.test.mjs` — browser-module smoke coverage with DOM stubs + +### DEC-004: Seed the prototype with realistic multi-role, multi-property data +**Origin:** agent1.1 — NEW +**Choice:** Start the prototype with a returning guest, multiple trip requests, multiple hosts, multiple listings, and admin rules, while still allowing live creation of new trip requests and listings. +**Why:** Seeded data makes the marketplace immediately demoable and proves multi-stay and multi-property support without setup friction. +**Alternatives considered:** +- Empty initial state: rejected because it slows down evaluation of the full lifecycle. +**Implementation:** +- `deliverable/state-engine.mjs` — `createInitialState()`, `addTripRequest()`, `addListing()` +- `deliverable/app.mjs` — guest trip planner form and host listing studio form + +### DEC-005: Use a demo-day simulation to drive lifecycle events deterministically +**Origin:** agent1.1 — NEW +**Choice:** Represent time as numbered demo days and trigger arrival, stay, checkout, turnover, pricing, and review proposals from stateful day advancement. +**Why:** Deterministic demo time makes the full lifecycle easy to reproduce quickly without waiting on real clocks or background jobs. +**Alternatives considered:** +- Real-time waiting: rejected because it is slower and less reliable for demos and tests. +**Implementation:** +- `deliverable/state-engine.mjs` — `advanceDay()`, date labeling, day-based lifecycle detection +- `deliverable/app.mjs` — advance-day controls and fast-forward lifecycle helper + +### DEC-006: Keep agent-to-agent exchanges attached to each booking in plain language +**Origin:** agent1.1 — NEW +**Choice:** Store negotiation, service recovery, escalation, and admin mediation messages directly on the booking thread and render them inline. +**Why:** The requirement explicitly says all agent-to-agent exchanges must remain visible to both humans in plain language. +**Alternatives considered:** +- Detached inbox or hidden logs: rejected because it breaks lifecycle context and makes approvals less legible. +**Implementation:** +- `deliverable/state-engine.mjs` — `addThreadMessage()`, booking creation, negotiation handlers, dispute handlers +- `deliverable/app.mjs` — `renderBooking()` thread rendering + +### DEC-007: Cover the critical lifecycle with automated verification +**Origin:** agent1.1 — NEW +**Choice:** Verify the prototype with automated Node tests for negotiation, escalation, persistence, multi-stay support, dynamic pricing, and UI smoke behavior. +**Why:** The prototype is stateful and approval-driven, so automated checks provide strong evidence that the end-to-end flows work as intended. +**Alternatives considered:** +- Manual-only verification: rejected because it is less repeatable and easier to miss lifecycle regressions. +**Implementation:** +- `tests/prototype.test.mjs` — booking negotiation, dispute escalation, persistence, dynamic pricing, concurrent booking tests +- `tests/ui-smoke.test.mjs` — UI render and click-through smoke test +- `.massgen_scratch/verification/final/node-tests.log` — final local test run output +- `.massgen_scratch/verification/final/http-smoke.txt` — static server smoke note + +## Deliberation Trail +- agent1.1 introduced the static browser prototype direction and rejected a heavier backend because a self-contained UI better demonstrates approval-driven agent workflows. +- agent1.1 made the six-stage loop a first-class UI concept rather than a hidden internal process so reviewers can see each proposal’s reasoning and approval boundary. +- agent1.1 split the solution into a pure workflow/state module and a browser UI layer, which enabled the final deliverable to preserve the same logic in both the demo and the tests. +- agent1.1 chose seeded multi-role demo data plus editable live state so the final product could immediately show multiple concurrent stays, recurring hosts, and persistent user history. +- agent1.1 anchored lifecycle progress to numbered demo days, which became the final mechanism for deterministic check-in, stay, checkout, turnover, and review proposals. +- agent1.1 attached visible booking threads to negotiations and disputes, and the final deliverable keeps that design unchanged because it is the clearest way to satisfy the plain-language transparency requirement. +- The final consolidation preserves all of those decisions and updates implementation references to this workspace’s delivered files. diff --git a/deliverable/stayflow_agents/tests/prototype.test.mjs b/deliverable/stayflow_agents/tests/prototype.test.mjs new file mode 100644 index 000000000..d3b98e11f --- /dev/null +++ b/deliverable/stayflow_agents/tests/prototype.test.mjs @@ -0,0 +1,174 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + createInitialState, + runAllAgentLoops, + approveProposal, + rejectProposal, + advanceDay, + reportStayIssue, + serializeState, + hydrateState, +} from '../deliverable/state-engine.mjs'; + +test('supports proposal-driven booking negotiation through confirmation', () => { + const state = createInitialState(); + + runAllAgentLoops(state); + const guestProposal = state.proposals.find( + (proposal) => proposal.status === 'pending' && proposal.humanRole === 'guest', + ); + + assert.ok(guestProposal, 'guest agent should propose a booking action'); + approveProposal(state, guestProposal.id); + + const inquiry = state.bookings.find((booking) => booking.stage === 'inquiry'); + assert.ok(inquiry, 'approving guest proposal should create an inquiry'); + + runAllAgentLoops(state); + const hostProposal = state.proposals.find( + (proposal) => + proposal.status === 'pending' && + proposal.humanRole === 'host' && + proposal.bookingId === inquiry.id, + ); + + assert.ok(hostProposal, 'host agent should react to the inquiry'); + approveProposal(state, hostProposal.id); + + assert.equal( + state.bookings.find((booking) => booking.id === inquiry.id)?.stage, + 'negotiating', + 'host approval should create a counter or negotiated response', + ); + + runAllAgentLoops(state); + const guestNegotiationProposal = state.proposals.find( + (proposal) => + proposal.status === 'pending' && + proposal.humanRole === 'guest' && + proposal.bookingId === inquiry.id && + proposal.action.type === 'accept_counter', + ); + + assert.ok(guestNegotiationProposal, 'guest agent should bring back the host counter'); + approveProposal(state, guestNegotiationProposal.id); + + const confirmed = state.bookings.find((booking) => booking.id === inquiry.id); + assert.equal(confirmed.stage, 'booked'); + assert.ok( + confirmed.thread.some((message) => message.from === 'hostAgent'), + 'agent-to-agent messages should be recorded visibly', + ); +}); + +test('runs full in-stay issue escalation to admin and back to resolution', () => { + const state = createInitialState(); + runAllAgentLoops(state); + approveProposal( + state, + state.proposals.find((proposal) => proposal.status === 'pending' && proposal.humanRole === 'guest').id, + ); + runAllAgentLoops(state); + approveProposal( + state, + state.proposals.find((proposal) => proposal.status === 'pending' && proposal.humanRole === 'host').id, + ); + runAllAgentLoops(state); + approveProposal( + state, + state.proposals.find( + (proposal) => proposal.status === 'pending' && proposal.action.type === 'accept_counter', + ).id, + ); + + const booking = state.bookings[0]; + advanceDay(state, booking.startDay - state.currentDay); + runAllAgentLoops(state); + approveProposal( + state, + state.proposals.find( + (proposal) => proposal.status === 'pending' && proposal.action.type === 'check_in_guest', + ).id, + ); + + reportStayIssue(state, { + bookingId: booking.id, + summary: 'Wi‑Fi outage blocks remote work', + severity: 'high', + }); + + runAllAgentLoops(state); + const hostIssueProposal = state.proposals.find( + (proposal) => proposal.status === 'pending' && proposal.action.type === 'offer_issue_resolution', + ); + assert.ok(hostIssueProposal, 'host agent should propose an issue response'); + rejectProposal(state, hostIssueProposal.id); + + runAllAgentLoops(state); + const guestEscalation = state.proposals.find( + (proposal) => proposal.status === 'pending' && proposal.action.type === 'escalate_dispute', + ); + assert.ok(guestEscalation, 'guest agent should propose escalating unresolved issues'); + approveProposal(state, guestEscalation.id); + + runAllAgentLoops(state); + const adminProposal = state.proposals.find( + (proposal) => proposal.status === 'pending' && proposal.humanRole === 'admin', + ); + assert.ok(adminProposal, 'admin should receive a mediation proposal'); + approveProposal(state, adminProposal.id); + + const resolvedBooking = state.bookings.find((candidate) => candidate.id === booking.id); + assert.equal(resolvedBooking.issue.status, 'resolved'); + assert.equal(resolvedBooking.dispute.status, 'closed'); + assert.ok(resolvedBooking.financials.adjustments.length > 0, 'resolution should create a refund or credit'); +}); + +test('supports multiple concurrent stays, dynamic pricing proposals, and persistence', () => { + const state = createInitialState(); + runAllAgentLoops(state); + + const initialGuestProposals = state.proposals.filter( + (proposal) => proposal.status === 'pending' && proposal.humanRole === 'guest', + ); + assert.ok(initialGuestProposals.length >= 2, 'seeded state should allow multiple stay proposals'); + + for (const proposal of initialGuestProposals.slice(0, 2)) { + approveProposal(state, proposal.id); + } + + runAllAgentLoops(state); + for (const proposal of state.proposals.filter( + (candidate) => candidate.status === 'pending' && candidate.humanRole === 'host', + )) { + approveProposal(state, proposal.id); + } + + runAllAgentLoops(state); + for (const proposal of state.proposals.filter( + (candidate) => candidate.status === 'pending' && candidate.action.type === 'accept_counter', + )) { + approveProposal(state, proposal.id); + } + + const bookedTrips = state.bookings.filter((booking) => booking.stage === 'booked'); + assert.ok(bookedTrips.length >= 2, 'guest should be able to maintain multiple active bookings'); + + state.listings.forEach((listing) => { + listing.availabilityStrategy = 'underbooked'; + }); + runAllAgentLoops(state); + assert.ok( + state.proposals.some( + (proposal) => proposal.status === 'pending' && proposal.action.type === 'adjust_price', + ), + 'host agent should propose pricing updates', + ); + + const restored = hydrateState(serializeState(state)); + assert.equal(restored.bookings.length, state.bookings.length); + assert.equal(restored.listings.length, state.listings.length); + assert.equal(restored.activityLog.length, state.activityLog.length); +}); diff --git a/deliverable/stayflow_agents/tests/ui-smoke.test.mjs b/deliverable/stayflow_agents/tests/ui-smoke.test.mjs new file mode 100644 index 000000000..d3081e428 --- /dev/null +++ b/deliverable/stayflow_agents/tests/ui-smoke.test.mjs @@ -0,0 +1,88 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +class FakeElement { + constructor() { + this.innerHTML = ''; + this.listeners = new Map(); + } + + addEventListener(type, handler) { + this.listeners.set(type, handler); + } + + dispatch(type, event) { + const handler = this.listeners.get(type); + if (handler) handler(event); + } +} + +function createStorage() { + const store = new Map(); + return { + getItem(key) { + return store.has(key) ? store.get(key) : null; + }, + setItem(key, value) { + store.set(key, String(value)); + }, + removeItem(key) { + store.delete(key); + }, + dump(key) { + return store.get(key); + }, + }; +} + +test('browser UI renders and approves a booking inquiry through the click layer', async () => { + const appEl = new FakeElement(); + const localStorage = createStorage(); + + global.window = { + localStorage, + setInterval() { + return 1; + }, + }; + + global.document = { + querySelector(selector) { + if (selector === '#app') return appEl; + return null; + }, + }; + + const moduleUrl = `${pathToFileURL(path.resolve('deliverable/app.mjs')).href}?ui_smoke=${Date.now()}`; + await import(moduleUrl); + + assert.match(appEl.innerHTML, /StayFlow Agents/); + assert.match(appEl.innerHTML, /Guest approvals/); + + const initialState = JSON.parse(localStorage.dump('stayflow-rental-marketplace-v1')); + const firstGuestProposal = initialState.proposals.find( + (proposal) => proposal.status === 'pending' && proposal.humanRole === 'guest', + ); + assert.ok(firstGuestProposal, 'expected a guest proposal in local storage'); + + appEl.dispatch('click', { + target: { + closest(selector) { + if (selector === 'button[data-action]') { + return { dataset: { action: 'approve-proposal', id: firstGuestProposal.id } }; + } + return null; + }, + }, + }); + + const afterApproval = JSON.parse(localStorage.dump('stayflow-rental-marketplace-v1')); + assert.equal(afterApproval.bookings.length, 1); + assert.equal(afterApproval.bookings[0].stage, 'inquiry'); + assert.match(appEl.innerHTML, /Bookings, negotiation threads, and stay monitoring/); + + delete global.window; + delete global.document; +}); diff --git a/deliverable/texforge/README.md b/deliverable/texforge/README.md new file mode 100644 index 000000000..488083d2c --- /dev/null +++ b/deliverable/texforge/README.md @@ -0,0 +1,35 @@ +# TexForge + +TexForge is a full-stack collaborative LaTeX editing platform prototype inspired by Overleaf. It ships as a Python-first local app with: + +- project and file lifecycle management (create, clone, archive, delete, move/rename, delete files) +- template marketplace with search, preview, and one-click project instantiation (IEEE, ACM, thesis, resume) +- responsive split-view editor and PDF preview +- real-time collaboration over WebSockets with presence and cursor updates +- email/password authentication with cookie sessions +- role-aware sharing (owner/editor/viewer), project memberships, and org-style dashboards +- threaded comments with resolve/unresolve, suggestions, snapshots, explicit lightweight branches, diffing, restore, sharing links, notifications, and activity timeline +- bibliography import and citation autocomplete for DOI/arXiv/Scholar-style flows +- compile job queue simulation with downloadable PDF and ZIP export +- offline deterministic AI assist endpoint for LaTeX generation/fixes +- admin metrics for users, projects, memberships, compile jobs, and reference usage + +## Run + +```bash +uv run python -m uvicorn run:app --reload +``` + +Then open http://127.0.0.1:8000 + +## Test + +```bash +PYTHONPATH=. UV_CACHE_DIR=.massgen_scratch/uv-cache uv run pytest tests/test_texforge.py -q -p no:cacheprovider +``` + +## Notes + +- This environment does not include Docker or TeX Live, so the compile layer is implemented as a worker-style simulation with real logs, artifacts, and extension points. +- The collaboration and product architecture are designed so a real Yjs/worker/object-storage stack can replace the local services without changing the product surface. +- Guest mode remains available for local demo browsing, while authenticated users unlock permissions, memberships, reference tools, and admin metrics. diff --git a/deliverable/texforge/run.py b/deliverable/texforge/run.py new file mode 100644 index 000000000..bd6d2ea8c --- /dev/null +++ b/deliverable/texforge/run.py @@ -0,0 +1,3 @@ +from texforge.app import create_app + +app = create_app() diff --git a/deliverable/texforge/tasks/changedoc.md b/deliverable/texforge/tasks/changedoc.md new file mode 100644 index 000000000..f9a0e112f --- /dev/null +++ b/deliverable/texforge/tasks/changedoc.md @@ -0,0 +1,79 @@ +# Change Document + +**Sources reviewed:** agent1.2, agent_a + +## Summary +Delivered the final TexForge workspace as a runnable FastAPI-based collaborative LaTeX platform slice with authenticated projects, multi-file editing, template instantiation, WebSocket collaboration, threaded review, compile/export simulation, references, snapshots/diffs, and explicit lightweight branches. The final code keeps the Python-first architecture from earlier rounds and preserves the expanded marketplace, lifecycle, review, and branching workflows introduced in coordination. + +## Decisions + +### DEC-001: Keep the Python-first full-stack architecture +**Origin:** agent1.1 → agent1.2 (kept) +**Choice:** Implement TexForge with FastAPI, Jinja templates, vanilla JavaScript, SQLite, and WebSockets. +**Why:** The available environment supports a strong Python delivery path without requiring a JS build toolchain, so this architecture maximizes runnable product surface area while still covering frontend, backend, persistence, and realtime behavior. +**Alternatives considered:** +- Rebuild the UI in a richer frontend stack: rejected because it would reduce shipped functionality in the current environment. +- Limit the deliverable to API-only behavior: rejected because the task calls for a full-stack platform. +**Implementation:** +- `texforge/app.py` → `create_app()` wires the HTTP, HTML, auth, compile, review, search, sharing, reference, branch, and admin flows. +- `texforge/db.py` → `Database` owns the SQLite schema and persistence helpers for projects, files, comments, snapshots, branches, references, and memberships. +- `texforge/templates/dashboard.html`, `texforge/templates/project.html`, `texforge/static/app.js`, and `texforge/static/style.css` provide the browser-facing product surface. + +### DEC-002: Promote templates into a usable marketplace workflow +**Origin:** agent_a NEW (extends agent1.2) +**Choice:** Support searchable template listing, preview-by-slug, and one-step project creation from a selected template. +**Why:** Template support is much more useful when users can browse, inspect, and instantiate a template directly instead of treating it as a static catalog entry. +**Alternatives considered:** +- Keep template use implicit inside generic project creation: rejected because it hides a major requested workflow. +- Add community submission/moderation before browse/preview/use flows: rejected because core marketplace usability comes first. +**Implementation:** +- `texforge/app.py` → `api_templates()`, `api_template_preview()`, `api_create_project_from_template()`. +- `texforge/db.py` → `search_templates()`. +- `texforge/templates/dashboard.html` and `texforge/static/app.js` → dashboard search, preview, and create-from-template interactions. +- `tests/test_texforge.py` → `test_template_marketplace_preview_search_and_instantiation()`. + +### DEC-003: Complete the core project and file lifecycle +**Origin:** agent_a NEW (extends agent1.2) +**Choice:** Add file move/rename, file delete, and project delete flows on top of existing create, clone, and archive behavior. +**Why:** The platform brief explicitly requires create/delete/archive project management and practical organization of multi-file LaTeX trees. +**Alternatives considered:** +- Prioritize a richer tree UI before lifecycle correctness: rejected because backend lifecycle completeness is the stronger foundation. +- Leave deletion out to avoid destructive actions: rejected because missing delete flows would leave the platform operationally incomplete. +**Implementation:** +- `texforge/app.py` → `api_move_file()`, `api_delete_file()`, `api_delete_project()`. +- `texforge/db.py` → `move_file()`, `delete_file()`, `delete_project()`. +- `texforge/templates/project.html` and `texforge/static/app.js` → file action controls and destructive-action handlers. +- `tests/test_texforge.py` → `test_file_move_delete_and_project_delete()`. + +### DEC-004: Turn review into threaded, stateful collaboration +**Origin:** agent_a NEW (extends agent1.2) +**Choice:** Expose threaded comment retrieval and resolve/unresolve actions, while preserving suggestions and acceptance flows. +**Why:** Academic review needs discussion threads that can be worked through and closed, not only flat comments. +**Alternatives considered:** +- Keep replies stored but not surfaced as threads: rejected because the UI would still behave like flat review. +- Focus only on suggestion acceptance: rejected because threaded review improves the wider collaboration loop. +**Implementation:** +- `texforge/app.py` → `api_comment()`, `api_list_comments()`, `api_resolve_comment()`, `api_unresolve_comment()`, `api_suggestion()`, `api_accept_suggestion()`. +- `texforge/db.py` → `list_comment_threads()`, `set_comment_resolved()`, `create_suggestion()`, `accept_suggestion()`. +- `texforge/static/app.js` → threaded comment rendering and resolution toggles. +- `tests/test_texforge.py` → `test_threaded_comments_resolution_and_branches()` plus existing suggestion coverage. + +### DEC-005: Represent lightweight branching as an explicit restorable object +**Origin:** agent_a NEW (extends agent1.2) +**Choice:** Persist named branches anchored to snapshots, list them separately, and allow branch restore through the API and UI. +**Why:** The requested version-control surface includes lightweight branching and named restore points; explicit branch objects are clearer and more demonstrable than implied branches. +**Alternatives considered:** +- Treat snapshots alone as informal branches: rejected because that leaves the branching workflow ambiguous. +- Attempt full git-style merge semantics locally: rejected because named branch creation and restore delivers the higher-value lightweight slice here. +**Implementation:** +- `texforge/app.py` → `api_create_branch()`, `api_list_branches()`, `api_restore_branch()`. +- `texforge/db.py` → `create_branch()`, `list_branches()`, `restore_branch()` and the `branches` table. +- `texforge/templates/project.html` and `texforge/static/app.js` → branch creation, listing, and restore controls. +- `tests/test_texforge.py` → `test_threaded_comments_resolution_and_branches()`. + +## Deliberation Trail +- **agent1.1 → agent1.2:** Established the runnable Python-first TexForge architecture and broad product slice. +- **agent_a NEW:** Completed the template gallery into a searchable, previewable marketplace with direct instantiation. +- **agent_a NEW:** Closed basic lifecycle gaps by adding file move/delete and project delete operations. +- **agent_a NEW:** Finished the latent threaded review model with explicit resolve/unresolve flows. +- **agent_a NEW:** Elevated snapshots into explicit lightweight branches with restore behavior. diff --git a/deliverable/texforge/tests/test_texforge.py b/deliverable/texforge/tests/test_texforge.py new file mode 100644 index 000000000..fdf7a5956 --- /dev/null +++ b/deliverable/texforge/tests/test_texforge.py @@ -0,0 +1,481 @@ +from __future__ import annotations + +import asyncio +import json +import os +from pathlib import Path +import socket +import subprocess +import sys +import time + +import httpx +from fastapi.testclient import TestClient +import websockets + +from texforge.app import create_app +from texforge.db import Database + + +def make_client(tmp_path: Path) -> TestClient: + db_path = tmp_path / "texforge.db" + database = Database(db_path) + app = create_app(database=database) + return TestClient(app) + + +def register_and_login(client: TestClient, email: str, password: str = "secret123", name: str = "User") -> dict: + register = client.post( + "/auth/register", + json={"email": email, "password": password, "name": name}, + ) + assert register.status_code == 200 + login = client.post("/auth/login", json={"email": email, "password": password}) + assert login.status_code == 200 + return login.json() + + +def free_port() -> int: + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + return int(sock.getsockname()[1]) + + +def wait_for_server(base_url: str) -> None: + for _ in range(50): + try: + response = httpx.get(f"{base_url}/health", timeout=1.0) + if response.status_code == 200: + return + except Exception: + time.sleep(0.1) + raise RuntimeError("server did not start in time") + + +def test_dashboard_renders_seeded_content(tmp_path: Path) -> None: + client = make_client(tmp_path) + + response = client.get("/") + + assert response.status_code == 200 + body = response.text + assert "TexForge" in body + assert "IEEE Conference" in body + assert "Quantum Notes" in body + assert "Live collaboration" in body + + +def test_project_lifecycle_compile_and_export(tmp_path: Path) -> None: + client = make_client(tmp_path) + + create_response = client.post( + "/api/projects", + json={ + "name": "My Paper", + "template": "acm", + "owner": "alice@example.com", + }, + ) + assert create_response.status_code == 200 + project = create_response.json() + project_id = project["id"] + + file_response = client.post( + f"/api/projects/{project_id}/files", + json={ + "path": "sections/intro.tex", + "content": "\\section{Intro} Collaborative writing.", + }, + ) + assert file_response.status_code == 200 + assert file_response.json()["path"] == "sections/intro.tex" + + clone_response = client.post(f"/api/projects/{project_id}/clone") + assert clone_response.status_code == 200 + assert clone_response.json()["name"].startswith("My Paper (Clone") + + compile_response = client.post( + f"/api/projects/{project_id}/compile", + json={"engine": "xelatex", "entrypoint": "main.tex", "trigger": "manual"}, + ) + assert compile_response.status_code == 200 + job = compile_response.json() + assert job["status"] == "completed" + assert "xelatex" in job["log"] + assert job["pdf_url"].endswith(".pdf") + + job_response = client.get(f"/api/projects/{project_id}/jobs/{job['id']}") + assert job_response.status_code == 200 + assert job_response.json()["status"] == "completed" + + export_response = client.get(f"/api/projects/{project_id}/export.zip") + assert export_response.status_code == 200 + assert export_response.headers["content-type"] == "application/zip" + + archive_response = client.post(f"/api/projects/{project_id}/archive") + assert archive_response.status_code == 200 + assert archive_response.json()["archived"] is True + + +def test_comments_snapshots_diff_search_and_ai(tmp_path: Path) -> None: + client = make_client(tmp_path) + + project = client.post( + "/api/projects", + json={"name": "Review Draft", "template": "ieee", "owner": "reviewer@example.com"}, + ).json() + project_id = project["id"] + + main_file = client.post( + f"/api/projects/{project_id}/files", + json={"path": "main.tex", "content": "\\section{Results} First draft."}, + ).json() + file_id = main_file["id"] + + snapshot_a = client.post( + f"/api/projects/{project_id}/snapshots", + json={"name": "Draft A", "file_id": file_id}, + ) + assert snapshot_a.status_code == 200 + + update = client.put( + f"/api/files/{file_id}", + json={"content": "\\section{Results} Revised draft with citation \\cite{smith2024}."}, + ) + assert update.status_code == 200 + + snapshot_b = client.post( + f"/api/projects/{project_id}/snapshots", + json={"name": "Draft B", "file_id": file_id}, + ) + assert snapshot_b.status_code == 200 + + diff_response = client.get( + f"/api/projects/{project_id}/diff", + params={"from_snapshot": snapshot_a.json()["id"], "to_snapshot": snapshot_b.json()["id"]}, + ) + assert diff_response.status_code == 200 + assert "Revised draft" in diff_response.json()["diff"] + + comment_response = client.post( + f"/api/projects/{project_id}/comments", + json={ + "file_id": file_id, + "author": "prof@example.com", + "body": "Please strengthen the literature review.", + "line_from": 1, + "line_to": 1, + }, + ) + assert comment_response.status_code == 200 + + search_response = client.get("/api/search", params={"q": "literature review"}) + assert search_response.status_code == 200 + assert any(item["kind"] == "comment" for item in search_response.json()["results"]) + + ai_response = client.post( + f"/api/projects/{project_id}/ai/assist", + json={ + "prompt": "Write a LaTeX table for experiment results with accuracy and F1 columns.", + "mode": "generate", + }, + ) + assert ai_response.status_code == 200 + assert "tabular" in ai_response.json()["suggestion"] + + +def test_auth_permissions_sharing_and_admin_metrics(tmp_path: Path) -> None: + owner_client = make_client(tmp_path) + bob_client = make_client(tmp_path) + + owner = register_and_login(owner_client, "alice@example.com", name="Alice") + register_and_login(bob_client, "bob@example.com", name="Bob") + + project = owner_client.post( + "/api/projects", + json={"name": "Secured Draft", "template": "ieee", "owner": owner["email"]}, + ).json() + project_id = project["id"] + + denied = bob_client.get(f"/api/projects/{project_id}/files") + assert denied.status_code == 403 + + share = owner_client.post( + f"/api/projects/{project_id}/share-links", + json={"role": "editor", "expires_in_days": 7}, + ) + assert share.status_code == 200 + share_id = share.json()["id"] + + accept = bob_client.post(f"/api/share/{share_id}/accept") + assert accept.status_code == 200 + assert accept.json()["role"] == "editor" + + file_response = bob_client.post( + f"/api/projects/{project_id}/files", + json={"path": "sections/method.tex", "content": "\\section{Method} Shared edits."}, + ) + assert file_response.status_code == 200 + + admin = owner_client.get("/api/admin/metrics") + assert admin.status_code == 200 + metrics = admin.json() + assert metrics["users"] >= 2 + assert metrics["projects"] >= 1 + assert metrics["memberships"] >= 2 + + +def test_suggestions_references_and_snapshot_restore(tmp_path: Path) -> None: + client = make_client(tmp_path) + owner = register_and_login(client, "writer@example.com", name="Writer") + + project = client.post( + "/api/projects", + json={"name": "Reference Draft", "template": "ieee", "owner": owner["email"]}, + ).json() + project_id = project["id"] + main_file = next(item for item in client.get(f"/api/projects/{project_id}/files").json() if item["path"] == "main.tex") + + suggestion = client.post( + f"/api/projects/{project_id}/suggestions", + json={ + "file_id": main_file["id"], + "author": "reviewer@example.com", + "body": "Use a stronger introduction sentence.", + "original_text": "Write here.", + "suggested_text": "Write here with stronger framing and a clearer motivation.", + }, + ) + assert suggestion.status_code == 200 + + accepted = client.post(f"/api/projects/{project_id}/suggestions/{suggestion.json()['id']}/accept") + assert accepted.status_code == 200 + assert "stronger framing" in accepted.json()["file"]["content"] + + imported = client.post( + f"/api/projects/{project_id}/references/import", + json={"source": "doi", "identifier": "10.5555/texforge-demo"}, + ) + assert imported.status_code == 200 + citation_key = imported.json()["citation_key"] + assert citation_key + + duplicate = client.post( + f"/api/projects/{project_id}/references/import", + json={"source": "doi", "identifier": "10.5555/texforge-demo"}, + ) + assert duplicate.status_code == 200 + assert duplicate.json()["duplicate"] is True + + autocomplete = client.get(f"/api/projects/{project_id}/citations", params={"q": "texforge"}) + assert autocomplete.status_code == 200 + assert any(item["citation_key"] == citation_key for item in autocomplete.json()["results"]) + + snapshot = client.post( + f"/api/projects/{project_id}/snapshots", + json={"name": "Accepted suggestion", "file_id": main_file["id"]}, + ).json() + + client.put( + f"/api/files/{main_file['id']}", + json={"content": "\\section{Introduction} Diverged draft."}, + ) + restored = client.post(f"/api/projects/{project_id}/snapshots/{snapshot['id']}/restore") + assert restored.status_code == 200 + assert "stronger framing" in restored.json()["content"] + + +def test_websocket_collaboration_broadcasts_presence_and_edits(tmp_path: Path) -> None: + db_path = tmp_path / "live_ws.db" + port = free_port() + base_url = f"http://127.0.0.1:{port}" + env = { + **os.environ, + "PYTHONPATH": ".", + "TEXFORGE_DB_PATH": str(db_path), + } + server = subprocess.Popen( + [sys.executable, "-m", "uvicorn", "run:app", "--port", str(port)], + cwd=str(Path(__file__).resolve().parents[1]), + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + try: + wait_for_server(base_url) + project = httpx.post( + f"{base_url}/api/projects", + json={"name": "Realtime", "template": "thesis", "owner": "alice@example.com"}, + timeout=5.0, + ).json() + project_id = project["id"] + + async def exercise() -> None: + alice = await websockets.connect(f"ws://127.0.0.1:{port}/ws/projects/{project_id}?user=alice") + alice_join = json.loads(await alice.recv()) + assert alice_join["type"] == "sync" + + bob = await websockets.connect(f"ws://127.0.0.1:{port}/ws/projects/{project_id}?user=bob") + bob_initial = json.loads(await bob.recv()) + assert bob_initial["type"] == "sync" + + alice_presence = json.loads(await alice.recv()) + assert alice_presence["type"] == "presence" + assert alice_presence["user"] == "bob" + + await bob.send(json.dumps({"type": "edit", "path": "main.tex", "content": "\\section{Realtime} Hello from Bob."})) + alice_edit = json.loads(await alice.recv()) + assert alice_edit["type"] == "edit" + assert alice_edit["content"].endswith("Hello from Bob.") + + await alice.send(json.dumps({"type": "cursor", "path": "main.tex", "line": 3, "column": 7})) + bob_cursor = json.loads(await bob.recv()) + assert bob_cursor["type"] == "cursor" + assert bob_cursor["line"] == 3 + await bob.close() + await alice.close() + + asyncio.run(exercise()) + finally: + server.terminate() + try: + server.wait(timeout=5) + except subprocess.TimeoutExpired: + server.kill() + + +def test_template_marketplace_preview_search_and_instantiation(tmp_path: Path) -> None: + client = make_client(tmp_path) + + listing = client.get('/api/templates', params={'q': 'resume'}) + assert listing.status_code == 200 + templates = listing.json()['results'] + assert len(templates) == 1 + assert templates[0]['slug'] == 'resume' + + preview = client.get('/api/templates/ieee') + assert preview.status_code == 200 + assert 'main_tex' in preview.json() + assert 'Sample Reference' in preview.json()['refs_bib'] + + created = client.post( + '/api/projects/from-template', + json={'name': 'Instantiated from gallery', 'template': 'resume', 'owner': 'gallery@example.com'}, + ) + assert created.status_code == 200 + project = created.json() + assert project['template'] == 'resume' + assert any(file['path'] == 'main.tex' for file in project['files']) + + + +def test_file_move_delete_and_project_delete(tmp_path: Path) -> None: + client = make_client(tmp_path) + project = client.post( + '/api/projects', + json={'name': 'Lifecycle', 'template': 'acm', 'owner': 'alice@example.com'}, + ).json() + project_id = project['id'] + + created = client.post( + f'/api/projects/{project_id}/files', + json={'path': 'sections/intro.tex', 'content': '\\section{Intro} draft'}, + ) + assert created.status_code == 200 + file_id = created.json()['id'] + + moved = client.patch(f'/api/files/{file_id}/move', json={'path': 'sections/background.tex'}) + assert moved.status_code == 200 + assert moved.json()['path'] == 'sections/background.tex' + + files_after_move = client.get(f'/api/projects/{project_id}/files') + assert files_after_move.status_code == 200 + paths = [item['path'] for item in files_after_move.json()] + assert 'sections/background.tex' in paths + assert 'sections/intro.tex' not in paths + + deleted_file = client.delete(f'/api/files/{file_id}') + assert deleted_file.status_code == 200 + assert deleted_file.json()['deleted'] is True + + deleted_project = client.delete(f'/api/projects/{project_id}') + assert deleted_project.status_code == 200 + assert deleted_project.json()['deleted'] is True + + missing = client.get(f'/api/projects/{project_id}/files') + assert missing.status_code == 404 + + + +def test_threaded_comments_resolution_and_branches(tmp_path: Path) -> None: + client = make_client(tmp_path) + project = client.post( + '/api/projects', + json={'name': 'Branching Draft', 'template': 'ieee', 'owner': 'alice@example.com'}, + ).json() + project_id = project['id'] + main_file = next(item for item in client.get(f'/api/projects/{project_id}/files').json() if item['path'] == 'main.tex') + + parent = client.post( + f'/api/projects/{project_id}/comments', + json={ + 'file_id': main_file['id'], + 'author': 'reviewer@example.com', + 'body': 'Please revise the introduction.', + 'line_from': 1, + 'line_to': 1, + }, + ) + assert parent.status_code == 200 + + reply = client.post( + f'/api/projects/{project_id}/comments', + json={ + 'file_id': main_file['id'], + 'author': 'author@example.com', + 'body': 'Will do.', + 'line_from': 1, + 'line_to': 1, + 'parent_id': parent.json()['id'], + }, + ) + assert reply.status_code == 200 + + thread = client.get(f'/api/projects/{project_id}/comments') + assert thread.status_code == 200 + thread_data = thread.json()['results'] + root = next(item for item in thread_data if item['id'] == parent.json()['id']) + assert root['reply_count'] == 1 + assert root['replies'][0]['id'] == reply.json()['id'] + + resolved = client.post(f"/api/projects/{project_id}/comments/{parent.json()['id']}/resolve") + assert resolved.status_code == 200 + assert resolved.json()['resolved'] is True + + unresolved = client.post(f"/api/projects/{project_id}/comments/{parent.json()['id']}/unresolve") + assert unresolved.status_code == 200 + assert unresolved.json()['resolved'] is False + + snapshot = client.post( + f'/api/projects/{project_id}/snapshots', + json={'name': 'Base draft', 'file_id': main_file['id']}, + ).json() + branch = client.post( + f'/api/projects/{project_id}/branches', + json={'name': 'journal-revision', 'snapshot_id': snapshot['id']}, + ) + assert branch.status_code == 200 + assert branch.json()['name'] == 'journal-revision' + + listed = client.get(f'/api/projects/{project_id}/branches') + assert listed.status_code == 200 + assert any(item['id'] == branch.json()['id'] for item in listed.json()['results']) + + client.put( + f"/api/files/{main_file['id']}", + json={'content': '\\section{Introduction} Diverged text.'}, + ) + restored = client.post(f"/api/projects/{project_id}/branches/{branch.json()['id']}/restore") + assert restored.status_code == 200 + assert 'Write here.' in restored.json()['content'] diff --git a/deliverable/texforge/texforge/__init__.py b/deliverable/texforge/texforge/__init__.py new file mode 100644 index 000000000..b94a1e85f --- /dev/null +++ b/deliverable/texforge/texforge/__init__.py @@ -0,0 +1,3 @@ +from .app import create_app + +__all__ = ["create_app"] diff --git a/deliverable/texforge/texforge/app.py b/deliverable/texforge/texforge/app.py new file mode 100644 index 000000000..e0b5f8739 --- /dev/null +++ b/deliverable/texforge/texforge/app.py @@ -0,0 +1,455 @@ +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any + +from fastapi import FastAPI, HTTPException, Request, Response, WebSocket, WebSocketDisconnect +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse, HTMLResponse +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates +from pydantic import BaseModel + +from .db import Database +from .services import CompileService, ai_assist, build_diff + + +class ProjectCreate(BaseModel): + name: str + template: str + owner: str + description: str = "" + visibility: str = "private" + + +class FileCreate(BaseModel): + path: str + content: str + + +class FileUpdate(BaseModel): + content: str + + +class FileMoveRequest(BaseModel): + path: str + + +class CompileRequest(BaseModel): + engine: str = "pdflatex" + entrypoint: str = "main.tex" + trigger: str = "manual" + + +class SnapshotRequest(BaseModel): + name: str + file_id: str + + +class CommentRequest(BaseModel): + file_id: str + author: str + body: str + line_from: int = 1 + line_to: int = 1 + parent_id: str | None = None + + +class AIRequest(BaseModel): + prompt: str + mode: str = "generate" + + +class AuthRegister(BaseModel): + email: str + password: str + name: str + + +class AuthLogin(BaseModel): + email: str + password: str + + +class ShareLinkRequest(BaseModel): + role: str = "viewer" + expires_in_days: int = 14 + + +class SuggestionRequest(BaseModel): + file_id: str + author: str + body: str + original_text: str + suggested_text: str + + +class ReferenceImportRequest(BaseModel): + source: str + identifier: str + + +class BranchRequest(BaseModel): + name: str + snapshot_id: str + + +class CollaborationHub: + def __init__(self, database: Database) -> None: + self.database = database + self.connections: dict[str, dict[str, WebSocket]] = {} + + async def connect(self, project_id: str, user: str, websocket: WebSocket) -> None: + await websocket.accept() + self.connections.setdefault(project_id, {})[user] = websocket + files = {file["path"]: file["content"] for file in self.database.list_project_files(project_id)} + await websocket.send_json({"type": "sync", "project_id": project_id, "files": files, "users": list(self.connections[project_id].keys())}) + await self.broadcast(project_id, {"type": "presence", "user": user, "status": "joined"}, exclude=user) + + async def disconnect(self, project_id: str, user: str) -> None: + group = self.connections.get(project_id, {}) + if user in group: + group.pop(user) + await self.broadcast(project_id, {"type": "presence", "user": user, "status": "left"}) + if not group: + self.connections.pop(project_id, None) + + async def broadcast(self, project_id: str, payload: dict[str, Any], exclude: str | None = None) -> None: + for member, websocket in list(self.connections.get(project_id, {}).items()): + if exclude and member == exclude: + continue + await websocket.send_json(payload) + + +def create_app(database: Database | None = None) -> FastAPI: + base_dir = Path(__file__).parent + app = FastAPI(title="TexForge", version="0.1.0") + app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) + db = database or Database(Path(os.environ.get("TEXFORGE_DB_PATH", "texforge_data/texforge.db"))) + compile_service = CompileService(db) + hub = CollaborationHub(db) + templates = Jinja2Templates(directory=str(base_dir / "templates")) + app.mount("/static", StaticFiles(directory=str(base_dir / "static")), name="static") + + role_rank = {"viewer": 1, "editor": 2, "owner": 3} + + def current_user(request: Request) -> dict[str, Any] | None: + session_id = request.cookies.get("texforge_session") + if not session_id: + return None + return db.get_user_by_session(session_id) + + def require_login(request: Request) -> dict[str, Any]: + user = current_user(request) + if not user: + raise HTTPException(status_code=401, detail="Authentication required") + return user + + def ensure_project_access(request: Request, project_id: str, minimum_role: str = "viewer") -> dict[str, Any]: + user = current_user(request) + if not user: + return {"role": "owner", "email": "guest", "name": "Guest Demo", "user_id": None} + member = db.get_project_member(project_id, user["id"]) + if not member: + raise HTTPException(status_code=403, detail="You do not have access to this project") + if role_rank.get(member["role"], 0) < role_rank.get(minimum_role, 0): + raise HTTPException(status_code=403, detail="Insufficient project permissions") + return member + + @app.get("/health") + def health() -> dict[str, str]: + return {"status": "ok"} + + @app.post("/auth/register") + def auth_register(payload: AuthRegister) -> dict[str, Any]: + try: + return db.create_user(payload.email, payload.password, payload.name) + except Exception as exc: + raise HTTPException(status_code=400, detail="Could not register user") from exc + + @app.post("/auth/login") + def auth_login(payload: AuthLogin, response: Response) -> dict[str, Any]: + user = db.authenticate_user(payload.email, payload.password) + if not user: + raise HTTPException(status_code=401, detail="Invalid credentials") + session_id = db.create_session(user["id"]) + response.set_cookie("texforge_session", session_id, httponly=True, samesite="lax") + return user + + @app.post("/auth/logout") + def auth_logout(request: Request, response: Response) -> dict[str, bool]: + session_id = request.cookies.get("texforge_session") + if session_id: + db.delete_session(session_id) + response.delete_cookie("texforge_session") + return {"ok": True} + + @app.get("/api/me") + def api_me(request: Request) -> dict[str, Any]: + user = current_user(request) + if not user: + return {"authenticated": False} + return {"authenticated": True, **user} + + @app.get("/", response_class=HTMLResponse) + def dashboard(request: Request) -> HTMLResponse: + projects = db.list_projects() + context = { + "request": request, + "projects": projects, + "templates": db.list_templates(), + "organizations": db.list_organizations(), + "notifications": db.list_notifications(), + "activity": db.list_activity(), + "active_project": projects[0] if projects else None, + "current_user": current_user(request), + "metrics": db.admin_metrics(), + } + return templates.TemplateResponse(request, "dashboard.html", context) + + @app.get("/projects/{project_id}", response_class=HTMLResponse) + def project_page(project_id: str, request: Request) -> HTMLResponse: + try: + project = db.get_project(project_id) + except KeyError as exc: + raise HTTPException(status_code=404, detail="Project not found") from exc + ensure_project_access(request, project_id, "viewer") + context = { + "request": request, + "project": project, + "templates": db.list_templates(), + "activity": db.list_activity(project_id), + "current_user": current_user(request), + } + return templates.TemplateResponse(request, "project.html", context) + + @app.get("/api/projects") + def api_projects() -> list[dict[str, Any]]: + return db.list_projects() + + @app.get("/api/templates") + def api_templates(q: str = "") -> dict[str, Any]: + return {"results": db.search_templates(q)} + + @app.get("/api/templates/{slug}") + def api_template_preview(slug: str) -> dict[str, Any]: + try: + return db.get_template(slug) + except KeyError as exc: + raise HTTPException(status_code=404, detail="Template not found") from exc + + @app.post("/api/projects") + def api_create_project(payload: ProjectCreate, request: Request) -> dict[str, Any]: + user = current_user(request) + owner = user["email"] if user else payload.owner + return db.create_project(payload.name, payload.template, owner, payload.description, payload.visibility) + + @app.post("/api/projects/from-template") + def api_create_project_from_template(payload: ProjectCreate, request: Request) -> dict[str, Any]: + return api_create_project(payload, request) + + @app.post("/api/projects/{project_id}/files") + def api_create_file(project_id: str, payload: FileCreate, request: Request) -> dict[str, Any]: + try: + db.get_project(project_id) + except KeyError as exc: + raise HTTPException(status_code=404, detail="Project not found") from exc + ensure_project_access(request, project_id, "editor") + return db.create_file(project_id, payload.path, payload.content) + + @app.get("/api/projects/{project_id}/files") + def api_list_files(project_id: str, request: Request) -> list[dict[str, Any]]: + try: + db.get_project(project_id) + except KeyError as exc: + raise HTTPException(status_code=404, detail="Project not found") from exc + ensure_project_access(request, project_id, "viewer") + return db.list_project_files(project_id) + + @app.put("/api/files/{file_id}") + def api_update_file(file_id: str, payload: FileUpdate, request: Request) -> dict[str, Any]: + file_record = db.get_file(file_id) + ensure_project_access(request, file_record["project_id"], "editor") + return db.update_file(file_id, payload.content) + + @app.patch("/api/files/{file_id}/move") + def api_move_file(file_id: str, payload: FileMoveRequest, request: Request) -> dict[str, Any]: + file_record = db.get_file(file_id) + ensure_project_access(request, file_record["project_id"], "editor") + return db.move_file(file_id, payload.path) + + @app.delete("/api/files/{file_id}") + def api_delete_file(file_id: str, request: Request) -> dict[str, Any]: + file_record = db.get_file(file_id) + ensure_project_access(request, file_record["project_id"], "editor") + return db.delete_file(file_id) + + @app.post("/api/projects/{project_id}/clone") + def api_clone_project(project_id: str, request: Request) -> dict[str, Any]: + ensure_project_access(request, project_id, "viewer") + return db.clone_project(project_id) + + @app.post("/api/projects/{project_id}/archive") + def api_archive_project(project_id: str, request: Request) -> dict[str, Any]: + ensure_project_access(request, project_id, "owner") + return db.archive_project(project_id) + + @app.delete("/api/projects/{project_id}") + def api_delete_project(project_id: str, request: Request) -> dict[str, Any]: + ensure_project_access(request, project_id, "owner") + return db.delete_project(project_id) + + @app.post("/api/projects/{project_id}/compile") + def api_compile(project_id: str, payload: CompileRequest, request: Request) -> dict[str, Any]: + ensure_project_access(request, project_id, "editor") + return compile_service.compile_project(project_id, payload.engine, payload.entrypoint, payload.trigger) + + @app.get("/api/projects/{project_id}/jobs/{job_id}") + def api_get_job(project_id: str, job_id: str, request: Request) -> dict[str, Any]: + ensure_project_access(request, project_id, "viewer") + return db.get_compile_job(project_id, job_id) + + @app.get("/api/projects/{project_id}/export.zip") + def api_export(project_id: str, request: Request) -> Response: + ensure_project_access(request, project_id, "viewer") + data = compile_service.export_project_zip(project_id) + return Response(data, media_type="application/zip", headers={"Content-Disposition": f"attachment; filename={project_id}.zip"}) + + @app.post("/api/projects/{project_id}/snapshots") + def api_snapshot(project_id: str, payload: SnapshotRequest, request: Request) -> dict[str, Any]: + ensure_project_access(request, project_id, "editor") + return db.create_snapshot(project_id, payload.file_id, payload.name) + + @app.post("/api/projects/{project_id}/snapshots/{snapshot_id}/restore") + def api_restore_snapshot(project_id: str, snapshot_id: str, request: Request) -> dict[str, Any]: + ensure_project_access(request, project_id, "editor") + return db.restore_snapshot(snapshot_id) + + @app.get("/api/projects/{project_id}/diff") + def api_diff(project_id: str, from_snapshot: str, to_snapshot: str, request: Request) -> dict[str, str]: + ensure_project_access(request, project_id, "viewer") + source = db.get_snapshot(from_snapshot) + target = db.get_snapshot(to_snapshot) + return {"diff": build_diff(source["content"], target["content"])} + + @app.post("/api/projects/{project_id}/comments") + def api_comment(project_id: str, payload: CommentRequest, request: Request) -> dict[str, Any]: + ensure_project_access(request, project_id, "editor") + return db.create_comment(project_id, payload.file_id, payload.author, payload.body, payload.line_from, payload.line_to, payload.parent_id) + + @app.get("/api/projects/{project_id}/comments") + def api_list_comments(project_id: str, request: Request) -> dict[str, Any]: + ensure_project_access(request, project_id, "viewer") + return {"results": db.list_comment_threads(project_id)} + + @app.post("/api/projects/{project_id}/comments/{comment_id}/resolve") + def api_resolve_comment(project_id: str, comment_id: str, request: Request) -> dict[str, Any]: + ensure_project_access(request, project_id, "editor") + return db.set_comment_resolved(comment_id, True) + + @app.post("/api/projects/{project_id}/comments/{comment_id}/unresolve") + def api_unresolve_comment(project_id: str, comment_id: str, request: Request) -> dict[str, Any]: + ensure_project_access(request, project_id, "editor") + return db.set_comment_resolved(comment_id, False) + + @app.get("/api/search") + def api_search(q: str) -> dict[str, Any]: + return {"results": db.search(q)} + + @app.post("/api/projects/{project_id}/ai/assist") + def api_ai(project_id: str, payload: AIRequest, request: Request) -> dict[str, str]: + ensure_project_access(request, project_id, "editor") + return ai_assist(payload.prompt, payload.mode) + + @app.post("/api/projects/{project_id}/share-links") + def api_share_link(project_id: str, payload: ShareLinkRequest, request: Request) -> dict[str, Any]: + ensure_project_access(request, project_id, "owner") + return db.create_share_link(project_id, payload.role, payload.expires_in_days) + + @app.post("/api/share/{share_id}/accept") + def api_accept_share(share_id: str, request: Request) -> dict[str, Any]: + user = require_login(request) + return db.accept_share_link(share_id, user["id"]) + + @app.get("/api/projects/{project_id}/members") + def api_members(project_id: str, request: Request) -> list[dict[str, Any]]: + ensure_project_access(request, project_id, "viewer") + return db.list_project_members(project_id) + + @app.post("/api/projects/{project_id}/suggestions") + def api_suggestion(project_id: str, payload: SuggestionRequest, request: Request) -> dict[str, Any]: + ensure_project_access(request, project_id, "editor") + return db.create_suggestion(project_id, payload.file_id, payload.author, payload.body, payload.original_text, payload.suggested_text) + + @app.post("/api/projects/{project_id}/suggestions/{suggestion_id}/accept") + def api_accept_suggestion(project_id: str, suggestion_id: str, request: Request) -> dict[str, Any]: + ensure_project_access(request, project_id, "editor") + return db.accept_suggestion(suggestion_id) + + @app.post("/api/projects/{project_id}/references/import") + def api_import_reference(project_id: str, payload: ReferenceImportRequest, request: Request) -> dict[str, Any]: + ensure_project_access(request, project_id, "editor") + return db.import_reference(project_id, payload.source, payload.identifier) + + @app.get("/api/projects/{project_id}/citations") + def api_citations(project_id: str, q: str, request: Request) -> dict[str, Any]: + ensure_project_access(request, project_id, "viewer") + return {"results": db.search_references(project_id, q)} + + @app.post("/api/projects/{project_id}/branches") + def api_create_branch(project_id: str, payload: BranchRequest, request: Request) -> dict[str, Any]: + ensure_project_access(request, project_id, "editor") + return db.create_branch(project_id, payload.snapshot_id, payload.name) + + @app.get("/api/projects/{project_id}/branches") + def api_list_branches(project_id: str, request: Request) -> dict[str, Any]: + ensure_project_access(request, project_id, "viewer") + return {"results": db.list_branches(project_id)} + + @app.post("/api/projects/{project_id}/branches/{branch_id}/restore") + def api_restore_branch(project_id: str, branch_id: str, request: Request) -> dict[str, Any]: + ensure_project_access(request, project_id, "editor") + return db.restore_branch(branch_id) + + @app.get("/api/admin/metrics") + def api_admin_metrics(request: Request) -> dict[str, Any]: + require_login(request) + return db.admin_metrics() + + @app.get("/artifacts/{project_id}/{filename}") + def artifact(project_id: str, filename: str, request: Request) -> FileResponse: + ensure_project_access(request, project_id, "viewer") + target = db.artifact_root / project_id / filename + if not target.exists(): + raise HTTPException(status_code=404, detail="Artifact not found") + media_type = "application/pdf" if target.suffix == ".pdf" else "text/plain" + return FileResponse(target, media_type=media_type) + + @app.websocket("/ws/projects/{project_id}") + async def ws_project(project_id: str, websocket: WebSocket, user: str = "anonymous") -> None: + await hub.connect(project_id, user, websocket) + try: + while True: + message = await websocket.receive_json() + if message.get("type") == "edit": + try: + db.create_or_update_file(project_id, message["path"], message["content"]) + except Exception: + pass + await hub.broadcast(project_id, {"type": "edit", "user": user, "path": message["path"], "content": message["content"]}, exclude=user) + elif message.get("type") == "cursor": + payload = { + "type": "cursor", + "user": user, + "path": message.get("path", "main.tex"), + "line": message.get("line", 1), + "column": message.get("column", 1), + } + await hub.broadcast(project_id, payload, exclude=user) + elif message.get("type") == "comment": + await hub.broadcast(project_id, {"type": "comment", "user": user, "body": message.get("body", "")}, exclude=user) + except WebSocketDisconnect: + await hub.disconnect(project_id, user) + + return app diff --git a/deliverable/texforge/texforge/db.py b/deliverable/texforge/texforge/db.py new file mode 100644 index 000000000..98b43c749 --- /dev/null +++ b/deliverable/texforge/texforge/db.py @@ -0,0 +1,962 @@ +from __future__ import annotations + +import json +import hashlib +import sqlite3 +import uuid +from datetime import UTC, datetime, timedelta +from pathlib import Path +from typing import Any + + +class Database: + def __init__(self, path: Path) -> None: + self.path = Path(path) + self.path.parent.mkdir(parents=True, exist_ok=True) + self.artifact_root = self.path.parent / "artifacts" + self.artifact_root.mkdir(parents=True, exist_ok=True) + self.setup() + self.seed() + + def _connect(self) -> sqlite3.Connection: + connection = sqlite3.connect(self.path) + connection.row_factory = sqlite3.Row + return connection + + def setup(self) -> None: + with self._connect() as conn: + conn.executescript( + """ + CREATE TABLE IF NOT EXISTS templates ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE, + category TEXT NOT NULL, + description TEXT NOT NULL, + tags TEXT NOT NULL, + main_tex TEXT NOT NULL, + refs_bib TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS organizations ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT NOT NULL, + created_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'user', + created_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + created_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS projects ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + template TEXT NOT NULL, + owner TEXT NOT NULL, + description TEXT NOT NULL, + visibility TEXT NOT NULL, + org_id TEXT, + archived INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS files ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + path TEXT NOT NULL, + content TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(project_id, path) + ); + + CREATE TABLE IF NOT EXISTS comments ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + file_id TEXT NOT NULL, + parent_id TEXT, + author TEXT NOT NULL, + body TEXT NOT NULL, + line_from INTEGER NOT NULL, + line_to INTEGER NOT NULL, + resolved INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS snapshots ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + file_id TEXT NOT NULL, + name TEXT NOT NULL, + content TEXT NOT NULL, + created_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS compile_jobs ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + engine TEXT NOT NULL, + entrypoint TEXT NOT NULL, + trigger TEXT NOT NULL, + status TEXT NOT NULL, + log TEXT NOT NULL, + pdf_path TEXT NOT NULL, + created_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS activity ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id TEXT, + actor TEXT NOT NULL, + verb TEXT NOT NULL, + target TEXT NOT NULL, + created_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS notifications ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + body TEXT NOT NULL, + level TEXT NOT NULL, + created_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS share_links ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + role TEXT NOT NULL, + expires_at TEXT NOT NULL, + url TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS project_memberships ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + user_id TEXT NOT NULL, + role TEXT NOT NULL, + created_at TEXT NOT NULL, + UNIQUE(project_id, user_id) + ); + + CREATE TABLE IF NOT EXISTS suggestions ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + file_id TEXT NOT NULL, + author TEXT NOT NULL, + body TEXT NOT NULL, + original_text TEXT NOT NULL, + suggested_text TEXT NOT NULL, + status TEXT NOT NULL, + created_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS references_library ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + source TEXT NOT NULL, + identifier TEXT NOT NULL, + citation_key TEXT NOT NULL, + title TEXT NOT NULL, + authors TEXT NOT NULL, + year TEXT NOT NULL, + raw_json TEXT NOT NULL, + created_at TEXT NOT NULL, + UNIQUE(project_id, source, identifier) + ); + + CREATE TABLE IF NOT EXISTS branches ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + snapshot_id TEXT NOT NULL, + name TEXT NOT NULL, + created_at TEXT NOT NULL + ); + """ + ) + + def seed(self) -> None: + with self._connect() as conn: + template_count = conn.execute("SELECT COUNT(*) FROM templates").fetchone()[0] + if template_count == 0: + templates = [ + { + "id": self._id("tpl"), + "name": "IEEE Conference", + "slug": "ieee", + "category": "Journal", + "description": "Two-column conference paper with bibliography and figure scaffold.", + "tags": json.dumps(["ieee", "conference", "paper"]), + "main_tex": "\\documentclass{article}\n\\begin{document}\n\\title{IEEE Paper}\n\\maketitle\n\\section{Introduction}\nWrite here.\\cite{smith2024}\n\\end{document}\n", + "refs_bib": "@article{smith2024,\n title={Sample Reference},\n author={Smith, Ada},\n journal={Journal of Examples},\n year={2024}\n}\n", + }, + { + "id": self._id("tpl"), + "name": "ACM Article", + "slug": "acm", + "category": "Journal", + "description": "ACM submission starter with abstract and CCS concepts.", + "tags": json.dumps(["acm", "article"]), + "main_tex": "\\documentclass{article}\n\\begin{document}\n\\title{ACM Draft}\n\\maketitle\n\\begin{abstract}\nAbstract here.\n\\end{abstract}\n\\section{Method}\n\\end{document}\n", + "refs_bib": "@inproceedings{lee2025,\n title={Collaborative Editing at Scale},\n author={Lee, Robin},\n booktitle={ACM Example},\n year={2025}\n}\n", + }, + { + "id": self._id("tpl"), + "name": "Research Thesis", + "slug": "thesis", + "category": "Thesis", + "description": "Multi-file thesis layout with chapters and front matter.", + "tags": json.dumps(["thesis", "phd"]), + "main_tex": "\\documentclass{report}\n\\begin{document}\n\\title{Thesis}\n\\maketitle\n\\chapter{Overview}\n\\input{chapters/ch1.tex}\n\\end{document}\n", + "refs_bib": "@book{doe2023,\n title={Example Thesis Book},\n author={Doe, Jane},\n year={2023}\n}\n", + }, + { + "id": self._id("tpl"), + "name": "Academic Resume", + "slug": "resume", + "category": "CV", + "description": "One-page resume with publications and teaching sections.", + "tags": json.dumps(["resume", "cv"]), + "main_tex": "\\documentclass{article}\n\\begin{document}\n\\section*{Experience}\n\\section*{Publications}\n\\end{document}\n", + "refs_bib": "", + }, + ] + conn.executemany( + "INSERT INTO templates (id, name, slug, category, description, tags, main_tex, refs_bib) VALUES (:id, :name, :slug, :category, :description, :tags, :main_tex, :refs_bib)", + templates, + ) + + if conn.execute("SELECT COUNT(*) FROM organizations").fetchone()[0] == 0: + conn.execute( + "INSERT INTO organizations (id, name, description, created_at) VALUES (?, ?, ?, ?)", + (self._id("org"), "TexForge Research Lab", "Shared workspace for papers, reviews, and journal submissions.", self.now()), + ) + + if conn.execute("SELECT COUNT(*) FROM notifications").fetchone()[0] == 0: + notifications = [ + (self._id("note"), "Compile queue healthy", "All worker lanes are available for fast feedback.", "success", self.now()), + (self._id("note"), "Review mention", "@alice requested changes on Quantum Notes.", "info", self.now()), + ] + conn.executemany( + "INSERT INTO notifications (id, title, body, level, created_at) VALUES (?, ?, ?, ?, ?)", + notifications, + ) + conn.commit() + + with self._connect() as conn: + if conn.execute("SELECT COUNT(*) FROM projects").fetchone()[0] == 0: + org_id = conn.execute("SELECT id FROM organizations LIMIT 1").fetchone()["id"] + else: + org_id = None + if org_id: + project = self.create_project( + name="Quantum Notes", + template="ieee", + owner="alice@example.com", + description="Shared manuscript for a collaborative quantum systems paper.", + visibility="private", + org_id=org_id, + ) + files = self.list_project_files(project["id"]) + main_tex = next(f for f in files if f["path"] == "main.tex") + self.create_comment( + project["id"], + main_tex["id"], + "prof@example.com", + "Live collaboration note: tighten the motivation paragraph.", + 1, + 2, + ) + self.create_snapshot(project["id"], main_tex["id"], "Initial draft") + self.record_activity(project["id"], "alice@example.com", "seeded", "Quantum Notes demo project") + + @staticmethod + def now() -> str: + return datetime.now(UTC).isoformat() + + @staticmethod + def _id(prefix: str) -> str: + return f"{prefix}_{uuid.uuid4().hex[:10]}" + + def list_templates(self) -> list[dict[str, Any]]: + with self._connect() as conn: + rows = conn.execute("SELECT * FROM templates ORDER BY name").fetchall() + return [self._row_to_template(r) for r in rows] + + def search_templates(self, query: str = "") -> list[dict[str, Any]]: + if not query.strip(): + return self.list_templates() + like = f"%{query.lower()}%" + with self._connect() as conn: + rows = conn.execute( + """SELECT * FROM templates + WHERE lower(name) LIKE ? OR lower(description) LIKE ? OR lower(tags) LIKE ? + ORDER BY name""", + (like, like, like), + ).fetchall() + return [self._row_to_template(r) for r in rows] + + def get_template(self, slug: str) -> dict[str, Any]: + with self._connect() as conn: + row = conn.execute("SELECT * FROM templates WHERE slug = ?", (slug,)).fetchone() + if row is None: + raise KeyError(f"Unknown template: {slug}") + return self._row_to_template(row) + + def list_organizations(self) -> list[dict[str, Any]]: + with self._connect() as conn: + rows = conn.execute("SELECT * FROM organizations ORDER BY name").fetchall() + return [dict(r) for r in rows] + + def list_notifications(self) -> list[dict[str, Any]]: + with self._connect() as conn: + rows = conn.execute("SELECT * FROM notifications ORDER BY created_at DESC").fetchall() + return [dict(r) for r in rows] + + def create_user(self, email: str, password: str, name: str, role: str = "user") -> dict[str, Any]: + user = { + "id": self._id("usr"), + "email": email.lower(), + "name": name, + "password_hash": self._hash_password(password), + "role": role, + "created_at": self.now(), + } + with self._connect() as conn: + conn.execute( + "INSERT INTO users (id, email, name, password_hash, role, created_at) VALUES (:id, :email, :name, :password_hash, :role, :created_at)", + user, + ) + safe_user = self.get_user_by_email(email) + self.record_activity(None, email.lower(), "registered", "user account") + return safe_user + + def get_user_by_email(self, email: str) -> dict[str, Any]: + with self._connect() as conn: + row = conn.execute("SELECT id, email, name, role, created_at FROM users WHERE email = ?", (email.lower(),)).fetchone() + if row is None: + raise KeyError(email) + return dict(row) + + def authenticate_user(self, email: str, password: str) -> dict[str, Any] | None: + with self._connect() as conn: + row = conn.execute("SELECT * FROM users WHERE email = ?", (email.lower(),)).fetchone() + if row is None or row["password_hash"] != self._hash_password(password): + return None + return { + "id": row["id"], + "email": row["email"], + "name": row["name"], + "role": row["role"], + "created_at": row["created_at"], + } + + def create_session(self, user_id: str) -> str: + session_id = self._id("sess") + with self._connect() as conn: + conn.execute( + "INSERT INTO sessions (id, user_id, created_at) VALUES (?, ?, ?)", + (session_id, user_id, self.now()), + ) + return session_id + + def delete_session(self, session_id: str) -> None: + with self._connect() as conn: + conn.execute("DELETE FROM sessions WHERE id = ?", (session_id,)) + + def get_user_by_session(self, session_id: str) -> dict[str, Any] | None: + with self._connect() as conn: + row = conn.execute( + """SELECT users.id, users.email, users.name, users.role, users.created_at + FROM sessions JOIN users ON users.id = sessions.user_id + WHERE sessions.id = ?""", + (session_id,), + ).fetchone() + return dict(row) if row else None + + def list_projects(self) -> list[dict[str, Any]]: + with self._connect() as conn: + rows = conn.execute("SELECT * FROM projects ORDER BY updated_at DESC").fetchall() + return [self._row_to_project(r) for r in rows] + + def get_project(self, project_id: str) -> dict[str, Any]: + with self._connect() as conn: + row = conn.execute("SELECT * FROM projects WHERE id = ?", (project_id,)).fetchone() + if row is None: + raise KeyError(project_id) + return self._row_to_project(row) + + def create_project( + self, + name: str, + template: str, + owner: str, + description: str = "", + visibility: str = "private", + org_id: str | None = None, + ) -> dict[str, Any]: + template_row = self.get_template(template) + project_id = self._id("prj") + now = self.now() + with self._connect() as conn: + conn.execute( + """INSERT INTO projects (id, name, template, owner, description, visibility, org_id, archived, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?)""", + (project_id, name, template, owner, description or template_row["description"], visibility, org_id, now, now), + ) + self.create_or_update_file(project_id, "main.tex", template_row["main_tex"]) + self.create_or_update_file(project_id, "refs.bib", template_row["refs_bib"]) + if template == "thesis": + self.create_or_update_file(project_id, "chapters/ch1.tex", "\\chapter{Introduction}\nThesis chapter placeholder.\n") + self.create_share_link(project_id, "editor") + try: + owner_user = self.get_user_by_email(owner) + except KeyError: + owner_user = None + if owner_user: + self.add_project_member(project_id, owner_user["id"], "owner") + self.record_activity(project_id, owner, "created", name) + return self.get_project(project_id) + + def create_share_link(self, project_id: str, role: str, expires_in_days: int = 14) -> dict[str, Any]: + share_id = self._id("share") + expires = (datetime.now(UTC) + timedelta(days=expires_in_days)).isoformat() + url = f"/share/{share_id}" + with self._connect() as conn: + conn.execute( + "INSERT INTO share_links (id, project_id, role, expires_at, url) VALUES (?, ?, ?, ?, ?)", + (share_id, project_id, role, expires, url), + ) + return {"id": share_id, "project_id": project_id, "role": role, "expires_at": expires, "url": url} + + def get_share_link(self, share_id: str) -> dict[str, Any]: + with self._connect() as conn: + row = conn.execute("SELECT * FROM share_links WHERE id = ?", (share_id,)).fetchone() + if row is None: + raise KeyError(share_id) + return dict(row) + + def accept_share_link(self, share_id: str, user_id: str) -> dict[str, Any]: + share = self.get_share_link(share_id) + self.add_project_member(share["project_id"], user_id, share["role"]) + user = self.get_user_by_id(user_id) + self.record_activity(share["project_id"], user["email"], "joined", share["role"]) + return {"project_id": share["project_id"], "user_id": user_id, "role": share["role"], "share_id": share_id} + + def list_share_links(self, project_id: str) -> list[dict[str, Any]]: + with self._connect() as conn: + rows = conn.execute("SELECT * FROM share_links WHERE project_id = ? ORDER BY expires_at DESC", (project_id,)).fetchall() + return [dict(r) for r in rows] + + def add_project_member(self, project_id: str, user_id: str, role: str) -> dict[str, Any]: + membership = { + "id": self._id("mbr"), + "project_id": project_id, + "user_id": user_id, + "role": role, + "created_at": self.now(), + } + with self._connect() as conn: + conn.execute( + """INSERT INTO project_memberships (id, project_id, user_id, role, created_at) + VALUES (:id, :project_id, :user_id, :role, :created_at) + ON CONFLICT(project_id, user_id) DO UPDATE SET role = excluded.role""", + membership, + ) + return self.get_project_member(project_id, user_id) + + def get_user_by_id(self, user_id: str) -> dict[str, Any]: + with self._connect() as conn: + row = conn.execute("SELECT id, email, name, role, created_at FROM users WHERE id = ?", (user_id,)).fetchone() + if row is None: + raise KeyError(user_id) + return dict(row) + + def get_project_member(self, project_id: str, user_id: str) -> dict[str, Any] | None: + with self._connect() as conn: + row = conn.execute( + """SELECT project_memberships.*, users.email, users.name + FROM project_memberships JOIN users ON users.id = project_memberships.user_id + WHERE project_memberships.project_id = ? AND project_memberships.user_id = ?""", + (project_id, user_id), + ).fetchone() + return dict(row) if row else None + + def list_project_members(self, project_id: str) -> list[dict[str, Any]]: + with self._connect() as conn: + rows = conn.execute( + """SELECT project_memberships.*, users.email, users.name + FROM project_memberships JOIN users ON users.id = project_memberships.user_id + WHERE project_memberships.project_id = ? + ORDER BY CASE project_memberships.role WHEN 'owner' THEN 0 WHEN 'editor' THEN 1 ELSE 2 END, users.email""", + (project_id,), + ).fetchall() + return [dict(row) for row in rows] + + def create_or_update_file(self, project_id: str, path: str, content: str) -> dict[str, Any]: + with self._connect() as conn: + existing = conn.execute("SELECT id FROM files WHERE project_id = ? AND path = ?", (project_id, path)).fetchone() + now = self.now() + if existing: + conn.execute("UPDATE files SET content = ?, updated_at = ? WHERE id = ?", (content, now, existing["id"])) + file_id = existing["id"] + else: + file_id = self._id("file") + conn.execute( + "INSERT INTO files (id, project_id, path, content, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)", + (file_id, project_id, path, content, now, now), + ) + conn.execute("UPDATE projects SET updated_at = ? WHERE id = ?", (now, project_id)) + return self.get_file(file_id) + + def create_file(self, project_id: str, path: str, content: str) -> dict[str, Any]: + file_record = self.create_or_update_file(project_id, path, content) + self.record_activity(project_id, "system", "file_added", path) + return file_record + + def move_file(self, file_id: str, new_path: str) -> dict[str, Any]: + now = self.now() + with self._connect() as conn: + row = conn.execute("SELECT project_id, path FROM files WHERE id = ?", (file_id,)).fetchone() + if row is None: + raise KeyError(file_id) + conn.execute("UPDATE files SET path = ?, updated_at = ? WHERE id = ?", (new_path, now, file_id)) + conn.execute("UPDATE projects SET updated_at = ? WHERE id = ?", (now, row["project_id"])) + self.record_activity(row["project_id"], "system", "file_moved", f"{row['path']} -> {new_path}") + return self.get_file(file_id) + + def delete_file(self, file_id: str) -> dict[str, Any]: + file_record = self.get_file(file_id) + with self._connect() as conn: + conn.execute("DELETE FROM comments WHERE file_id = ?", (file_id,)) + conn.execute("DELETE FROM snapshots WHERE file_id = ?", (file_id,)) + conn.execute("DELETE FROM suggestions WHERE file_id = ?", (file_id,)) + conn.execute("DELETE FROM files WHERE id = ?", (file_id,)) + conn.execute("UPDATE projects SET updated_at = ? WHERE id = ?", (self.now(), file_record["project_id"])) + self.record_activity(file_record["project_id"], "system", "file_deleted", file_record["path"]) + return {"deleted": True, "file_id": file_id, "path": file_record["path"]} + + def list_project_files(self, project_id: str) -> list[dict[str, Any]]: + with self._connect() as conn: + rows = conn.execute("SELECT * FROM files WHERE project_id = ? ORDER BY path", (project_id,)).fetchall() + return [dict(r) for r in rows] + + def get_file(self, file_id: str) -> dict[str, Any]: + with self._connect() as conn: + row = conn.execute("SELECT * FROM files WHERE id = ?", (file_id,)).fetchone() + if row is None: + raise KeyError(file_id) + return dict(row) + + def update_file(self, file_id: str, content: str) -> dict[str, Any]: + now = self.now() + with self._connect() as conn: + row = conn.execute("SELECT project_id, path FROM files WHERE id = ?", (file_id,)).fetchone() + if row is None: + raise KeyError(file_id) + conn.execute("UPDATE files SET content = ?, updated_at = ? WHERE id = ?", (content, now, file_id)) + conn.execute("UPDATE projects SET updated_at = ? WHERE id = ?", (now, row["project_id"])) + self.record_activity(row["project_id"], "system", "file_updated", row["path"]) + return self.get_file(file_id) + + def clone_project(self, project_id: str) -> dict[str, Any]: + project = self.get_project(project_id) + clone = self.create_project( + name=f"{project['name']} (Clone {datetime.now(UTC).strftime('%H:%M')})", + template=project["template"], + owner=project["owner"], + description=project["description"], + visibility=project["visibility"], + org_id=project.get("org_id"), + ) + for file in self.list_project_files(project_id): + self.create_or_update_file(clone["id"], file["path"], file["content"]) + self.record_activity(clone["id"], project["owner"], "cloned_from", project["name"]) + return clone + + def archive_project(self, project_id: str) -> dict[str, Any]: + with self._connect() as conn: + conn.execute("UPDATE projects SET archived = 1, updated_at = ? WHERE id = ?", (self.now(), project_id)) + self.record_activity(project_id, "system", "archived", project_id) + return self.get_project(project_id) + + def delete_project(self, project_id: str) -> dict[str, Any]: + project = self.get_project(project_id) + with self._connect() as conn: + conn.execute("DELETE FROM branches WHERE project_id = ?", (project_id,)) + conn.execute("DELETE FROM compile_jobs WHERE project_id = ?", (project_id,)) + conn.execute("DELETE FROM comments WHERE project_id = ?", (project_id,)) + conn.execute("DELETE FROM snapshots WHERE project_id = ?", (project_id,)) + conn.execute("DELETE FROM share_links WHERE project_id = ?", (project_id,)) + conn.execute("DELETE FROM project_memberships WHERE project_id = ?", (project_id,)) + conn.execute("DELETE FROM suggestions WHERE project_id = ?", (project_id,)) + conn.execute("DELETE FROM references_library WHERE project_id = ?", (project_id,)) + conn.execute("DELETE FROM activity WHERE project_id = ?", (project_id,)) + conn.execute("DELETE FROM files WHERE project_id = ?", (project_id,)) + conn.execute("DELETE FROM projects WHERE id = ?", (project_id,)) + artifact_dir = self.artifact_root / project_id + if artifact_dir.exists(): + for child in artifact_dir.iterdir(): + child.unlink() + artifact_dir.rmdir() + return {"deleted": True, "project_id": project_id, "name": project["name"]} + + def create_comment( + self, + project_id: str, + file_id: str, + author: str, + body: str, + line_from: int, + line_to: int, + parent_id: str | None = None, + ) -> dict[str, Any]: + comment = { + "id": self._id("cmt"), + "project_id": project_id, + "file_id": file_id, + "parent_id": parent_id, + "author": author, + "body": body, + "line_from": line_from, + "line_to": line_to, + "resolved": 0, + "created_at": self.now(), + } + with self._connect() as conn: + conn.execute( + """INSERT INTO comments (id, project_id, file_id, parent_id, author, body, line_from, line_to, resolved, created_at) + VALUES (:id, :project_id, :file_id, :parent_id, :author, :body, :line_from, :line_to, :resolved, :created_at)""", + comment, + ) + self.record_activity(project_id, author, "commented", body[:60]) + return comment + + def list_comments(self, project_id: str) -> list[dict[str, Any]]: + with self._connect() as conn: + rows = conn.execute("SELECT * FROM comments WHERE project_id = ? ORDER BY created_at DESC", (project_id,)).fetchall() + return [dict(r) for r in rows] + + def list_comment_threads(self, project_id: str) -> list[dict[str, Any]]: + comments = self.list_comments(project_id) + by_parent: dict[str, list[dict[str, Any]]] = {} + roots: list[dict[str, Any]] = [] + for comment in comments: + comment["resolved"] = bool(comment["resolved"]) + comment["replies"] = [] + comment["reply_count"] = 0 + parent_id = comment.get("parent_id") + if parent_id: + by_parent.setdefault(parent_id, []).append(comment) + else: + roots.append(comment) + for root in roots: + replies = list(reversed(by_parent.get(root["id"], []))) + root["replies"] = replies + root["reply_count"] = len(replies) + return roots + + def get_comment(self, comment_id: str) -> dict[str, Any]: + with self._connect() as conn: + row = conn.execute("SELECT * FROM comments WHERE id = ?", (comment_id,)).fetchone() + if row is None: + raise KeyError(comment_id) + comment = dict(row) + comment["resolved"] = bool(comment["resolved"]) + return comment + + def set_comment_resolved(self, comment_id: str, resolved: bool) -> dict[str, Any]: + comment = self.get_comment(comment_id) + with self._connect() as conn: + conn.execute("UPDATE comments SET resolved = ? WHERE id = ?", (1 if resolved else 0, comment_id)) + verb = "resolved_comment" if resolved else "reopened_comment" + self.record_activity(comment["project_id"], comment["author"], verb, comment["body"][:60]) + return self.get_comment(comment_id) + + def create_snapshot(self, project_id: str, file_id: str, name: str) -> dict[str, Any]: + file_record = self.get_file(file_id) + snapshot = { + "id": self._id("snap"), + "project_id": project_id, + "file_id": file_id, + "name": name, + "content": file_record["content"], + "created_at": self.now(), + } + with self._connect() as conn: + conn.execute( + "INSERT INTO snapshots (id, project_id, file_id, name, content, created_at) VALUES (:id, :project_id, :file_id, :name, :content, :created_at)", + snapshot, + ) + self.record_activity(project_id, "system", "snapshot", name) + return snapshot + + def restore_snapshot(self, snapshot_id: str) -> dict[str, Any]: + snapshot = self.get_snapshot(snapshot_id) + restored = self.update_file(snapshot["file_id"], snapshot["content"]) + self.record_activity(snapshot["project_id"], "system", "restored_snapshot", snapshot["name"]) + return restored + + def get_snapshot(self, snapshot_id: str) -> dict[str, Any]: + with self._connect() as conn: + row = conn.execute("SELECT * FROM snapshots WHERE id = ?", (snapshot_id,)).fetchone() + if row is None: + raise KeyError(snapshot_id) + return dict(row) + + def list_snapshots(self, project_id: str) -> list[dict[str, Any]]: + with self._connect() as conn: + rows = conn.execute("SELECT * FROM snapshots WHERE project_id = ? ORDER BY created_at DESC", (project_id,)).fetchall() + return [dict(r) for r in rows] + + def create_branch(self, project_id: str, snapshot_id: str, name: str) -> dict[str, Any]: + self.get_project(project_id) + snapshot = self.get_snapshot(snapshot_id) + branch = { + "id": self._id("br"), + "project_id": project_id, + "snapshot_id": snapshot["id"], + "name": name, + "created_at": self.now(), + } + with self._connect() as conn: + conn.execute( + "INSERT INTO branches (id, project_id, snapshot_id, name, created_at) VALUES (:id, :project_id, :snapshot_id, :name, :created_at)", + branch, + ) + self.record_activity(project_id, "system", "branch_created", name) + return branch + + def list_branches(self, project_id: str) -> list[dict[str, Any]]: + with self._connect() as conn: + rows = conn.execute("SELECT * FROM branches WHERE project_id = ? ORDER BY created_at DESC", (project_id,)).fetchall() + return [dict(r) for r in rows] + + def get_branch(self, branch_id: str) -> dict[str, Any]: + with self._connect() as conn: + row = conn.execute("SELECT * FROM branches WHERE id = ?", (branch_id,)).fetchone() + if row is None: + raise KeyError(branch_id) + return dict(row) + + def restore_branch(self, branch_id: str) -> dict[str, Any]: + branch = self.get_branch(branch_id) + restored = self.restore_snapshot(branch["snapshot_id"]) + self.record_activity(branch["project_id"], "system", "restored_branch", branch["name"]) + return restored + + def create_compile_job(self, project_id: str, engine: str, entrypoint: str, trigger: str, log: str, pdf_path: str) -> dict[str, Any]: + job = { + "id": self._id("job"), + "project_id": project_id, + "engine": engine, + "entrypoint": entrypoint, + "trigger": trigger, + "status": "completed", + "log": log, + "pdf_path": pdf_path, + "created_at": self.now(), + } + with self._connect() as conn: + conn.execute( + """INSERT INTO compile_jobs (id, project_id, engine, entrypoint, trigger, status, log, pdf_path, created_at) + VALUES (:id, :project_id, :engine, :entrypoint, :trigger, :status, :log, :pdf_path, :created_at)""", + job, + ) + self.record_activity(project_id, "worker", "compiled", f"{engine}:{entrypoint}") + return self.get_compile_job(project_id, job["id"]) + + def get_compile_job(self, project_id: str, job_id: str) -> dict[str, Any]: + with self._connect() as conn: + row = conn.execute("SELECT * FROM compile_jobs WHERE project_id = ? AND id = ?", (project_id, job_id)).fetchone() + if row is None: + raise KeyError(job_id) + job = dict(row) + job["pdf_url"] = f"/artifacts/{project_id}/{Path(job['pdf_path']).name}" + return job + + def list_activity(self, project_id: str | None = None) -> list[dict[str, Any]]: + with self._connect() as conn: + if project_id: + rows = conn.execute("SELECT * FROM activity WHERE project_id = ? ORDER BY created_at DESC LIMIT 20", (project_id,)).fetchall() + else: + rows = conn.execute("SELECT * FROM activity ORDER BY created_at DESC LIMIT 20").fetchall() + return [dict(r) for r in rows] + + def record_activity(self, project_id: str | None, actor: str, verb: str, target: str) -> None: + with self._connect() as conn: + conn.execute( + "INSERT INTO activity (project_id, actor, verb, target, created_at) VALUES (?, ?, ?, ?, ?)", + (project_id, actor, verb, target, self.now()), + ) + + def create_suggestion(self, project_id: str, file_id: str, author: str, body: str, original_text: str, suggested_text: str) -> dict[str, Any]: + suggestion = { + "id": self._id("sgt"), + "project_id": project_id, + "file_id": file_id, + "author": author, + "body": body, + "original_text": original_text, + "suggested_text": suggested_text, + "status": "open", + "created_at": self.now(), + } + with self._connect() as conn: + conn.execute( + """INSERT INTO suggestions (id, project_id, file_id, author, body, original_text, suggested_text, status, created_at) + VALUES (:id, :project_id, :file_id, :author, :body, :original_text, :suggested_text, :status, :created_at)""", + suggestion, + ) + self.record_activity(project_id, author, "suggested", body[:60]) + return suggestion + + def get_suggestion(self, suggestion_id: str) -> dict[str, Any]: + with self._connect() as conn: + row = conn.execute("SELECT * FROM suggestions WHERE id = ?", (suggestion_id,)).fetchone() + if row is None: + raise KeyError(suggestion_id) + return dict(row) + + def accept_suggestion(self, suggestion_id: str) -> dict[str, Any]: + suggestion = self.get_suggestion(suggestion_id) + file_record = self.get_file(suggestion["file_id"]) + if suggestion["original_text"] and suggestion["original_text"] in file_record["content"]: + updated_content = file_record["content"].replace(suggestion["original_text"], suggestion["suggested_text"], 1) + else: + updated_content = file_record["content"] + "\n" + suggestion["suggested_text"] + updated_file = self.update_file(file_record["id"], updated_content) + with self._connect() as conn: + conn.execute("UPDATE suggestions SET status = 'accepted' WHERE id = ?", (suggestion_id,)) + self.record_activity(suggestion["project_id"], suggestion["author"], "accepted_suggestion", suggestion["body"][:60]) + return {"suggestion": self.get_suggestion(suggestion_id), "file": updated_file} + + def import_reference(self, project_id: str, source: str, identifier: str) -> dict[str, Any]: + with self._connect() as conn: + row = conn.execute( + "SELECT * FROM references_library WHERE project_id = ? AND source = ? AND identifier = ?", + (project_id, source, identifier), + ).fetchone() + if row: + result = dict(row) + result["duplicate"] = True + return result + + slug = "".join(ch for ch in identifier.lower() if ch.isalnum())[-10:] or "ref" + title = f"Imported {source.upper()} reference for {identifier}" + citation_key = f"texforge{slug}" + ref = { + "id": self._id("ref"), + "project_id": project_id, + "source": source, + "identifier": identifier, + "citation_key": citation_key, + "title": title, + "authors": "TexForge Research Team", + "year": str(datetime.now(UTC).year), + "raw_json": json.dumps({"source": source, "identifier": identifier, "title": title}), + "created_at": self.now(), + } + with self._connect() as conn: + conn.execute( + """INSERT INTO references_library + (id, project_id, source, identifier, citation_key, title, authors, year, raw_json, created_at) + VALUES (:id, :project_id, :source, :identifier, :citation_key, :title, :authors, :year, :raw_json, :created_at)""", + ref, + ) + bibtex_entry = ( + f"@article{{{citation_key},\n" + f" title={{{title}}},\n" + f" author={{{ref['authors']}}},\n" + f" year={{{ref['year']}}},\n" + f" note={{{identifier}}}\n" + f"}}\n" + ) + refs_file = next((file for file in self.list_project_files(project_id) if file["path"] == "refs.bib"), None) + if refs_file and citation_key not in refs_file["content"]: + updated_bib = refs_file["content"].rstrip() + ("\n\n" if refs_file["content"].strip() else "") + bibtex_entry + self.update_file(refs_file["id"], updated_bib) + self.record_activity(project_id, "system", "imported_reference", identifier) + ref["duplicate"] = False + return ref + + def search_references(self, project_id: str, query: str) -> list[dict[str, Any]]: + like = f"%{query.lower()}%" + with self._connect() as conn: + rows = conn.execute( + """SELECT * FROM references_library + WHERE project_id = ? AND ( + lower(citation_key) LIKE ? OR lower(title) LIKE ? OR lower(authors) LIKE ? OR lower(identifier) LIKE ? + ) + ORDER BY created_at DESC""", + (project_id, like, like, like, like), + ).fetchall() + return [dict(row) for row in rows] + + def admin_metrics(self) -> dict[str, int]: + with self._connect() as conn: + users = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0] + projects = conn.execute("SELECT COUNT(*) FROM projects").fetchone()[0] + memberships = conn.execute("SELECT COUNT(*) FROM project_memberships").fetchone()[0] + compile_jobs = conn.execute("SELECT COUNT(*) FROM compile_jobs").fetchone()[0] + references = conn.execute("SELECT COUNT(*) FROM references_library").fetchone()[0] + return { + "users": users, + "projects": projects, + "memberships": memberships, + "compile_jobs": compile_jobs, + "references": references, + } + + def search(self, query: str) -> list[dict[str, Any]]: + like = f"%{query.lower()}%" + results: list[dict[str, Any]] = [] + with self._connect() as conn: + for row in conn.execute("SELECT id, name, description FROM projects WHERE lower(name) LIKE ? OR lower(description) LIKE ?", (like, like)).fetchall(): + results.append({"kind": "project", "id": row["id"], "label": row["name"], "snippet": row["description"]}) + for row in conn.execute("SELECT id, path, content FROM files WHERE lower(path) LIKE ? OR lower(content) LIKE ?", (like, like)).fetchall(): + results.append({"kind": "file", "id": row["id"], "label": row["path"], "snippet": row["content"][:120]}) + for row in conn.execute("SELECT id, body, author FROM comments WHERE lower(body) LIKE ?", (like,)).fetchall(): + results.append({"kind": "comment", "id": row["id"], "label": row["author"], "snippet": row["body"]}) + for row in conn.execute("SELECT id, name, description FROM templates WHERE lower(name) LIKE ? OR lower(description) LIKE ?", (like, like)).fetchall(): + results.append({"kind": "template", "id": row["id"], "label": row["name"], "snippet": row["description"]}) + for row in conn.execute( + "SELECT id, citation_key, title FROM references_library WHERE lower(citation_key) LIKE ? OR lower(title) LIKE ? OR lower(identifier) LIKE ?", + (like, like, like), + ).fetchall(): + results.append({"kind": "reference", "id": row["id"], "label": row["citation_key"], "snippet": row["title"]}) + return results + + def _row_to_project(self, row: sqlite3.Row) -> dict[str, Any]: + project = dict(row) + project["archived"] = bool(project["archived"]) + project["files"] = self.list_project_files(project["id"]) + project["comments"] = self.list_comments(project["id"]) + project["snapshots"] = self.list_snapshots(project["id"]) + project["branches"] = self.list_branches(project["id"]) + project["shares"] = self.list_share_links(project["id"]) + project["members"] = self.list_project_members(project["id"]) + project["references"] = self.search_references(project["id"], "") + return project + + @staticmethod + def _row_to_template(row: sqlite3.Row) -> dict[str, Any]: + template = dict(row) + template["tags"] = json.loads(template["tags"]) + return template + + @staticmethod + def _hash_password(password: str) -> str: + return hashlib.sha256(password.encode("utf-8")).hexdigest() diff --git a/deliverable/texforge/texforge/services.py b/deliverable/texforge/texforge/services.py new file mode 100644 index 000000000..ef6784186 --- /dev/null +++ b/deliverable/texforge/texforge/services.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +import difflib +import io +import zipfile + +from .db import Database + + +class CompileService: + def __init__(self, database: Database) -> None: + self.database = database + + def compile_project(self, project_id: str, engine: str, entrypoint: str, trigger: str) -> dict: + project = self.database.get_project(project_id) + files = self.database.list_project_files(project_id) + entry = next((file for file in files if file["path"] == entrypoint), None) + if entry is None: + entry = files[0] if files else {"path": entrypoint, "content": ""} + warnings = lint_latex(entry["content"]) + project_dir = self.database.artifact_root / project_id + project_dir.mkdir(parents=True, exist_ok=True) + job_name = f"{engine}_{entrypoint.replace('/', '_').replace('.', '_')}" + pdf_path = project_dir / f"{job_name}.pdf" + pdf_path.write_bytes(render_simple_pdf(project["name"], entry["content"])) + log = "\n".join( + [ + f"[worker] trigger={trigger}", + f"[engine] {engine} {entrypoint}", + f"[files] {len(files)} tracked files", + "[status] completed simulated compile pipeline", + "[note] swap CompileService with Dockerized TeX Live worker for real engine execution", + "[lint] " + ("; ".join(warnings) if warnings else "No major issues detected"), + ] + ) + return self.database.create_compile_job(project_id, engine, entrypoint, trigger, log, str(pdf_path)) + + def export_project_zip(self, project_id: str) -> bytes: + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive: + project = self.database.get_project(project_id) + archive.writestr("README.txt", f"Export bundle for {project['name']}\n") + for file in self.database.list_project_files(project_id): + archive.writestr(file["path"], file["content"]) + for share in self.database.list_share_links(project_id): + archive.writestr("sharing/share-links.json", f"{share}\n") + return buffer.getvalue() + + +def lint_latex(content: str) -> list[str]: + warnings: list[str] = [] + if content.count("\\begin{") != content.count("\\end{"): + warnings.append("Environment counts appear unbalanced") + if "\\cite{" in content and "refs.bib" not in content: + warnings.append("Remember to keep bibliography entries in refs.bib") + if "\\ref{" in content and "\\label{" not in content: + warnings.append("Reference detected without nearby label definition") + if len(content.strip()) < 20: + warnings.append("Document is very short; consider expanding sections") + return warnings + + +def render_simple_pdf(title: str, body: str) -> bytes: + clean_lines = [title, "TexForge preview"] + body.splitlines()[:20] + text = " ".join(line.replace("(", "[").replace(")", "]") for line in clean_lines) + stream = f"BT /F1 12 Tf 50 760 Td ({text}) Tj ET" + objects = [ + b"1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n", + b"2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n", + b"3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj\n", + f"4 0 obj << /Length {len(stream)} >> stream\n{stream}\nendstream endobj\n".encode(), + b"5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj\n", + ] + pdf = io.BytesIO() + pdf.write(b"%PDF-1.4\n") + offsets = [0] + for obj in objects: + offsets.append(pdf.tell()) + pdf.write(obj) + xref_start = pdf.tell() + pdf.write(f"xref\n0 {len(objects) + 1}\n".encode()) + pdf.write(b"0000000000 65535 f \n") + for offset in offsets[1:]: + pdf.write(f"{offset:010d} 00000 n \n".encode()) + pdf.write(f"trailer << /Size {len(objects) + 1} /Root 1 0 R >>\nstartxref\n{xref_start}\n%%EOF".encode()) + return pdf.getvalue() + + +def build_diff(source: str, target: str) -> str: + diff = difflib.unified_diff(source.splitlines(), target.splitlines(), fromfile="from", tofile="to", lineterm="") + return "\n".join(diff) + + +def ai_assist(prompt: str, mode: str) -> dict[str, str]: + prompt_lower = prompt.lower() + if "table" in prompt_lower: + suggestion = ( + "\\begin{table}[t]\n" + "\\centering\n" + "\\begin{tabular}{lcc}\n" + "Model & Accuracy & F1 \\\\ \n" + "\\hline\n" + "Baseline & 0.91 & 0.89 \\\\ \n" + "TexForge & 0.95 & 0.94 \\\\ \n" + "\\end{tabular}\n" + "\\caption{Experiment results.}\n" + "\\end{table}\n" + ) + elif "citation" in prompt_lower or "doi" in prompt_lower: + suggestion = "@article{newref2026, title={Generated citation scaffold}, author={Author, Example}, year={2026}}" + elif mode == "fix": + suggestion = "Try adding missing \\end{...} statements, a \\bibliography section, and labels for every referenced figure or section." + else: + suggestion = "\\section{Generated Draft}\nThis paragraph was generated from your prompt and can be refined collaboratively." + summary = "TexForge Copilot generated a deterministic offline suggestion suitable for local demo use." + return {"suggestion": suggestion, "summary": summary} diff --git a/deliverable/texforge/texforge/static/app.js b/deliverable/texforge/texforge/static/app.js new file mode 100644 index 000000000..8bd008a1e --- /dev/null +++ b/deliverable/texforge/texforge/static/app.js @@ -0,0 +1,399 @@ +const setTheme = () => { + const saved = localStorage.getItem('texforge-theme'); + if (saved === 'light') document.body.classList.add('light'); +}; +setTheme(); +document.getElementById('themeToggle')?.addEventListener('click', () => { + document.body.classList.toggle('light'); + localStorage.setItem('texforge-theme', document.body.classList.contains('light') ? 'light' : 'dark'); +}); + +const page = document.body.dataset.page; + +if (page === 'dashboard') { + const form = document.getElementById('projectForm'); + const status = document.getElementById('projectFormStatus'); + const registerForm = document.getElementById('registerForm'); + const loginForm = document.getElementById('loginForm'); + const authStatus = document.getElementById('authStatus'); + const templateSearchForm = document.getElementById('templateSearchForm'); + const templateSearchInput = document.getElementById('templateSearchInput'); + const templatePreview = document.getElementById('templatePreview'); + + const renderTemplates = (templates) => { + document.querySelectorAll('.template-card').forEach((card) => { + const visible = templates.some((tpl) => tpl.slug === card.dataset.templateSlug); + card.style.display = visible ? '' : 'none'; + }); + }; + + document.querySelectorAll('.template-preview-button').forEach((button) => { + button.addEventListener('click', async () => { + const response = await fetch(`/api/templates/${button.dataset.templateSlug}`); + const template = await response.json(); + templatePreview.textContent = `${template.name}\n\nmain.tex\n---------\n${template.main_tex}\n\nrefs.bib\n--------\n${template.refs_bib || '(empty)'}`; + }); + }); + + templateSearchForm?.addEventListener('submit', (event) => event.preventDefault()); + templateSearchInput?.addEventListener('input', async () => { + const response = await fetch(`/api/templates?q=${encodeURIComponent(templateSearchInput.value)}`); + const data = await response.json(); + renderTemplates(data.results); + }); + form?.addEventListener('submit', async (event) => { + event.preventDefault(); + const payload = Object.fromEntries(new FormData(form).entries()); + const response = await fetch('/api/projects', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + const data = await response.json(); + status.textContent = `Created ${data.name}. Redirecting...`; + window.location.href = `/projects/${data.id}`; + }); + + registerForm?.addEventListener('submit', async (event) => { + event.preventDefault(); + const payload = Object.fromEntries(new FormData(registerForm).entries()); + const response = await fetch('/auth/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + authStatus.textContent = response.ok ? 'Registered. You can now log in.' : 'Registration failed.'; + }); + + loginForm?.addEventListener('submit', async (event) => { + event.preventDefault(); + const payload = Object.fromEntries(new FormData(loginForm).entries()); + const response = await fetch('/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + authStatus.textContent = response.ok ? 'Logged in. Refreshing workspace...' : 'Login failed.'; + if (response.ok) window.location.reload(); + }); +} + +if (page === 'project') { + const project = window.__PROJECT__; + const projectId = document.body.dataset.projectId; + const editor = document.getElementById('editor'); + const fileList = document.getElementById('fileList'); + const compileButton = document.getElementById('compileButton'); + const compileLog = document.getElementById('compileLog'); + const pdfFrame = document.getElementById('pdfFrame'); + const highlightPanel = document.getElementById('highlightPanel'); + const presenceStatus = document.getElementById('presenceStatus'); + const lintStatus = document.getElementById('lintStatus'); + const snapshotButton = document.getElementById('snapshotButton'); + const commentForm = document.getElementById('commentForm'); + const commentList = document.getElementById('commentList'); + const fileForm = document.getElementById('fileForm'); + const renameFileButton = document.getElementById('renameFileButton'); + const deleteFileButton = document.getElementById('deleteFileButton'); + const aiForm = document.getElementById('aiForm'); + const aiOutput = document.getElementById('aiOutput'); + const snapshotList = document.getElementById('snapshotList'); + const engineSelect = document.getElementById('engineSelect'); + const shareForm = document.getElementById('shareForm'); + const shareStatus = document.getElementById('shareStatus'); + const referenceForm = document.getElementById('referenceForm'); + const referenceList = document.getElementById('referenceList'); + const suggestionForm = document.getElementById('suggestionForm'); + const suggestionStatus = document.getElementById('suggestionStatus'); + const branchForm = document.getElementById('branchForm'); + const branchList = document.getElementById('branchList'); + const branchSnapshotSelect = document.getElementById('branchSnapshotSelect'); + let fileItems = [...document.querySelectorAll('.file-item')]; + let activeFile = project.files[0]; + let debounce; + + const renderHighlight = (content) => { + highlightPanel.textContent = content; + const begins = (content.match(/\\begin\{/g) || []).length; + const ends = (content.match(/\\end\{/g) || []).length; + lintStatus.textContent = begins === ends ? 'Lint: environments balanced' : 'Lint: check missing \\end{...}'; + }; + + const selectFile = (item) => { + fileItems.forEach((node) => node.classList.remove('active')); + item.classList.add('active'); + activeFile = { id: item.dataset.fileId, path: item.dataset.filePath, content: item.dataset.fileContent }; + editor.value = activeFile.content; + renderHighlight(activeFile.content); + }; + + if (fileItems.length) selectFile(fileItems[0]); + fileItems.forEach((item) => item.addEventListener('click', () => selectFile(item))); + + const attachFileItem = (li) => { + li.addEventListener('click', () => selectFile(li)); + }; + + const refreshComments = async () => { + const response = await fetch(`/api/projects/${projectId}/comments`); + if (!response.ok) return; + const data = await response.json(); + commentList.innerHTML = ''; + data.results.forEach((comment) => { + const li = document.createElement('li'); + li.innerHTML = `${comment.author} — ${comment.body} ${comment.resolved ? 'Resolved' : ''}`; + const toggle = document.createElement('button'); + toggle.type = 'button'; + toggle.className = 'ghost inline-action'; + toggle.textContent = comment.resolved ? 'Unresolve' : 'Resolve'; + toggle.addEventListener('click', async () => { + await fetch(`/api/projects/${projectId}/comments/${comment.id}/${comment.resolved ? 'unresolve' : 'resolve'}`, { method: 'POST' }); + refreshComments(); + }); + li.appendChild(toggle); + if (comment.replies?.length) { + const replies = document.createElement('ul'); + replies.className = 'mini-list nested-list'; + comment.replies.forEach((reply) => { + const replyItem = document.createElement('li'); + replyItem.innerHTML = `${reply.author} — ${reply.body}`; + replies.appendChild(replyItem); + }); + li.appendChild(replies); + } + commentList.appendChild(li); + }); + }; + + const addSnapshotOption = (snapshot) => { + const li = document.createElement('li'); + li.dataset.snapshotId = snapshot.id; + li.textContent = `${snapshot.name} — ${snapshot.created_at}`; + snapshotList.prepend(li); + + const option = document.createElement('option'); + option.value = snapshot.id; + option.textContent = snapshot.name; + branchSnapshotSelect?.prepend(option); + if (branchSnapshotSelect) branchSnapshotSelect.value = snapshot.id; + }; + + const refreshBranches = async () => { + const response = await fetch(`/api/projects/${projectId}/branches`); + if (!response.ok) return; + const data = await response.json(); + branchList.innerHTML = ''; + data.results.forEach((branch) => { + const li = document.createElement('li'); + li.innerHTML = `${branch.name} `; + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'ghost branch-restore-button'; + button.textContent = 'Restore'; + button.addEventListener('click', async () => { + const response = await fetch(`/api/projects/${projectId}/branches/${branch.id}/restore`, { method: 'POST' }); + const file = await response.json(); + if (activeFile?.id === file.id) { + editor.value = file.content; + renderHighlight(file.content); + } + }); + li.appendChild(button); + branchList.appendChild(li); + }); + }; + + const scheme = window.location.protocol === 'https:' ? 'wss' : 'ws'; + const user = `browser-${Math.random().toString(16).slice(2, 8)}`; + const socket = new WebSocket(`${scheme}://${window.location.host}/ws/projects/${projectId}?user=${user}`); + socket.addEventListener('message', (event) => { + const payload = JSON.parse(event.data); + if (payload.type === 'presence') presenceStatus.textContent = `${payload.user} ${payload.status}`; + if (payload.type === 'edit' && payload.path === activeFile?.path) { + editor.value = payload.content; + renderHighlight(payload.content); + presenceStatus.textContent = `${payload.user} updated ${payload.path}`; + } + if (payload.type === 'cursor') presenceStatus.textContent = `${payload.user} is at line ${payload.line}`; + if (payload.type === 'sync') presenceStatus.textContent = `Connected with ${payload.users.length} participant(s)`; + }); + + editor?.addEventListener('input', () => { + renderHighlight(editor.value); + clearTimeout(debounce); + debounce = setTimeout(() => { + socket.send(JSON.stringify({ type: 'edit', path: activeFile.path, content: editor.value })); + }, 200); + }); + + editor?.addEventListener('keyup', () => { + const line = editor.value.slice(0, editor.selectionStart).split('\n').length; + socket.send(JSON.stringify({ type: 'cursor', path: activeFile.path, line, column: 1 })); + }); + + compileButton?.addEventListener('click', async () => { + const response = await fetch(`/api/projects/${projectId}/compile`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ engine: engineSelect.value, entrypoint: activeFile?.path || 'main.tex', trigger: 'manual' }), + }); + const job = await response.json(); + compileLog.textContent = job.log; + pdfFrame.src = job.pdf_url; + }); + + snapshotButton?.addEventListener('click', async () => { + if (!activeFile?.id) return; + const response = await fetch(`/api/projects/${projectId}/snapshots`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ file_id: activeFile.id, name: `Snapshot ${new Date().toLocaleTimeString()}` }), + }); + const snap = await response.json(); + addSnapshotOption(snap); + }); + + commentForm?.addEventListener('submit', async (event) => { + event.preventDefault(); + const formData = Object.fromEntries(new FormData(commentForm).entries()); + const response = await fetch(`/api/projects/${projectId}/comments`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ...formData, file_id: activeFile.id, line_from: 1, line_to: 1 }), + }); + const comment = await response.json(); + await refreshComments(); + commentForm.reset(); + }); + + fileForm?.addEventListener('submit', async (event) => { + event.preventDefault(); + const payload = Object.fromEntries(new FormData(fileForm).entries()); + const response = await fetch(`/api/projects/${projectId}/files`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + const file = await response.json(); + const li = document.createElement('li'); + li.className = 'file-item'; + li.dataset.fileId = file.id; + li.dataset.filePath = file.path; + li.dataset.fileContent = file.content; + li.textContent = file.path; + attachFileItem(li); + fileList.appendChild(li); + fileItems = [...document.querySelectorAll('.file-item')]; + selectFile(li); + fileForm.reset(); + }); + + renameFileButton?.addEventListener('click', async () => { + if (!activeFile?.id) return; + const nextPath = window.prompt('New path for this file', activeFile.path); + if (!nextPath || nextPath === activeFile.path) return; + const response = await fetch(`/api/files/${activeFile.id}/move`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: nextPath }), + }); + const file = await response.json(); + const activeNode = document.querySelector(`.file-item[data-file-id="${file.id}"]`); + if (activeNode) { + activeNode.dataset.filePath = file.path; + activeNode.textContent = file.path; + selectFile(activeNode); + } + }); + + deleteFileButton?.addEventListener('click', async () => { + if (!activeFile?.id) return; + if (!window.confirm(`Delete ${activeFile.path}?`)) return; + const response = await fetch(`/api/files/${activeFile.id}`, { method: 'DELETE' }); + if (!response.ok) return; + const activeNode = document.querySelector(`.file-item[data-file-id="${activeFile.id}"]`); + activeNode?.remove(); + fileItems = [...document.querySelectorAll('.file-item')]; + if (fileItems.length) { + selectFile(fileItems[0]); + } else { + activeFile = null; + editor.value = ''; + } + }); + + aiForm?.addEventListener('submit', async (event) => { + event.preventDefault(); + const payload = Object.fromEntries(new FormData(aiForm).entries()); + const response = await fetch(`/api/projects/${projectId}/ai/assist`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + const data = await response.json(); + aiOutput.textContent = `${data.summary}\n\n${data.suggestion}`; + }); + + shareForm?.addEventListener('submit', async (event) => { + event.preventDefault(); + const payload = Object.fromEntries(new FormData(shareForm).entries()); + const response = await fetch(`/api/projects/${projectId}/share-links`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ...payload, expires_in_days: 14 }), + }); + if (!response.ok) { + shareStatus.textContent = 'Share link creation requires owner access.'; + return; + } + const data = await response.json(); + shareStatus.textContent = `Created ${data.role} link: ${data.url}`; + }); + + referenceForm?.addEventListener('submit', async (event) => { + event.preventDefault(); + const payload = Object.fromEntries(new FormData(referenceForm).entries()); + const response = await fetch(`/api/projects/${projectId}/references/import`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + const data = await response.json(); + const li = document.createElement('li'); + li.innerHTML = `${data.citation_key} — ${data.title}${data.duplicate ? ' (duplicate)' : ''}`; + referenceList.prepend(li); + referenceForm.reset(); + }); + + suggestionForm?.addEventListener('submit', async (event) => { + event.preventDefault(); + const payload = Object.fromEntries(new FormData(suggestionForm).entries()); + const createResponse = await fetch(`/api/projects/${projectId}/suggestions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ...payload, file_id: activeFile.id }), + }); + const suggestion = await createResponse.json(); + const acceptResponse = await fetch(`/api/projects/${projectId}/suggestions/${suggestion.id}/accept`, { method: 'POST' }); + const accepted = await acceptResponse.json(); + editor.value = accepted.file.content; + renderHighlight(accepted.file.content); + suggestionStatus.textContent = `Accepted suggestion from ${suggestion.author}.\n\n${accepted.file.content}`; + }); + + branchForm?.addEventListener('submit', async (event) => { + event.preventDefault(); + const payload = Object.fromEntries(new FormData(branchForm).entries()); + const response = await fetch(`/api/projects/${projectId}/branches`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!response.ok) return; + branchForm.reset(); + refreshBranches(); + }); + + refreshComments(); + refreshBranches(); +} diff --git a/deliverable/texforge/texforge/static/style.css b/deliverable/texforge/texforge/static/style.css new file mode 100644 index 000000000..df4b9e862 --- /dev/null +++ b/deliverable/texforge/texforge/static/style.css @@ -0,0 +1,105 @@ +:root { + --bg: #0b1020; + --panel: #151d33; + --muted: #8ea1c6; + --text: #eef3ff; + --accent: #7c9cff; + --border: rgba(255,255,255,0.08); +} +body.light { + --bg: #f2f5ff; + --panel: #ffffff; + --muted: #5c6d93; + --text: #102040; + --accent: #365ef6; + --border: rgba(16,32,64,0.1); +} +* { box-sizing: border-box; } +body { + margin: 0; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif; + background: radial-gradient(circle at top, rgba(124,156,255,0.18), transparent 35%), var(--bg); + color: var(--text); +} +a { color: inherit; text-decoration: none; } +button, input, textarea, select { + font: inherit; + border-radius: 14px; + border: 1px solid var(--border); + background: rgba(255,255,255,0.03); + color: var(--text); + padding: 0.8rem 1rem; +} +textarea { min-height: 120px; resize: vertical; } +button { cursor: pointer; } +.topbar { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 2rem; + padding: 2rem; +} +.topbar.compact { padding-bottom: 1rem; } +.topbar-actions { display: flex; gap: 0.75rem; align-items: center; } +.eyebrow { text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted); font-size: 0.78rem; } +.lede { max-width: 70ch; color: var(--muted); } +.dashboard-grid, .workspace-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1rem; + padding: 0 2rem 2rem; +} +.panel { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 24px; + padding: 1.25rem; + box-shadow: 0 20px 50px rgba(0,0,0,0.16); +} +.panel-header { display: flex; justify-content: space-between; align-items: center; gap: 1rem; } +.hero-card { min-height: 220px; display: flex; flex-direction: column; justify-content: center; } +.span-2 { grid-column: span 2; } +.primary, button.primary { + background: linear-gradient(135deg, var(--accent), #9f7cff); + color: white; + border: none; + padding: 0.85rem 1.15rem; + border-radius: 14px; +} +.ghost, button.ghost { background: transparent; } +.badge, .tag { + display: inline-flex; + align-items: center; + border: 1px solid var(--border); + border-radius: 999px; + padding: 0.3rem 0.65rem; + color: var(--muted); + font-size: 0.82rem; +} +.project-grid, .template-grid { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); } +.auth-grid { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); } +.project-card, .template-card { border: 1px solid var(--border); border-radius: 18px; padding: 1rem; } +.project-meta, .tag-row, .toolbar { display: flex; gap: 0.5rem; flex-wrap: wrap; } +.compact-toolbar { margin-top: 0.75rem; } +.mini-list, .timeline, .file-list { list-style: none; padding: 0; margin: 0; } +.mini-list li, .timeline li { padding: 0.6rem 0; border-bottom: 1px solid var(--border); } +.stack-form { display: grid; gap: 0.75rem; } +.compact-form { margin-top: 1rem; } +.file-browser { max-height: 84vh; overflow: auto; } +.file-item { padding: 0.75rem; border-radius: 12px; border: 1px solid transparent; cursor: pointer; } +.file-item.active, .file-item:hover { border-color: var(--accent); background: rgba(124,156,255,0.12); } +.split-view { display: grid; grid-template-columns: 1.1fr 0.9fr; gap: 1rem; } +.editor { width: 100%; min-height: 52vh; background: #0c1326; color: #f4f7ff; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } +body.light .editor { background: #f8fbff; color: #102040; } +.editor-footer { display: flex; justify-content: space-between; gap: 1rem; margin-top: 0.5rem; } +.pdf-frame, .highlight-panel, .log-panel { width: 100%; min-height: 250px; border-radius: 18px; border: 1px solid var(--border); background: rgba(255,255,255,0.02); padding: 1rem; } +.highlight-panel { min-height: 180px; white-space: pre-wrap; overflow: auto; } +.log-panel { white-space: pre-wrap; overflow: auto; } +.muted { color: var(--muted); } +.inline-action { margin-left: 0.75rem; padding: 0.35rem 0.7rem; border-radius: 999px; } +.nested-list { margin-top: 0.5rem; margin-left: 1rem; } +@media (max-width: 1100px) { + .dashboard-grid, .workspace-grid, .split-view { grid-template-columns: 1fr; } + .span-2 { grid-column: span 1; } + .topbar { flex-direction: column; } +} diff --git a/deliverable/texforge/texforge/templates/dashboard.html b/deliverable/texforge/texforge/templates/dashboard.html new file mode 100644 index 000000000..cd8d0f27b --- /dev/null +++ b/deliverable/texforge/texforge/templates/dashboard.html @@ -0,0 +1,158 @@ + + + + + + TexForge + + + +
+
+

Collaborative academic writing platform

+

TexForge

+

Production-style collaborative LaTeX workspace with live collaboration, history, compile queue simulation, sharing, and AI-assisted authoring.

+
+
+ {% if current_user %} + Signed in as {{ current_user.name }} + {% else %} + Guest demo mode + {% endif %} + + New Project +
+
+ +
+
+

Live collaboration

+

Cursor presence, awareness, shared editing, compile logs, comments, snapshots, templates, organizations, and admin insights are all available in one local-first stack.

+ {% if active_project %} + Open {{ active_project.name }} + {% endif %} +
+ +
+

Create project

+
+ + + + +
+

+
+ +
+

Authentication

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

{% if current_user %}Authenticated{% else %}Sign in to unlock role-aware collaboration and admin metrics.{% endif %}

+
+ +
+
+

Projects

+ {{ projects|length }} active +
+
+ {% for project in projects %} +
+
+ {{ project.template|upper }} + {% if project.archived %}Archived{% endif %} +
+

{{ project.name }}

+

{{ project.description }}

+
    +
  • {{ project.files|length }} files
  • +
  • {{ project.comments|length }} comments
  • +
  • {{ project.snapshots|length }} snapshots
  • +
+ Open workspace +
+ {% endfor %} +
+
+ +
+
+

Template gallery

+ Marketplace +
+
+ +
+
+ {% for template in templates %} +
+

{{ template.name }}

+

{{ template.description }}

+
+ {% for tag in template.tags %} + {{ tag }} + {% endfor %} +
+ +
+ {% endfor %} +
+
Select a template to preview its main.tex and bibliography scaffold.
+
+ +
+

Organizations

+ +
+ +
+

Notifications

+ +
+ +
+

System metrics

+ +
+ +
+

Activity timeline

+ +
+
+ + + diff --git a/deliverable/texforge/texforge/templates/project.html b/deliverable/texforge/texforge/templates/project.html new file mode 100644 index 000000000..777b18425 --- /dev/null +++ b/deliverable/texforge/texforge/templates/project.html @@ -0,0 +1,202 @@ + + + + + + {{ project.name }} · TexForge + + + +
+
+

TexForge workspace

+

{{ project.name }}

+

{{ project.description }}

+
+
+ {% if current_user %} + {{ current_user.email }} + {% endif %} + Back + +
+
+ +
+
+

Files

{{ project.files|length }}
+ +
+ + +
+
+ + + +
+
+ +
+
+

Editor

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

+        
+
+
+ +
+

Comments & review

+ +
+ + + +
+
+ +
+

History & branching

+ +
+ + + +
+ +

Lightweight branching now persists explicit named branches pinned to snapshots, with one-click restore.

+
+ +
+

Sharing & permissions

+ +
+ + +
+

Owner/editor/viewer workflows are persisted through project memberships and expiring links.

+
+ +
+

Team members

+ +
+ +
+

Compile log

+
No compile yet.
+
+ +
+

Copilot

+
+ + + +
+
No suggestion yet.
+
+ +
+

Reference manager

+ +
+ + + +
+
+ +
+

Suggestion mode

+
+ + + + + +
+
No suggestions yet.
+
+ +
+

Activity timeline

+ +
+
+ + + + diff --git a/exp_config/README.md b/exp_config/README.md index c2fe2ba5e..9ca6bc774 100644 --- a/exp_config/README.md +++ b/exp_config/README.md @@ -15,6 +15,7 @@ uv pip install massgen To set your API key, use the command and select OpenRouter in the “Select Providers” step. This is only required for Kimi and Grok. For Claude Code (subscription-based), simply log in to Claude Code within VS Code. +For Codex (subscription-based), make sure the Codex CLI is installed and authenticated with `codex login`. During setup, just press “Next” for the Docker step since we will pull the Docker image manually, and continue pressing “Next” for all remaining options. @@ -38,13 +39,32 @@ docker pull ghcr.io/massgen/mcp-runtime-sudo:v0.1.79 Navigate to the `MassGen/` directory and copy paste you prompt into `prompt.txt` select the corresponding yaml and execute: ```bash -bash exp_config/run.sh exp_config/claude.yaml exp_config/prompt.txt (for calude) +bash exp_config/run.sh exp_config/claude.yaml exp_config/prompt.txt (for Claude) + +bash exp_config/run.sh exp_config/codex.yaml exp_config/prompt.txt (for Codex) bash exp_config/run.sh exp_config/Grok.yaml exp_config/prompt.txt (for Grok) bash exp_config/run.sh exp_config/kimi.yaml exp_config/prompt.txt (for Kimi) ``` +Reusable task prompts are kept in `exp_config/prompts/`: + +- `collaborative_latex_platform.txt` +- `github_platform.txt` +- `notion_platform.txt` +- `rental_platform_ai_agents.txt` +- `fourier_transform_visualization.txt` +- `sorting_visualization.txt` +- `bayesian_inference_beamer.txt` + +For example: + +```bash +bash exp_config/run.sh exp_config/codex.yaml exp_config/prompts/bayesian_inference_beamer.txt +bash exp_config/run.sh exp_config/codex.yaml exp_config/prompts/sorting_visualization.txt +``` + --- ### 4. Configuration Notes @@ -52,7 +72,7 @@ bash exp_config/run.sh exp_config/kimi.yaml exp_config/prompt.txt (for Kimi) Before running: - Update the configuration file to match your experiment setup -- Copy your prompt into `prompt.txt` +- Copy your prompt into `prompt.txt`, or pass one of the named prompt files in `exp_config/prompts/` ### 5. Running @@ -74,4 +94,4 @@ DURATION: 459.7s This indicates a successful run. -When uploading results, please compress (zip) everything inside the LOG_DIR directory not FINAL_DIR! \ No newline at end of file +When uploading results, please compress (zip) everything inside the LOG_DIR directory not FINAL_DIR! diff --git a/exp_config/codex.yaml b/exp_config/codex.yaml new file mode 100644 index 000000000..cce56ba03 --- /dev/null +++ b/exp_config/codex.yaml @@ -0,0 +1,25 @@ +agent: + id: agent_a + backend: + type: codex + model: gpt-5.4 + cwd: "workspace" + exclude_file_operation_mcps: false + reasoning: + effort: high + summary: auto + +ui: + display_type: "textual_terminal" + logging_enabled: true + +orchestrator: + snapshot_storage: "snapshots" + agent_temporary_workspace: "temp_workspaces" + max_new_answers_per_agent: 3 + +timeout_settings: + orchestrator_timeout_seconds: 10800 + initial_round_timeout_seconds: 3600 + subsequent_round_timeout_seconds: 3600 + round_timeout_grace_seconds: 600 diff --git a/exp_config/prompt.txt b/exp_config/prompt.txt index 3ccb0e795..1309c97d9 100644 --- a/exp_config/prompt.txt +++ b/exp_config/prompt.txt @@ -1,2 +1,8 @@ -please write a snake game for me -using python \ No newline at end of file +TASK: Sorting Visualization + +Description: +Create an animation showing how sorting works. + +Requirements: +- Self-contained SVG file +- Should be educational diff --git a/exp_config/prompts/bayesian_inference_beamer.txt b/exp_config/prompts/bayesian_inference_beamer.txt new file mode 100644 index 000000000..d7a5e14d6 --- /dev/null +++ b/exp_config/prompts/bayesian_inference_beamer.txt @@ -0,0 +1,12 @@ +TASK: Bayesian Inference Beamer PPT + +Description: +You are an expert in Bayesian statistics and LaTeX typesetting. Generate a single self-contained .tex file that compiles to a Beamer presentation explaining Bayesian inference to graduate students in a quantitative field. + +Requirements: +- Use \documentclass{beamer} with a clean theme such as metropolis, Madrid, or Singapore. +- Use amsmath and amssymb for all mathematical notation. +- The content must be fully self-contained. +- Do not use inline images or external image files. +- Create all diagrams using TikZ. +- The final deliverable should be a single .tex file that can compile without requiring additional custom assets. diff --git a/exp_config/prompts/collaborative_latex_platform.txt b/exp_config/prompts/collaborative_latex_platform.txt new file mode 100644 index 000000000..05961966f --- /dev/null +++ b/exp_config/prompts/collaborative_latex_platform.txt @@ -0,0 +1,80 @@ +TASK: Build a Collaborative LaTeX Platform + +Build a full-stack, production-ready collaborative LaTeX editing platform called "TexForge", inspired by Overleaf. The goal is to replicate a complete cloud-based academic writing and publishing workflow with real-time collaboration, version control, and PDF rendering. + +Core Features + +Projects & Files: +- Create/delete/archive projects. +- Support folder structures, multi-file LaTeX projects, file upload/download, drag-and-drop organization, templates (IEEE, ACM, thesis, resume), project cloning, and sharing. + +Editor: +- Rich LaTeX editor with syntax highlighting, auto-completion, linting, error highlighting, split view (code + PDF), forward/inverse search (SyncTeX), customizable themes, and Vim/Emacs keybindings. + +Real-Time Collaboration: +- Multi-user live editing using CRDT (Yjs), cursor presence, user awareness (avatars, selections), inline comments, suggestion mode, version snapshots, and conflict-free merging. + +Compilation Engine: +- On-demand and auto LaTeX compilation via Dockerized TeX Live. +- Include compile logs, error tracing, incremental builds, support for BibTeX/Biber, XeLaTeX, LuaLaTeX, and PDF preview with live refresh. + +Version Control: +- Full document history, diff viewer for LaTeX, named versions, restore points, lightweight branching, and optional Git integration / GitHub sync. + +Comments & Review: +- Inline comments, threaded discussions, resolve/unresolve, reviewer roles, track changes, and suggestion acceptance/rejection. + +Templates & Gallery: +- Template marketplace for journal formats, CVs, reports, preview before use, community submissions, tagging, and search. + +Collaboration & Sharing + +Permissions: +- Owner/editor/viewer roles, link-based sharing, public/private projects, access expiration, and organization-level sharing. + +Teams & Organizations: +- Create organizations, shared project spaces, team permissions, academic lab workflows, and admin roles. + +Bibliography & References + +Reference Management: +- BibTeX editor, citation auto-complete, import from DOI/arXiv/Google Scholar, shared reference libraries, and duplicate detection. + +Cross-Referencing: +- Auto label suggestions, reference validation, and warnings for missing citations. + +Output & Publishing + +PDF Export: +- Download compiled PDFs, versioned exports, and watermarking options. + +Journal Submission: +- Export bundles (ZIP with source), format validation for IEEE/ACM/Springer. + +Collaboration with Journals (optional): +- Submission pipelines and integrations. + +Search: +- Global search across projects, files, comments, and templates. +- Semantic search for LaTeX commands and references. + +AI Assistance (Copilot Equivalent): +- Inline AI assistant for LaTeX code generation and completion, error fixing suggestions, natural language to LaTeX conversion, academic writing assistance, summaries, paraphrasing, and citation suggestions. + +Integrations: +- GitHub sync (import/export repos), Dropbox/Google Drive import, ORCID integration for authors, and arXiv submission helper. + +Authentication & Security: +- Email/password, OAuth (Google, GitHub), 2FA (TOTP), access tokens, audit logs, encrypted storage, secure project sharing, and private project isolation. + +Notifications: +- Real-time collaboration updates, comment mentions, project activity, email notifications, and in-app notifications. + +Admin Panel: +- User management, project moderation, storage quotas, system monitoring, compilation job queue, rate limiting, and audit logs. + +Infrastructure: +- Dockerized LaTeX workers, Redis queue system, scalable WebSocket layer, CDN for assets, object storage for files, and horizontal scaling support. + +Miscellaneous: +- Dark/light mode, keyboard shortcuts, mobile-responsive UI, offline editing (optional), autosave, and project activity timeline. diff --git a/exp_config/prompts/fourier_transform_visualization.txt b/exp_config/prompts/fourier_transform_visualization.txt new file mode 100644 index 000000000..b81bfcabb --- /dev/null +++ b/exp_config/prompts/fourier_transform_visualization.txt @@ -0,0 +1,8 @@ +TASK: Visualization of Fourier Transform + +Description: +Make a visual that explains the Fourier transform. + +Requirements: +- Output an SVG file +- Should be clear and informative diff --git a/exp_config/prompts/github_platform.txt b/exp_config/prompts/github_platform.txt new file mode 100644 index 000000000..907e2c40d --- /dev/null +++ b/exp_config/prompts/github_platform.txt @@ -0,0 +1,80 @@ +TASK: Build a GitHub Platform + +Build a full-stack GitHub clone called "GitVault". The goal is to replicate GitHub's complete feature set as a production-ready web application. + +Build every feature listed below: + +Repositories: +- Create/delete/fork/archive repos. +- Public and private visibility. +- File browser, web editor, blame view, file history, raw view, download ZIP. +- Topics/tags, releases, README rendering, license and .gitignore templates. +- Repository insights, dependency graph, contributor graph, traffic analytics, language breakdown, network graph, and compare branches/tags/commits. + +Git: +- Branch and tag management, commit history, commit diff viewer. +- Merge strategies (merge/squash/rebase), conflict detection, protected branches, merge queues, auto-merge, and CODEOWNERS support. + +Pull Requests: +- Open/close/merge PRs, draft PRs, inline code comments, suggested changes, review requests, approve/request changes/comment states, PR templates, required reviews, status checks, linked issues, and PR diff viewer. + +Issues: +- Create/close/lock issues, labels, milestones, assignees, issue templates, pinned issues, issue linking, reactions, sorting, and filtering. + +Discussions: +- Categories, Q&A with marked answers, reactions, pinning, and locking. + +Projects: +- Kanban board, table view, roadmap/timeline view, custom fields, issue and PR linking, filters, and project templates. + +Wiki: +- Create/edit/delete wiki pages, markdown rendering, page history, and sidebar navigation. + +Actions (CI/CD): +- YAML workflow parser, workflow runs with logs, job steps, self-hosted and cloud runners via Docker. +- Workflow triggers (push/PR/schedule/manual), artifacts upload/download, environment variables and secrets, reusable workflows, deployment environments with protection rules, and workflow badges. + +Packages: +- Container registry (Docker), npm, Maven, NuGet package hosting, version management, and access control. + +Pages: +- Static site hosting from repo branch or /docs folder, custom domain support, and HTTPS. + +Security: +- Dependabot alerts and auto PRs, CodeQL code scanning, secret scanning, private vulnerability reporting, security advisories, security policy (SECURITY.md), branch protection rules, and required 2FA. + +Search: +- Global code search, semantic search, symbol search, search by repo/user/org/topic, and filters by language/stars/date. + +Organizations: +- Create orgs, teams with granular permissions (read/write/admin/maintain), team discussions, org-level secrets, member roles, billing management, and audit log streaming. + +User Profiles: +- Avatar, bio, location, social links, contribution graph heatmap, pinned repos, achievement badges, followers/following, stars, and activity feed. + +Notifications: +- Web and email notifications, notification inbox, watching repos/issues/PRs, mentions, and custom notification routing. + +Authentication: +- Email/password, OAuth (GitHub/Google), 2FA (TOTP), SSO (SAML), personal access tokens, fine-grained tokens, SSH key management, GPG key management, and session management. + +GitHub Copilot Equivalent: +- Inline AI code suggestions in the web editor using an LLM API. + +Codespaces Equivalent: +- Launch browser-based VS Code (code-server) tied to any repo. + +Marketplace: +- List and install GitHub Apps, OAuth Apps, and Actions from a marketplace directory. + +Sponsors: +- User and org sponsorship tiers, payment integration, and sponsor dashboard. + +API: +- Full REST API and GraphQL API mirroring GitHub's, webhooks with event filtering, and GitHub App support. + +Admin Panel: +- Site-wide admin dashboard, user management, org management, rate limiting, audit logs, and system health monitoring. + +Miscellaneous: +- Dark/light theme, keyboard shortcuts, mobile-responsive UI, starring and watching repos, trending repos page, explore page, invite collaborators, repository transfer, repo visibility changes, deploy keys, and branch renaming. diff --git a/exp_config/prompts/notion_platform.txt b/exp_config/prompts/notion_platform.txt new file mode 100644 index 000000000..4f83dde32 --- /dev/null +++ b/exp_config/prompts/notion_platform.txt @@ -0,0 +1,38 @@ +TASK: Build a Notion Platform + +Build a full-stack Notion clone called "NoteFlow". The goal is to replicate Notion's complete feature set as a production-ready collaborative workspace web application. + +Build every feature listed below: + +Workspace & Pages: +- Create personal and team workspaces, nested pages, page icons, cover images, breadcrumbs, slash commands, page duplication, soft delete/trash, restore pages, favorites, recent pages, shared pages, page locking, full-width pages, small text mode, page comments, backlinks, synced blocks, table of contents, page history/versioning, page templates, page verification, published pages, SEO metadata, and custom page URLs. + +Editor: +- Rich text editor with block-based editing. +- Support paragraphs, headings, bullet lists, numbered lists, to-do lists, toggle lists, quotes, callouts, dividers, code blocks with syntax highlighting, inline code, equations/LaTeX, tables, columns, embeds, bookmarks, images, videos, audio, PDFs, file uploads, mentions, dates, reminders, emoji, inline comments, annotations, text color/background color, drag-and-drop block reordering, multi-block selection, copy/paste with structure preservation, markdown shortcuts, slash menu, keyboard shortcuts, collaborative cursor presence, real-time multiplayer editing, and offline draft recovery. + +Collaboration: +- Real-time collaboration across pages and databases, presence indicators, user cursors, inline comments, page discussions, threaded comments, comment resolution, mentions, task assignments, notifications, edit history, activity feed, permission-aware collaboration, shared links, guest access, invite by email, workspace roles, page-level permissions, database-level permissions, public sharing controls, domain-restricted sharing, and expiring links. + +Tasks & Project Management: +- Task databases, assignees, due dates, priorities, status workflows, recurring tasks, reminders, dependencies, sub-items, project templates, dashboards, my tasks view, team task views, milestone tracking, timeline dependencies, progress tracking, linked project docs, calendar integration, and workload views. + +Search & Knowledge Management: +- Global search across pages, databases, comments, and attachments. +- Support full-text search, semantic search, filters by workspace/page/person/date/type, quick switcher, recent searches, backlinks, graph-like relationship discovery, search ranking, search snippets, and search permissions trimming. + +File & Media Management: +- Upload and attach files to pages and database rows. +- Support images, PDFs, docs, spreadsheets, video embeds, audio embeds, bookmarks, preview cards, drag-and-drop upload, storage quotas, file version replacement, CDN delivery, signed URLs, and permissions-aware file access. + +Notifications: +- In-app and email notifications for mentions, comments, assignments, page shares, reminders, database updates, task due dates, workspace invites, and AI job completions. +- Include notification inbox, read/unread state, batching, digests, and notification preferences. + +Authentication & Identity: +- Email/password login, OAuth (Google/GitHub), magic links, 2FA (TOTP), session management, device/session list, avatar/profile management, workspace switching, SSO/SAML for enterprise, SCIM provisioning, and audit-friendly identity metadata. + +Import/Export: +- Import from Markdown, HTML, CSV, DOCX, and common workspace formats. +- Export pages/databases as Markdown, PDF, HTML, CSV, and JSON. +- Preserve hierarchy, attachments, metadata, relations, and formatting as much as possible. diff --git a/exp_config/prompts/rental_platform_ai_agents.txt b/exp_config/prompts/rental_platform_ai_agents.txt new file mode 100644 index 000000000..f40c17cec --- /dev/null +++ b/exp_config/prompts/rental_platform_ai_agents.txt @@ -0,0 +1,45 @@ +TASK: Rental Platform with AI Agents + +Description: +Build a short-stay rental marketplace where guests and hosts each have a dedicated AI agent. The guest agent finds and manages stays; the host agent operates the property. Both agents propose actions that their human approves or rejects in one tap - no action is taken without explicit consent. Deliver a working prototype demonstrating the full booking lifecycle and agent-to-agent interaction. + +Requirements: + +1. Roles +- Guest: books stays; approves or rejects guest agent proposals. +- Guest Agent: searches listings, manages bookings, handles check-in, monitors stays, escalates problems. +- Host: owns properties; approves or rejects host agent proposals. +- Host Agent: manages listings and pricing, responds to inquiries, coordinates turnovers, handles guest communication. +- Admin: sets marketplace rules, mediates disputes the agents cannot resolve. + +2. Agent Loop +Every agent runs the same six-stage loop on behalf of its human: +- Monitor: continuously watch active bookings, listings, and messages. +- Detect: identify events requiring action (inquiries, pricing changes, conflicts, in-stay issues, review windows). +- Propose: present a concrete, ready-to-execute action to the human in plain language. +- Approve: human approves or rejects in one tap. +- Act: execute the approved action immediately. +- Confirm: update state and surface result; on failure, restart with a revised proposal. + +3. Marketplace Capabilities +- Listing creation and search/match between guests and listings. +- Full booking lifecycle: inquiry -> booking -> check-in -> stay -> check-out -> review. +- Support for multiple concurrent stays per guest and multiple properties per host. +- Persistent state across repeat interactions (returning guests, recurring hosts). + +4. Guest Agent Capabilities +- Pre-trip: search, compare, propose bookings matching stated preferences. +- During-stay: monitor for problems, propose interventions. +- Post-stay: handle refunds, disputes, reviews, learn from outcomes. + +5. Host Agent Capabilities +- Listing management and availability updates. +- Dynamic pricing proposals. +- Inquiry response and guest communication. +- Turnover coordination between bookings. +- Issue detection and escalation to host. + +6. Agent-to-Agent Communication +- Direct negotiation between guest and host agents (e.g., price, check-in time, special requests). +- Dispute escalation path to admin when agents cannot resolve. +- All agent-to-agent exchanges visible to both humans in plain language. diff --git a/exp_config/prompts/sorting_visualization.txt b/exp_config/prompts/sorting_visualization.txt new file mode 100644 index 000000000..1309c97d9 --- /dev/null +++ b/exp_config/prompts/sorting_visualization.txt @@ -0,0 +1,8 @@ +TASK: Sorting Visualization + +Description: +Create an animation showing how sorting works. + +Requirements: +- Self-contained SVG file +- Should be educational