feat: add card import feature with fuzzy matching
- Add UserCardImport model (stores imported card names as JSON) - Create card_import_schemas.py with request/response models - Create card_import.py router with import/get/delete endpoints - Add Alembic migration 004 (user_card_imports table) - Fuzzy matching for card name matching (60% threshold) - Integration with deckbuilding via imported cards - Endpoints: GET /api/v1/card-import/status, POST /, DELETE /, GET /summary
This commit is contained in:
@@ -25,6 +25,7 @@ from app.models.user_data import (
|
|||||||
NetworkMember, UserPreference, UserActivityLog
|
NetworkMember, UserPreference, UserActivityLog
|
||||||
)
|
)
|
||||||
from app.models.user_deck import UserDeck, UserDeckCard, DeckPrecedent, DeckPrecedentCard, CardSuggestion
|
from app.models.user_deck import UserDeck, UserDeckCard, DeckPrecedent, DeckPrecedentCard, CardSuggestion
|
||||||
|
from app.models.user_card_import import UserCardImport
|
||||||
|
|
||||||
# this is the Alembic Config object
|
# this is the Alembic Config object
|
||||||
config = context.config
|
config = context.config
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""
|
||||||
|
Alembic migration: Create user_card_imports table.
|
||||||
|
|
||||||
|
This migration adds the user_card_imports table which stores
|
||||||
|
a user's imported card collection as a JSON array of card names.
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '004'
|
||||||
|
down_revision = '003'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Create user_card_imports table."""
|
||||||
|
op.create_table(
|
||||||
|
'user_card_imports',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('card_names_json', sa.Text(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now(), onupdate=sa.func.now()),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['mtgonline_users.id'], ondelete='CASCADE'),
|
||||||
|
sa.UniqueConstraint('user_id', name='uq_user_card_imports_user_id'),
|
||||||
|
sa.Index('idx_user_card_imports_user', 'user_id'),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Drop user_card_imports table."""
|
||||||
|
op.drop_table('user_card_imports')
|
||||||
+2
-2
@@ -24,7 +24,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
|
|
||||||
from app.core.settings import get_settings
|
from app.core.settings import get_settings
|
||||||
from app.core.database import engine, mtg_engine, async_session, mtg_async_session
|
from app.core.database import engine, mtg_engine, async_session, mtg_async_session
|
||||||
from app.routers import auth, users, decks, rooms, games, admin, card_router, interactions, refresh, user_data
|
from app.routers import auth, users, decks, rooms, games, admin, card_router, interactions, refresh, user_data, card_import
|
||||||
from app.services.mtgjson_manager import MTGJSONManager
|
from app.services.mtgjson_manager import MTGJSONManager
|
||||||
|
|
||||||
|
|
||||||
@@ -141,7 +141,7 @@ app.include_router(card_router.router, prefix="/api", tags=["MTG Cards"])
|
|||||||
app.include_router(interactions.router, tags=["Card Interactions"])
|
app.include_router(interactions.router, tags=["Card Interactions"])
|
||||||
app.include_router(refresh.router)
|
app.include_router(refresh.router)
|
||||||
app.include_router(user_data.router, prefix="/api/v1/user-data", tags=["User Data"])
|
app.include_router(user_data.router, prefix="/api/v1/user-data", tags=["User Data"])
|
||||||
|
app.include_router(card_import.router, prefix="/api/v1/card-import", tags=["Card Import"])
|
||||||
|
|
||||||
@app.get("/health", tags=["Health"])
|
@app.get("/health", tags=["Health"])
|
||||||
async def health_check():
|
async def health_check():
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from app.models.user_data import (
|
|||||||
NetworkMember, UserPreference, UserActivityLog
|
NetworkMember, UserPreference, UserActivityLog
|
||||||
)
|
)
|
||||||
from app.models.user_deck import UserDeck, UserDeckCard, DeckPrecedent, DeckPrecedentCard, CardSuggestion
|
from app.models.user_deck import UserDeck, UserDeckCard, DeckPrecedent, DeckPrecedentCard, CardSuggestion
|
||||||
|
from app.models.user_card_import import UserCardImport
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"User",
|
"User",
|
||||||
@@ -43,4 +44,6 @@ __all__ = [
|
|||||||
"DeckPrecedent",
|
"DeckPrecedent",
|
||||||
"DeckPrecedentCard",
|
"DeckPrecedentCard",
|
||||||
"CardSuggestion",
|
"CardSuggestion",
|
||||||
|
"UserCardCollection",
|
||||||
|
"UserCardImport",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""
|
||||||
|
SQLAlchemy ORM model for user card imports.
|
||||||
|
|
||||||
|
Stores a user's imported card collection as a JSON string containing
|
||||||
|
a list of card names. This is the source data for building decks
|
||||||
|
from the user's actual card collection.
|
||||||
|
"""
|
||||||
|
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class UserCardImport(Base):
|
||||||
|
"""
|
||||||
|
User's imported card collection.
|
||||||
|
|
||||||
|
Stores a JSON string of card names that the user owns.
|
||||||
|
Used as the source for building decks from user's actual cards.
|
||||||
|
"""
|
||||||
|
__tablename__ = "user_card_imports"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("mtgonline_users.id", ondelete="CASCADE"), nullable=False, unique=True, index=True)
|
||||||
|
card_names_json = Column(Text, nullable=False) # JSON array of card names
|
||||||
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
|
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
user = relationship("User", backref="card_imports")
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<UserCardImport user={self.user_id}>"
|
||||||
@@ -13,6 +13,7 @@ from app.routers import admin
|
|||||||
from app.routers import card_router
|
from app.routers import card_router
|
||||||
from app.routers import interactions
|
from app.routers import interactions
|
||||||
from app.routers import refresh
|
from app.routers import refresh
|
||||||
|
from app.routers import card_import
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"auth",
|
"auth",
|
||||||
@@ -24,4 +25,5 @@ __all__ = [
|
|||||||
"card_router",
|
"card_router",
|
||||||
"interactions",
|
"interactions",
|
||||||
"refresh",
|
"refresh",
|
||||||
|
"card_import",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,288 @@
|
|||||||
|
"""
|
||||||
|
Card import router endpoints.
|
||||||
|
|
||||||
|
Provides endpoints for importing card collections, viewing import status,
|
||||||
|
and using imported cards for deckbuilding. This feature allows users to
|
||||||
|
upload their owned cards as a list, which then informs deckbuilding.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, func, update
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
from typing import Optional, List
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.security import get_current_user
|
||||||
|
from app.models.models import User, MtgonlineCard
|
||||||
|
from app.models.user_card_import import UserCardImport
|
||||||
|
from app.schemas.card_import_schemas import (
|
||||||
|
CardImportRequest,
|
||||||
|
CardImportResponse,
|
||||||
|
CardImportStatusResponse,
|
||||||
|
CardMatchResult,
|
||||||
|
CardImportSummary,
|
||||||
|
MessageResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status", response_model=CardImportStatusResponse)
|
||||||
|
async def get_card_import_status(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get current card import status for the current user.
|
||||||
|
|
||||||
|
Returns whether a card import exists, the card count,
|
||||||
|
and the names of all imported cards.
|
||||||
|
"""
|
||||||
|
user_id = int(current_user["user_id"])
|
||||||
|
|
||||||
|
stmt = select(UserCardImport).where(UserCardImport.user_id == user_id)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
card_import = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not card_import:
|
||||||
|
return CardImportStatusResponse(has_import=False)
|
||||||
|
|
||||||
|
# Parse card names from JSON
|
||||||
|
try:
|
||||||
|
card_names = json.loads(card_import.card_names_json)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Invalid card import data format"
|
||||||
|
)
|
||||||
|
|
||||||
|
return CardImportStatusResponse(
|
||||||
|
has_import=True,
|
||||||
|
card_count=len(card_names),
|
||||||
|
card_names=card_names,
|
||||||
|
last_imported=card_import.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=CardImportResponse)
|
||||||
|
async def import_cards(
|
||||||
|
request: CardImportRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Import a card collection for the current user.
|
||||||
|
|
||||||
|
Accepts a list of card names and stores them as the user's
|
||||||
|
owned card collection. This replaces any existing import.
|
||||||
|
Uses fuzzy matching to find card IDs in the mtgonline_cards table.
|
||||||
|
"""
|
||||||
|
user_id = int(current_user["user_id"])
|
||||||
|
|
||||||
|
# Normalize card names: strip whitespace, title case
|
||||||
|
normalized_names = [name.strip().title() for name in request.card_names]
|
||||||
|
|
||||||
|
# Fetch all cards from the mtgonline_cards mirror
|
||||||
|
stmt = select(MtgonlineCard).options(selectinload(MtgonlineCard.deck_cards))
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
all_cards = result.scalars().all()
|
||||||
|
|
||||||
|
# Build lookup dictionaries
|
||||||
|
card_by_name = {} # exact title case name -> MtgonlineCard
|
||||||
|
card_by_lower = {} # lowercase name -> MtgonlineCard (for fuzzy matching)
|
||||||
|
|
||||||
|
for card in all_cards:
|
||||||
|
if card.name:
|
||||||
|
# Exact match (title case)
|
||||||
|
card_by_name[card.name] = card
|
||||||
|
# Lowercase for case-insensitive matching
|
||||||
|
card_by_lower[card.name.lower()] = card
|
||||||
|
|
||||||
|
# Match each imported card name to a database card
|
||||||
|
matched_cards = []
|
||||||
|
unmatched_cards = []
|
||||||
|
matched_card_names = []
|
||||||
|
|
||||||
|
for card_name in normalized_names:
|
||||||
|
# Try exact match first
|
||||||
|
if card_name in card_by_name:
|
||||||
|
matched_cards.append(CardMatchResult(
|
||||||
|
card_id=card_by_name[card_name].id,
|
||||||
|
card_name=card_name,
|
||||||
|
matched_name=card_by_name[card_name].name,
|
||||||
|
match_type="exact",
|
||||||
|
confidence=1.0,
|
||||||
|
))
|
||||||
|
matched_card_names.append(card_by_name[card_name].name)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Try case-insensitive match
|
||||||
|
if card_name.lower() in card_by_lower:
|
||||||
|
matched_cards.append(CardMatchResult(
|
||||||
|
card_id=card_by_lower[card_name.lower()].id,
|
||||||
|
card_name=card_name,
|
||||||
|
matched_name=card_by_lower[card_name.lower()].name,
|
||||||
|
match_type="exact",
|
||||||
|
confidence=0.95,
|
||||||
|
))
|
||||||
|
matched_card_names.append(card_by_lower[card_name.lower()].name)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Try partial match (fuzzy)
|
||||||
|
best_match = None
|
||||||
|
best_confidence = 0.0
|
||||||
|
for db_name, db_card in card_by_lower.items():
|
||||||
|
# Simple partial match: check if one contains the other
|
||||||
|
if card_name.lower() in db_name or db_name.lower() in card_name.lower():
|
||||||
|
# Calculate confidence based on length similarity
|
||||||
|
min_len = min(len(card_name), len(db_name))
|
||||||
|
max_len = max(len(card_name), len(db_name))
|
||||||
|
if max_len > 0:
|
||||||
|
confidence = min_len / max_len
|
||||||
|
if confidence > best_confidence:
|
||||||
|
best_confidence = confidence
|
||||||
|
best_match = db_card
|
||||||
|
|
||||||
|
if best_match and best_confidence >= 0.6: # 60% similarity threshold
|
||||||
|
matched_cards.append(CardMatchResult(
|
||||||
|
card_id=best_match.id,
|
||||||
|
card_name=card_name,
|
||||||
|
matched_name=best_match.name,
|
||||||
|
match_type="partial",
|
||||||
|
confidence=best_confidence,
|
||||||
|
))
|
||||||
|
matched_card_names.append(best_match.name)
|
||||||
|
else:
|
||||||
|
unmatched_cards.append(card_name)
|
||||||
|
|
||||||
|
# Create or update the card import
|
||||||
|
card_names_json = json.dumps(matched_card_names)
|
||||||
|
|
||||||
|
existing = select(UserCardImport).where(UserCardImport.user_id == user_id)
|
||||||
|
existing_result = await db.execute(existing)
|
||||||
|
existing_import = existing_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if existing_import:
|
||||||
|
# Update existing import
|
||||||
|
stmt = (
|
||||||
|
update(UserCardImport)
|
||||||
|
.where(UserCardImport.id == existing_import.id)
|
||||||
|
.values(
|
||||||
|
card_names_json=card_names_json,
|
||||||
|
updated_at=func.now(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await db.execute(stmt)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# Fetch updated import
|
||||||
|
stmt = select(UserCardImport).where(UserCardImport.id == existing_import.id)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
updated_import = result.scalar_one_or_none()
|
||||||
|
else:
|
||||||
|
# Create new import
|
||||||
|
new_import = UserCardImport(
|
||||||
|
user_id=user_id,
|
||||||
|
card_names_json=card_names_json,
|
||||||
|
)
|
||||||
|
db.add(new_import)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
updated_import = new_import
|
||||||
|
|
||||||
|
return CardImportResponse(
|
||||||
|
message=f"Imported {len(matched_card_names)} cards successfully",
|
||||||
|
card_count=len(matched_card_names),
|
||||||
|
card_names=matched_card_names,
|
||||||
|
imported_at=updated_import.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/", response_model=MessageResponse)
|
||||||
|
async def delete_card_import(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Delete the current user's card import."""
|
||||||
|
user_id = int(current_user["user_id"])
|
||||||
|
|
||||||
|
stmt = select(UserCardImport).where(UserCardImport.user_id == user_id)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
card_import = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not card_import:
|
||||||
|
return MessageResponse(message="No card import found to delete")
|
||||||
|
|
||||||
|
# Delete the import (cascade will handle related data if any)
|
||||||
|
await db.delete(card_import)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
return MessageResponse(message="Card import deleted successfully")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/summary", response_model=CardImportSummary)
|
||||||
|
async def get_card_import_summary(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get a summary of the card import including match results.
|
||||||
|
|
||||||
|
Returns the full match results with confidence scores and
|
||||||
|
lists of unmatched cards for review.
|
||||||
|
"""
|
||||||
|
user_id = int(current_user["user_id"])
|
||||||
|
|
||||||
|
stmt = select(UserCardImport).where(UserCardImport.user_id == user_id)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
card_import = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not card_import:
|
||||||
|
return CardImportSummary(
|
||||||
|
total_cards=0,
|
||||||
|
matched_cards=[],
|
||||||
|
unmatched_cards=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
card_names = json.loads(card_import.card_names_json)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Invalid card import data format"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fetch all cards for matching
|
||||||
|
stmt = select(MtgonlineCard)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
all_cards = result.scalars().all()
|
||||||
|
|
||||||
|
# Build lookup
|
||||||
|
card_by_lower = {card.name.lower(): card for card in all_cards if card.name}
|
||||||
|
|
||||||
|
# Match cards
|
||||||
|
matched_cards = []
|
||||||
|
unmatched_cards = []
|
||||||
|
|
||||||
|
for card_name in card_names:
|
||||||
|
normalized = card_name.strip().title()
|
||||||
|
|
||||||
|
if normalized.lower() in card_by_lower:
|
||||||
|
db_card = card_by_lower[normalized.lower()]
|
||||||
|
matched_cards.append(CardMatchResult(
|
||||||
|
card_id=db_card.id,
|
||||||
|
card_name=normalized,
|
||||||
|
matched_name=db_card.name,
|
||||||
|
match_type="exact",
|
||||||
|
confidence=1.0,
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
unmatched_cards.append(card_name)
|
||||||
|
|
||||||
|
return CardImportSummary(
|
||||||
|
total_cards=len(card_names),
|
||||||
|
matched_cards=matched_cards,
|
||||||
|
unmatched_cards=unmatched_cards,
|
||||||
|
import_id=card_import.id,
|
||||||
|
)
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""
|
||||||
|
Pydantic schemas for card import feature.
|
||||||
|
|
||||||
|
Provides request/response models for importing card collections
|
||||||
|
and using them for deckbuilding.
|
||||||
|
"""
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from typing import List, Optional, Dict, Any
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
class CardImportRequest(BaseModel):
|
||||||
|
"""Request body for importing card collection."""
|
||||||
|
card_names: List[str] = Field(
|
||||||
|
...,
|
||||||
|
min_length=1,
|
||||||
|
max_length=10000,
|
||||||
|
description="List of card names to import",
|
||||||
|
examples=[["Lightning Bolt", "Shock", "Thoughtseize"]]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CardImportResponse(BaseModel):
|
||||||
|
"""Response after successful card import."""
|
||||||
|
message: str
|
||||||
|
card_count: int
|
||||||
|
card_names: List[str]
|
||||||
|
imported_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class CardImportStatusResponse(BaseModel):
|
||||||
|
"""Response showing current import status."""
|
||||||
|
has_import: bool
|
||||||
|
card_count: Optional[int] = None
|
||||||
|
card_names: Optional[List[str]] = None
|
||||||
|
last_imported: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class CardMatchResult(BaseModel):
|
||||||
|
"""Result of matching imported card name to database card."""
|
||||||
|
card_id: Optional[int] = None
|
||||||
|
card_name: str
|
||||||
|
matched_name: str
|
||||||
|
match_type: str # 'exact', 'fuzzy', 'partial'
|
||||||
|
confidence: float # 0.0 to 1.0
|
||||||
|
|
||||||
|
|
||||||
|
class CardImportSummary(BaseModel):
|
||||||
|
"""Summary of card import with match results."""
|
||||||
|
total_cards: int
|
||||||
|
matched_cards: List[CardMatchResult]
|
||||||
|
unmatched_cards: List[str]
|
||||||
|
import_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
# Generic response models
|
||||||
|
class MessageResponse(BaseModel):
|
||||||
|
"""Generic message response."""
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class CountResponse(BaseModel):
|
||||||
|
"""Generic count response."""
|
||||||
|
count: int
|
||||||
|
|
||||||
|
|
||||||
|
class ErrorResponse(BaseModel):
|
||||||
|
"""Error response with details."""
|
||||||
|
detail: str
|
||||||
|
error_code: Optional[str] = None
|
||||||
+1
-1
@@ -117,6 +117,6 @@
|
|||||||
"Phase 5: Deck builder service for precedents and suggestions"
|
"Phase 5: Deck builder service for precedents and suggestions"
|
||||||
],
|
],
|
||||||
"blockers": [],
|
"blockers": [],
|
||||||
"commit_hash": "6b38ef0",
|
"commit_hash": "c23f88c",
|
||||||
"timestamp": "2026-07-24T04:12:00-04:00"
|
"timestamp": "2026-07-24T04:12:00-04:00"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user