feat: init
This commit is contained in:
28
backend/converter.py
Normal file
28
backend/converter.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class ConvertRequest(BaseModel):
|
||||
markdown: str
|
||||
width: int = 80
|
||||
|
||||
|
||||
class ConvertResponse(BaseModel):
|
||||
micron: str
|
||||
|
||||
|
||||
@router.post("/convert", response_model=ConvertResponse)
|
||||
async def convert(req: ConvertRequest):
|
||||
try:
|
||||
from md2txt import convert_markdown
|
||||
|
||||
result = convert_markdown(
|
||||
req.markdown,
|
||||
width=req.width,
|
||||
renderer_name="micron",
|
||||
)
|
||||
return ConvertResponse(micron=result)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Conversion failed: {e}")
|
||||
25
backend/docker_utils.py
Normal file
25
backend/docker_utils.py
Normal file
@@ -0,0 +1,25 @@
|
||||
import os
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
NOMADNET_CONTAINER = os.environ.get("NOMADNET_CONTAINER", "nomadnet")
|
||||
|
||||
|
||||
@router.post("/restart")
|
||||
async def restart_nomadnet():
|
||||
try:
|
||||
import docker
|
||||
|
||||
client = docker.from_env()
|
||||
container = client.containers.get(NOMADNET_CONTAINER)
|
||||
container.restart()
|
||||
return {"status": "restarted", "container": NOMADNET_CONTAINER}
|
||||
except docker.errors.NotFound:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Container '{NOMADNET_CONTAINER}' not found",
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
81
backend/graph.py
Normal file
81
backend/graph.py
Normal file
@@ -0,0 +1,81 @@
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
SOURCES_DIR = Path(os.environ.get("SOURCES_DIR", "/data/sources"))
|
||||
PAGES_DIR = Path(os.environ.get("PAGES_DIR", "/data/pages"))
|
||||
|
||||
# Matches markdown links: [text](slug) where slug has no protocol or path separators
|
||||
_INTERNAL_LINK = re.compile(r"\[([^\]]+)\]\(([a-zA-Z0-9_-]+)\)")
|
||||
|
||||
|
||||
class GraphNode(BaseModel):
|
||||
id: str
|
||||
published: bool
|
||||
title: str | None = None
|
||||
|
||||
|
||||
class GraphEdge(BaseModel):
|
||||
source: str
|
||||
target: str
|
||||
|
||||
|
||||
class GraphData(BaseModel):
|
||||
nodes: list[GraphNode]
|
||||
edges: list[GraphEdge]
|
||||
|
||||
|
||||
def _all_page_names() -> set[str]:
|
||||
names: set[str] = set()
|
||||
if PAGES_DIR.is_dir():
|
||||
for f in PAGES_DIR.iterdir():
|
||||
if f.suffix == ".mu" and f.is_file():
|
||||
names.add(f.stem)
|
||||
if SOURCES_DIR.is_dir():
|
||||
for f in SOURCES_DIR.iterdir():
|
||||
if f.suffix == ".md" and f.is_file():
|
||||
names.add(f.stem)
|
||||
return names
|
||||
|
||||
|
||||
def _extract_title(markdown: str) -> str | None:
|
||||
for line in markdown.splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("# "):
|
||||
return stripped[2:].strip()
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/graph", response_model=GraphData)
|
||||
async def get_graph():
|
||||
all_names = _all_page_names()
|
||||
nodes: list[GraphNode] = []
|
||||
edges: list[GraphEdge] = []
|
||||
|
||||
for name in sorted(all_names):
|
||||
md_path = SOURCES_DIR / f"{name}.md"
|
||||
mu_path = PAGES_DIR / f"{name}.mu"
|
||||
|
||||
title = None
|
||||
if md_path.is_file():
|
||||
content = md_path.read_text(encoding="utf-8")
|
||||
title = _extract_title(content)
|
||||
|
||||
# Parse internal links
|
||||
for match in _INTERNAL_LINK.finditer(content):
|
||||
target = match.group(2)
|
||||
if target in all_names:
|
||||
edges.append(GraphEdge(source=name, target=target))
|
||||
|
||||
nodes.append(GraphNode(
|
||||
id=name,
|
||||
published=mu_path.is_file(),
|
||||
title=title,
|
||||
))
|
||||
|
||||
return GraphData(nodes=nodes, edges=edges)
|
||||
28
backend/main.py
Normal file
28
backend/main.py
Normal file
@@ -0,0 +1,28 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from converter import router as converter_router
|
||||
from pages import router as pages_router
|
||||
from graph import router as graph_router
|
||||
from docker_utils import router as docker_router
|
||||
|
||||
app = FastAPI(title="Micron Page Editor")
|
||||
|
||||
app.include_router(converter_router, prefix="/api")
|
||||
app.include_router(pages_router, prefix="/api")
|
||||
app.include_router(graph_router, prefix="/api")
|
||||
app.include_router(docker_router, prefix="/api")
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# Serve built frontend as static files (SPA fallback)
|
||||
static_dir = Path(__file__).parent / "static"
|
||||
if static_dir.is_dir():
|
||||
app.mount("/", StaticFiles(directory=str(static_dir), html=True), name="static")
|
||||
136
backend/pages.py
Normal file
136
backend/pages.py
Normal file
@@ -0,0 +1,136 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
PAGES_DIR = Path(os.environ.get("PAGES_DIR", "/data/pages"))
|
||||
SOURCES_DIR = Path(os.environ.get("SOURCES_DIR", "/data/sources"))
|
||||
|
||||
|
||||
class PageMeta(BaseModel):
|
||||
name: str
|
||||
title: str | None = None
|
||||
published: bool = False
|
||||
has_source: bool = False
|
||||
last_modified: float | None = None
|
||||
size: int | None = None
|
||||
|
||||
|
||||
class PageDetail(BaseModel):
|
||||
name: str
|
||||
markdown: str | None = None
|
||||
micron: str | None = None
|
||||
|
||||
|
||||
class SaveRequest(BaseModel):
|
||||
markdown: str
|
||||
publish: bool = False
|
||||
|
||||
|
||||
def _extract_title(markdown: str) -> str | None:
|
||||
for line in markdown.splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("# "):
|
||||
return stripped[2:].strip()
|
||||
return None
|
||||
|
||||
|
||||
def _list_all_page_names() -> set[str]:
|
||||
names: set[str] = set()
|
||||
if PAGES_DIR.is_dir():
|
||||
for f in PAGES_DIR.iterdir():
|
||||
if f.suffix == ".mu" and f.is_file():
|
||||
names.add(f.stem)
|
||||
if SOURCES_DIR.is_dir():
|
||||
for f in SOURCES_DIR.iterdir():
|
||||
if f.suffix == ".md" and f.is_file():
|
||||
names.add(f.stem)
|
||||
return names
|
||||
|
||||
|
||||
def _page_meta(name: str) -> PageMeta:
|
||||
mu_path = PAGES_DIR / f"{name}.mu"
|
||||
md_path = SOURCES_DIR / f"{name}.md"
|
||||
|
||||
title = None
|
||||
if md_path.is_file():
|
||||
title = _extract_title(md_path.read_text(encoding="utf-8"))
|
||||
|
||||
published = mu_path.is_file()
|
||||
last_modified = mu_path.stat().st_mtime if published else None
|
||||
size = mu_path.stat().st_size if published else None
|
||||
|
||||
return PageMeta(
|
||||
name=name,
|
||||
title=title,
|
||||
published=published,
|
||||
has_source=md_path.is_file(),
|
||||
last_modified=last_modified,
|
||||
size=size,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/pages", response_model=list[PageMeta])
|
||||
async def list_pages():
|
||||
return [_page_meta(n) for n in sorted(_list_all_page_names())]
|
||||
|
||||
|
||||
@router.get("/pages/{name}", response_model=PageDetail)
|
||||
async def get_page(name: str):
|
||||
md_path = SOURCES_DIR / f"{name}.md"
|
||||
mu_path = PAGES_DIR / f"{name}.mu"
|
||||
|
||||
if not md_path.is_file() and not mu_path.is_file():
|
||||
raise HTTPException(status_code=404, detail="Page not found")
|
||||
|
||||
markdown = md_path.read_text(encoding="utf-8") if md_path.is_file() else None
|
||||
micron = mu_path.read_text(encoding="utf-8") if mu_path.is_file() else None
|
||||
|
||||
return PageDetail(name=name, markdown=markdown, micron=micron)
|
||||
|
||||
|
||||
@router.post("/pages/{name}", response_model=PageMeta)
|
||||
async def save_page(name: str, req: SaveRequest):
|
||||
SOURCES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
PAGES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Always save markdown source
|
||||
md_path = SOURCES_DIR / f"{name}.md"
|
||||
md_path.write_text(req.markdown, encoding="utf-8")
|
||||
|
||||
# Optionally publish
|
||||
if req.publish:
|
||||
try:
|
||||
from md2txt import convert_markdown
|
||||
|
||||
micron = convert_markdown(
|
||||
req.markdown,
|
||||
width=80,
|
||||
renderer_name="micron",
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Conversion failed: {e}")
|
||||
|
||||
mu_path = PAGES_DIR / f"{name}.mu"
|
||||
mu_path.write_text(micron, encoding="utf-8")
|
||||
|
||||
return _page_meta(name)
|
||||
|
||||
|
||||
@router.delete("/pages/{name}")
|
||||
async def delete_page(name: str):
|
||||
md_path = SOURCES_DIR / f"{name}.md"
|
||||
mu_path = PAGES_DIR / f"{name}.mu"
|
||||
|
||||
if not md_path.is_file() and not mu_path.is_file():
|
||||
raise HTTPException(status_code=404, detail="Page not found")
|
||||
|
||||
if md_path.is_file():
|
||||
md_path.unlink()
|
||||
if mu_path.is_file():
|
||||
mu_path.unlink()
|
||||
|
||||
return {"deleted": name}
|
||||
4
backend/requirements.txt
Normal file
4
backend/requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
fastapi>=0.115
|
||||
uvicorn[standard]>=0.34
|
||||
docker>=7.0
|
||||
md2txt @ git+https://codeberg.org/randogoth/md2txt
|
||||
Reference in New Issue
Block a user