Desynth page and improved item info api. Added string substitution to utils.
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user