Skip to content

Commit

Permalink
Fonts (#117)
Browse files Browse the repository at this point in the history
* add a lot of fonts

* update location of settings

* fonts metadata api

* something works

* persist frame panel metadata in url

* font selector

* default font

* move all fonts

* move other assets

* move folders

* sync fonts

* font app fonts

* dedupe

* bit better font select

* template export select all

* can toggle font

* upload fonts

* select font on scene

* upload custom fonts

* no init

* font select

* caret info

* e2e with fonts

* ubuntu font

* cleanup

* fix logic

* pick nodes
  • Loading branch information
mariusandra authored Jan 13, 2025
1 parent fd8c213 commit 14f615d
Show file tree
Hide file tree
Showing 136 changed files with 8,871 additions and 137 deletions.
2 changes: 2 additions & 0 deletions backend/app/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@

from .auth import * # noqa: E402, F403
from .apps import * # noqa: E402, F403
from .assets import * # noqa: E402, F403
from .frames import * # noqa: E402, F403
from .fonts import * # noqa: E402, F403
from .log import * # noqa: E402, F403
from .repositories import * # noqa: E402, F403
from .settings import * # noqa: E402, F403
Expand Down
178 changes: 178 additions & 0 deletions backend/app/api/assets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import uuid
from typing import Optional
from http import HTTPStatus
from fastapi import Depends, HTTPException, File, Form, UploadFile, Query
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session
from fastapi.responses import Response

from app.database import get_db
from app.models.assets import Assets
from app.schemas.assets import (
AssetResponse
)
from . import api_with_auth

@api_with_auth.get("/assets", response_model=list[AssetResponse])
async def list_assets(
path: Optional[str] = Query(None, description="Optional substring filter on the asset path"),
db: Session = Depends(get_db)
):
"""
Return a list of all stored Assets (without the binary data).
Optionally filter by `path` if specified.
"""
query = db.query(Assets)
if path:
query = query.filter(Assets.path.ilike(f"%{path}%"))
results = query.all()

output = []
for asset in results:
output.append(AssetResponse(
id=asset.id,
path=asset.path,
size=len(asset.data) if asset.data else 0
))
return output


@api_with_auth.get("/assets/{asset_id}", response_model=AssetResponse)
async def get_asset(asset_id: str, db: Session = Depends(get_db)):
"""
Return metadata for a single asset by its ID.
"""
asset = db.query(Assets).filter_by(id=asset_id).first()
if not asset:
raise HTTPException(status_code=404, detail="Asset not found")

return AssetResponse(
id=asset.id,
path=asset.path,
size=len(asset.data) if asset.data else 0
)


@api_with_auth.get("/assets/{asset_id}/download")
async def download_asset(asset_id: str, db: Session = Depends(get_db)):
"""
Download the raw binary data of an asset by ID.
"""
asset = db.query(Assets).filter_by(id=asset_id).first()
if not asset:
raise HTTPException(status_code=404, detail="Asset not found")
if not asset.data:
raise HTTPException(status_code=404, detail="Asset has no data")

return Response(
content=asset.data,
media_type="application/octet-stream",
headers={"Content-Disposition": f'attachment; filename="{uuid.uuid4()}"'}
)


@api_with_auth.post("/assets", response_model=AssetResponse, status_code=201)
async def create_asset(
path: str = Form(..., description="Unique path identifier for this asset"),
file: UploadFile = File(...),
db: Session = Depends(get_db)
):
"""
Create and store a new asset in the DB, reading from multipart/form-data.
- `path` must be unique
- `file` is the actual file data
"""
existing = db.query(Assets).filter_by(path=path).first()
if existing:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Asset path '{path}' is already in use."
)

try:
content = await file.read()
except Exception:
raise HTTPException(status_code=400, detail="Error reading uploaded file.")

new_asset = Assets(path=path, data=content)
db.add(new_asset)
try:
db.commit()
except SQLAlchemyError:
db.rollback()
raise HTTPException(status_code=500, detail="Database error")

return AssetResponse(
id=new_asset.id,
path=new_asset.path,
size=len(new_asset.data) if new_asset.data else 0
)


@api_with_auth.put("/assets/{asset_id}", response_model=AssetResponse)
async def update_asset(
asset_id: str,
path: Optional[str] = Form(None, description="New path (must remain unique)"),
file: Optional[UploadFile] = File(None),
db: Session = Depends(get_db)
):
"""
Update an existing asset with multipart/form-data.
You can update:
- The path (unique)
- The file contents (if provided).
If you only want to change the path (and not the file), omit `file`.
"""
asset = db.query(Assets).filter_by(id=asset_id).first()
if not asset:
raise HTTPException(status_code=404, detail="Asset not found")

# If user wants to update the path:
if path and path != asset.path:
# check uniqueness of new path
conflict = db.query(Assets).filter_by(path=path).first()
if conflict and conflict.id != asset_id:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Another asset already uses path '{path}'."
)
asset.path = path

# If user wants to update the binary data:
if file is not None:
try:
content = await file.read()
asset.data = content
except Exception:
raise HTTPException(status_code=400, detail="Error reading uploaded file.")

try:
db.commit()
except SQLAlchemyError:
db.rollback()
raise HTTPException(status_code=500, detail="Database error")

