190 lines
6.0 KiB
Python
Executable File
190 lines
6.0 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
import argparse
|
|
import json
|
|
import re
|
|
import urllib.request
|
|
import xml.etree.ElementTree as ET
|
|
import zipfile
|
|
|
|
NS_MAIN = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
|
|
NS_REL = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
|
|
NS_PKG_REL = "http://schemas.openxmlformats.org/package/2006/relationships"
|
|
|
|
NS = {
|
|
"m": NS_MAIN,
|
|
"r": NS_REL,
|
|
"pr": NS_PKG_REL,
|
|
}
|
|
|
|
CONSOLE_TO_BRAND = {
|
|
"NES": "NINTENDO",
|
|
"SNES": "NINTENDO",
|
|
"WII": "NINTENDO",
|
|
"PS1": "SONY",
|
|
"PS2": "SONY",
|
|
"PS3": "SONY",
|
|
"PS4": "SONY",
|
|
"PS5": "SONY",
|
|
"XBOX 360": "MICROSOFT",
|
|
}
|
|
|
|
|
|
def col_to_index(col: str) -> int:
|
|
idx = 0
|
|
for ch in col:
|
|
idx = idx * 26 + (ord(ch) - 64)
|
|
return idx
|
|
|
|
|
|
def normalize_console(sheet_name: str) -> str:
|
|
normalized = sheet_name.strip().upper()
|
|
if normalized == "WII":
|
|
return "Wii"
|
|
return sheet_name.strip()
|
|
|
|
|
|
def to_number(value):
|
|
if value is None:
|
|
return None
|
|
if isinstance(value, (int, float)):
|
|
return float(value)
|
|
text = str(value).strip().replace(",", ".")
|
|
if text == "":
|
|
return None
|
|
try:
|
|
return float(text)
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def parse_xlsx(path: str):
|
|
brands = {}
|
|
games_by_console = {}
|
|
|
|
with zipfile.ZipFile(path) as zf:
|
|
shared = []
|
|
if "xl/sharedStrings.xml" in zf.namelist():
|
|
shared_root = ET.fromstring(zf.read("xl/sharedStrings.xml"))
|
|
for si in shared_root.findall("m:si", NS):
|
|
text = "".join(t.text or "" for t in si.findall(".//m:t", NS))
|
|
shared.append(text)
|
|
|
|
wb = ET.fromstring(zf.read("xl/workbook.xml"))
|
|
rels = ET.fromstring(zf.read("xl/_rels/workbook.xml.rels"))
|
|
rel_map = {rel.attrib["Id"]: rel.attrib["Target"] for rel in rels.findall("pr:Relationship", NS)}
|
|
|
|
sheets = []
|
|
for sheet in wb.findall(".//m:sheets/m:sheet", NS):
|
|
name = sheet.attrib["name"]
|
|
rid = sheet.attrib[f"{{{NS_REL}}}id"]
|
|
target = rel_map[rid]
|
|
if not target.startswith("xl/"):
|
|
target = "xl/" + target
|
|
sheets.append((name, target))
|
|
|
|
for sheet_name, target in sheets:
|
|
console_name = normalize_console(sheet_name)
|
|
brand = CONSOLE_TO_BRAND.get(sheet_name.strip().upper())
|
|
if not brand:
|
|
continue
|
|
|
|
brands.setdefault(brand, [])
|
|
if console_name not in brands[brand]:
|
|
brands[brand].append(console_name)
|
|
games_by_console.setdefault(console_name, [])
|
|
|
|
root = ET.fromstring(zf.read(target))
|
|
rows = root.findall(".//m:sheetData/m:row", NS)
|
|
|
|
for row in rows:
|
|
cells = row.findall("m:c", NS)
|
|
values = {i: "" for i in range(1, 7)}
|
|
|
|
for cell in cells:
|
|
ref = cell.attrib.get("r", "A1")
|
|
match = re.match(r"[A-Z]+", ref)
|
|
if not match:
|
|
continue
|
|
idx = col_to_index(match.group(0))
|
|
if idx > 6:
|
|
continue
|
|
|
|
cell_type = cell.attrib.get("t")
|
|
value_elem = cell.find("m:v", NS)
|
|
value = ""
|
|
if value_elem is not None and value_elem.text is not None:
|
|
if cell_type == "s":
|
|
try:
|
|
value = shared[int(value_elem.text)]
|
|
except (ValueError, IndexError):
|
|
value = value_elem.text
|
|
else:
|
|
value = value_elem.text
|
|
values[idx] = value
|
|
|
|
title = str(values[1]).strip()
|
|
version = str(values[2]).strip()
|
|
duplicate_raw = str(values[3]).strip()
|
|
purchase_price = to_number(values[4])
|
|
cote = to_number(values[5])
|
|
condition = to_number(values[6])
|
|
|
|
if version.upper() == "VERSION":
|
|
continue
|
|
|
|
if not title:
|
|
continue
|
|
|
|
if re.fullmatch(r"\d+(\.\d+)?", title) and not any([version, duplicate_raw, purchase_price, cote, condition]):
|
|
continue
|
|
|
|
games_by_console[console_name].append(
|
|
{
|
|
"title": title,
|
|
"version": version,
|
|
"isDuplicate": duplicate_raw.upper() == "OUI",
|
|
"purchasePrice": purchase_price,
|
|
"value": cote,
|
|
"condition": condition,
|
|
"genre": "",
|
|
"publisher": "",
|
|
"year": None,
|
|
"loanedTo": "",
|
|
}
|
|
)
|
|
|
|
return {"brands": brands, "gamesByConsole": games_by_console}
|
|
|
|
|
|
def post_import(api_base: str, payload: dict):
|
|
req = urllib.request.Request(
|
|
f"{api_base.rstrip('/')}/api/catalog/import",
|
|
data=json.dumps(payload).encode("utf-8"),
|
|
headers={"Content-Type": "application/json"},
|
|
method="POST",
|
|
)
|
|
with urllib.request.urlopen(req) as resp:
|
|
body = resp.read().decode("utf-8")
|
|
return json.loads(body)
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="Import COLLECTIONS.xlsx to video game DB API")
|
|
parser.add_argument("xlsx_path", help="Path to COLLECTIONS.xlsx")
|
|
parser.add_argument("--api-base", default="http://127.0.0.1:7001", help="API base URL")
|
|
args = parser.parse_args()
|
|
|
|
payload = parse_xlsx(args.xlsx_path)
|
|
result = post_import(args.api_base, payload)
|
|
|
|
total_games = sum(len(v) for v in payload["gamesByConsole"].values())
|
|
print(json.dumps({
|
|
"sheetsImported": list(payload["gamesByConsole"].keys()),
|
|
"parsedGames": total_games,
|
|
"apiResult": result,
|
|
}, ensure_ascii=True, indent=2))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|