Desynth page and improved item info api. Added string substitution to utils.

This commit is contained in:
Aodhan
2025-07-10 03:20:33 +01:00
parent b9b47c96f6
commit ef9b64adfe
38 changed files with 5703 additions and 4489 deletions

View File

@@ -51,10 +51,21 @@ async def ensure_view():
"""),
{"table": t},
)
stack_exists = await conn.scalar(
text(
"""
SELECT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name=:table AND column_name='stack_size'
)
"""),
{"table": t},
)
desc_col = "description" if desc_exists else "NULL"
icon_col = "icon_id" if icon_exists else "NULL"
stack_col = "stack_size" if stack_exists else "NULL"
selects.append(
f"SELECT id, name, {desc_col} AS description, {icon_col} AS icon_id, type_description FROM {t}"
f"SELECT id, name, {desc_col} AS description, {icon_col} AS icon_id, type_description, {stack_col} AS stack_size FROM {t}"
)
union_sql = " UNION ALL ".join(selects)

View File

@@ -15,10 +15,21 @@ class Inventory(Base):
character_name = Column(String)
storage_type = Column(String)
item_name = Column(String)
item_id = Column(Integer, nullable=True)
quantity = Column(Integer)
last_updated = Column(DateTime, default=datetime.utcnow)
class ItemIcon(Base):
__tablename__ = "item_icons"
id = Column(Integer, primary_key=True)
category = Column(String, nullable=False)
image_data = Column(String, nullable=False)
image_format = Column(String, nullable=True)
image_encoding = Column(String, nullable=True)
class Spell(Base):
"""Spell table with job level columns (selected jobs only)."""

View File

@@ -15,6 +15,7 @@ from .database import get_session
# Map craft names -> table names in Postgres
ALLOWED_CRAFTS = {
"desynthesis": "recipes_desynthesis",
"woodworking": "recipes_woodworking",
"smithing": "recipes_smithing",
"alchemy": "recipes_alchemy",
@@ -33,6 +34,17 @@ class RecipeUsageSummary(BaseModel):
name: str
level: int
class DesynthRecipe(BaseModel):
id: int
craft: str
cap: Optional[int] | None = None
item: str
crystal: str
ingredients: str # raw text, quantity assumed 1 each
hq1: Optional[str] | None = None
hq2: Optional[str] | None = None
hq3: Optional[str] | None = None
class ItemRecipeUsage(BaseModel):
crafted: list[RecipeUsageSummary] = []
ingredient: list[RecipeUsageSummary] = []
@@ -60,6 +72,29 @@ def _craft_table(craft: str) -> str:
return ALLOWED_CRAFTS[craft_lower]
@router.get("/recipes/desynthesis", response_model=List[DesynthRecipe])
async def list_desynth_recipes(session: AsyncSession = Depends(get_session)):
q = text("SELECT * FROM recipes_desynthesis ORDER BY item")
result = await session.execute(q)
rows = result.fetchall()
out: list[DesynthRecipe] = []
for r in rows:
out.append(
DesynthRecipe(
id=r.id,
craft=r.craft,
cap=r.cap,
item=r.item,
crystal=r.crystal,
ingredients=r.ingredients,
hq1=r.hq1,
hq2=r.hq2,
hq3=r.hq3,
)
)
return out
@router.get("/recipes/{craft}", response_model=List[RecipeDetail])
async def list_recipes(
craft: str = Path(..., description="Craft name, e.g. woodworking"),
@@ -134,10 +169,15 @@ async def item_recipe_usage(item_name: str, session: AsyncSession = Depends(get_
)
# As ingredient (simple text match in JSON/array column)
# Match exact ingredient name by looking for the item quoted in the JSON text.
# Using the surrounding double quotes prevents partial matches, e.g. "Chestnut" will not
# match the ingredient string "Chestnut Lumber".
quoted = f'"{item_name}"'
q2 = text(
f"SELECT id, name, level FROM {table} WHERE ingredients::text ILIKE :pat LIMIT 50"
f"SELECT id, name, level FROM {table} "
f"WHERE ingredients::text ILIKE :pat LIMIT 50"
)
res2 = await session.execute(q2, {"pat": f"%{item_name}%"})
res2 = await session.execute(q2, {"pat": f"%{quoted}%"})
for r in res2.fetchall():
if not any(c.id == r.id and c.craft == craft for c in crafted) and not any(
i.id == r.id and i.craft == craft for i in ingredient

View File

@@ -33,13 +33,13 @@ async def import_inventory_csv(
raise HTTPException(status_code=400, detail="CSV must be UTF-8 encoded")
reader = csv.DictReader(io.StringIO(text_data), delimiter=";", quotechar='"')
rows = []
raw_rows = []
for r in reader:
try:
qty = int(r["quantity"].strip()) if r["quantity"].strip() else 0
except (KeyError, ValueError):
raise HTTPException(status_code=400, detail="Invalid CSV schema or quantity value")
rows.append(
raw_rows.append(
{
"character_name": r["char"].strip(),
"storage_type": r["storage"].strip(),
@@ -48,6 +48,57 @@ async def import_inventory_csv(
}
)
# ---------------------------------------------------------------------
# Resolve stack sizes and split quantities across inventory slots
# ---------------------------------------------------------------------
item_names = {r["item_name"] for r in raw_rows}
stack_sizes: dict[str, int] = {}
if item_names:
# Discover item tables that have a stack_size column ( *_items )
tbl_res = await session.execute(
text(
"""
SELECT table_name
FROM information_schema.columns
WHERE table_schema = 'public'
AND column_name = 'stack_size'
AND table_name LIKE '%_items'
"""
)
)
item_tables = [row[0] for row in tbl_res.fetchall()]
# Query each table for name ▸ stack_size entries we need
for t in item_tables:
q = text(f"SELECT name, stack_size FROM {t} WHERE name = ANY(:names)")
res = await session.execute(q, {"names": list(item_names)})
for name, stack in res.fetchall():
# Prefer the first non-null value encountered
if name not in stack_sizes or not stack_sizes[name]:
stack_sizes[name] = (stack or 1) if stack and stack > 0 else 1
def _stack_for(item_name: str) -> int:
return stack_sizes.get(item_name, 1) or 1
# Resolve item_ids via all_items once
id_rows = await session.execute(text("SELECT id,name FROM all_items WHERE name = ANY(:names)"), {"names": list(item_names)})
id_map = {n: i for i, n in id_rows.fetchall()}
rows: list[dict] = []
for r in raw_rows:
qty = r["quantity"]
if qty <= 0:
continue
stack = _stack_for(r["item_name"])
# Split into multiple slot-rows respecting stack size
while qty > 0:
take = min(stack, qty)
slot_row = r.copy()
slot_row["quantity"] = take
slot_row["item_id"] = id_map.get(r["item_name"]) # may be None
rows.append(slot_row)
qty -= take
# Replace table contents inside a transaction
try:
await session.execute(text("TRUNCATE TABLE inventory;"))
@@ -105,6 +156,7 @@ class InventoryItem(BaseModel):
description: Optional[str]
icon_id: Optional[str]
type_description: Optional[str]
stack_size: Optional[int]
last_updated: Optional[datetime]
class Config:
@@ -119,7 +171,7 @@ async def inventory(
):
"""Return items for a character, optionally filtered by storage_type."""
base_sql = """
SELECT i.*, ai.description, ai.icon_id, ai.type_description
SELECT i.*, ai.description, ai.icon_id, ai.type_description, ai.stack_size
FROM inventory i
LEFT JOIN all_items ai ON ai.name = i.item_name
WHERE i.character_name = :char
@@ -139,6 +191,7 @@ async def inventory(
description=r.description,
icon_id=r.icon_id,
type_description=r.type_description,
stack_size=r.stack_size,
last_updated=r.last_updated,
) for r in rows]
@@ -217,6 +270,7 @@ class ItemDetail(BaseModel):
description: Optional[str]
icon_id: Optional[str]
type_description: Optional[str]
icon_b64: Optional[str] = None
@router.get("/icon/{icon_id}")
@@ -238,32 +292,66 @@ async def get_icon(icon_id: str, session: AsyncSession = Depends(get_session)):
@router.get("/items/by-name/{item_name}", response_model=ItemDetail)
async def item_detail_by_name(item_name: str, session: AsyncSession = Depends(get_session)):
q = text("SELECT * FROM all_items WHERE name = :n LIMIT 1")
q = text("""
SELECT a.*, ic.image_data, ic.image_encoding
FROM all_items a
LEFT JOIN item_icons ic ON ic.id = a.icon_id
WHERE a.name = :n
LIMIT 1
""")
row = (await session.execute(q, {"n": item_name})).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Item not found")
import base64
icon_b64: str | None = None
if row.image_data is not None:
if row.image_encoding == "base64":
icon_b64 = row.image_data
else:
try:
icon_b64 = base64.b64encode(row.image_data).decode()
except Exception:
icon_b64 = None
return ItemDetail(
id=row.id,
name=row.name,
description=row.description,
icon_id=row.icon_id,
type_description=row.type_description,
icon_b64=icon_b64,
)
@router.get("/items/{item_id}", response_model=ItemDetail)
async def item_detail(item_id: int, session: AsyncSession = Depends(get_session)):
"""Fetch full item record from all_items view."""
q = text("SELECT * FROM all_items WHERE id = :id LIMIT 1")
result = await session.execute(q, {"id": item_id})
row = result.fetchone()
async def item_detail(item_id: int = Path(..., ge=1), session: AsyncSession = Depends(get_session)):
"""Retrieve item metadata and icon by numeric ID."""
q = text(
"""
SELECT a.*, ic.image_data, ic.image_encoding
FROM all_items a
LEFT JOIN item_icons ic ON ic.id = a.icon_id
WHERE a.id = :id
LIMIT 1
"""
)
row = (await session.execute(q, {"id": item_id})).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Item not found")
import base64
icon_b64: str | None = None
if row.image_data is not None:
if row.image_encoding == "base64":
icon_b64 = row.image_data
else:
try:
icon_b64 = base64.b64encode(row.image_data).decode()
except Exception:
icon_b64 = None
return ItemDetail(
id=row.id,
name=row.name,
description=row.description,
icon_id=row.icon_id,
type_description=row.type_description,
icon_b64=icon_b64,
)