return AssetResponse(
id=asset.id,
path=asset.path,
size=len(asset.data) if asset.data else 0
)


@api_with_auth.delete("/assets/{asset_id}")
async def delete_asset(asset_id: str, db: Session = Depends(get_db)):
"""
Delete an asset by ID.
"""
asset = db.query(Assets).filter_by(id=asset_id).first()
if not asset:
raise HTTPException(status_code=404, detail="Asset not found")

db.delete(asset)
try:
db.commit()
except SQLAlchemyError:
db.rollback()
raise HTTPException(status_code=500, detail="Database error")

return {"message": "Asset deleted successfully"}
82 changes: 82 additions & 0 deletions backend/app/api/fonts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import io
import os
from fastapi import Depends, HTTPException
from fastapi.responses import FileResponse, StreamingResponse
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.assets import Assets
from app.models.fonts import gather_all_fonts_info, parse_font_info_in_memory
from app.schemas.fonts import FontMetadata, FontsListResponse
from . import api_with_auth

@api_with_auth.get("/fonts", response_model=FontsListResponse)
async def api_fonts_list(db: Session = Depends(get_db)):
"""
Return a combined list of font metadata from:
1) local ../frameos/assets/copied/fonts
2) DB assets with path starting with fonts/
"""
# 1) Gather local fonts from folder
local_list = gather_all_fonts_info("../frameos/assets/copied/fonts")

# 2) Gather DB fonts with path like "fonts/..."
db_assets = db.query(Assets).filter(Assets.path.like("fonts/%")).all() # [NEW]
for asset in db_assets:
# asset.path is e.g. "fonts/MyFont.ttf"
filename = os.path.basename(asset.path)
if not filename.lower().endswith(".ttf"):
continue
# parse in-memory
try:
font_info = parse_font_info_in_memory(asset.data, filename)
local_list.append(font_info.dict())
except Exception:
# If we can't parse, skip or log
pass

# Build a combined list of FontMetadata objects
combined_fonts = []
for item in local_list:
# gather_all_fonts_info returns raw dicts, so unify them into FontMetadata
if isinstance(item, dict):
combined_fonts.append(FontMetadata(**item))
else:
# or if gather_all_fonts_info returned FontMetadata directly
combined_fonts.append(item)

return {"fonts": combined_fonts}


@api_with_auth.get("/fonts/{font_name}")
async def api_fonts_download(font_name: str, db: Session = Depends(get_db)):
"""
Download a font by name. Checks DB first, then local folder.
If found in DB, returns a StreamingResponse from memory.
If found locally, returns a FileResponse from disk.
"""
# 1) Check DB for path="fonts/<font_name>"
asset = db.query(Assets).filter_by(path=f"fonts/{font_name}").first()
if asset:
if not asset.data:
raise HTTPException(status_code=404, detail="Font asset has no data")
# return an in-memory streaming response
return StreamingResponse(
io.BytesIO(asset.data),
media_type="font/ttf",
headers={"Content-Disposition": f'attachment; filename="{font_name}"'},
)

# 2) Check local folder
local_path = f"../frameos/assets/copied/fonts/{font_name}"
if "/" in font_name or "\\" in font_name:
return {"error": "Invalid font filename"}
if os.path.isfile(local_path):
# Same logic as before. If you want a file download:
return FileResponse(
local_path,
filename=font_name,
media_type="font/ttf",
headers={"Cache-Control": "max-age=86400"},
)

return {"error": "font not found"}
19 changes: 19 additions & 0 deletions backend/app/api/frames.py
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,7 @@ async def api_frame_get_assets(id: int, db: Session = Depends(get_db), redis: Re
output: list[str] = []
await exec_command(db, redis, frame, ssh, command, output, log_output=False)
await remove_ssh_connection(ssh)
await log(db, redis, id, "stdinfo", "SSH connection closed")

assets = []
for line in output:
Expand Down Expand Up @@ -352,12 +353,30 @@ async def api_frame_get_asset(

finally:
await remove_ssh_connection(ssh)
await log(db, redis, id, "stdinfo", "SSH connection closed")

except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e))

@api_with_auth.post("/frames/{id:int}/assets/sync")
async def api_frame_get_assets_upload_fonts(id: int, db: Session = Depends(get_db), redis: Redis = Depends(get_redis)):
frame = db.get(Frame, id)
if frame is None:
raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Frame not found")
try:
from app.models.assets import sync_assets
ssh = await get_ssh_connection(db, redis, frame)
try:
await sync_assets(db, redis, frame, ssh)
finally:
await remove_ssh_connection(ssh)
await log(db, redis, id, "stdinfo", "SSH connection closed")
return {"message": "Assets synced successfully"}
except Exception as e:
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e))

@api_with_auth.post("/frames/{id:int}/reset")
async def api_frame_reset_event(id: int, redis: Redis = Depends(get_redis)):
try:
Expand Down
1 change: 1 addition & 0 deletions backend/app/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from .apps import * # noqa: F403
from .assets import * # noqa: F403
from .frame import * # noqa: F403
from .log import * # noqa: F403
from .metrics import * # noqa: F403
Expand Down
Loading

0 comments on commit 14f615d

Please sign in to comment.