Added Flask-based web application for reviewing and editing H&M product data across multiple languages. Includes inline editing, approval workflow, change logging, and multi-image support. Key components: - server.py: Flask API with thread-safe JSON read/write - html_generator.py: Standalone HTML generator with language utilities - static/index.html: Interactive review UI with dynamic column management - CLAUDE.md: Comprehensive development guide - Sample campaign data (1022A, 2023) with images - Updated .gitignore for Python projects Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
816 lines
29 KiB
Python
816 lines
29 KiB
Python
"""
|
|
HTML Site Generator for H&M EMS Product Data.
|
|
|
|
Generates a self-contained HTML page with embedded CSS/JS that displays
|
|
product data grouped by Article ID, with a language selector to switch
|
|
between translation variants and an export function for tracking edits.
|
|
"""
|
|
|
|
import os
|
|
import json
|
|
import shutil
|
|
from datetime import datetime
|
|
|
|
|
|
# Language display name mapping
|
|
LANGUAGE_DISPLAY_NAMES = {
|
|
"ar-jo": "Arabic (Jordan)",
|
|
"ar-ma": "Arabic (Morocco)",
|
|
"bg-bg": "Bulgarian (Bulgaria)",
|
|
"bs-ba": "Bosnian (Bosnia and Herzegovina)",
|
|
"ca-es": "Catalan (Spain)",
|
|
"cs-cz": "Czech (Czech Republic)",
|
|
"da-dk": "Danish (Denmark)",
|
|
"de-at": "German (Austria)",
|
|
"de-ch": "German (Switzerland)",
|
|
"de-de": "German (Germany)",
|
|
"el-gr": "Greek (Greece)",
|
|
"en-au": "English (Australia)",
|
|
"en-ca": "English (Canada)",
|
|
"en-cn": "English (China)",
|
|
"en-gb": "English (Great Britain)",
|
|
"en-hk": "English (Hong Kong)",
|
|
"en-id": "English (Indonesia)",
|
|
"en-ie": "English (Ireland)",
|
|
"en-in": "English (India)",
|
|
"en-kh": "English (Cambodia)",
|
|
"en-my": "English (Malaysia)",
|
|
"en-nz": "English (New Zealand)",
|
|
"en-ph": "English (Philippines)",
|
|
"en-pr": "English (Puerto Rico)",
|
|
"en-sg": "English (Singapore)",
|
|
"en-th": "English (Thailand)",
|
|
"en-tw": "English (Taiwan)",
|
|
"en-us": "English (United States)",
|
|
"en-za": "English (South Africa)",
|
|
"es-cl": "Spanish (Chile)",
|
|
"es-co": "Spanish (Colombia)",
|
|
"es-cr": "Spanish (Costa Rica)",
|
|
"es-ec": "Spanish (Ecuador)",
|
|
"es-es": "Spanish (Spain)",
|
|
"es-gt": "Spanish (Guatemala)",
|
|
"es-mx": "Spanish (Mexico)",
|
|
"es-pe": "Spanish (Peru)",
|
|
"es-uy": "Spanish (Uruguay)",
|
|
"et-ee": "Estonian (Estonia)",
|
|
"fi-fi": "Finnish (Finland)",
|
|
"fr-be": "French (Belgium)",
|
|
"fr-ca": "French (Canada)",
|
|
"fr-ch": "French (Switzerland)",
|
|
"fr-fr": "French (France)",
|
|
"fr-lu": "French (Luxembourg)",
|
|
"he-il": "Hebrew (Israel)",
|
|
"hr-hr": "Croatian (Croatia)",
|
|
"hu-hu": "Hungarian (Hungary)",
|
|
"is-is": "Icelandic (Iceland)",
|
|
"it-ch": "Italian (Switzerland)",
|
|
"it-it": "Italian (Italy)",
|
|
"ja-jp": "Japanese (Japan)",
|
|
"ka-ge": "Georgian (Georgia)",
|
|
"ko-kr": "Korean (South Korea)",
|
|
"lt-lt": "Lithuanian (Lithuania)",
|
|
"lv-lv": "Latvian (Latvia)",
|
|
"mk-mk": "Macedonian (North Macedonia)",
|
|
"nb-no": "Norwegian (Norway)",
|
|
"nl-be": "Dutch (Belgium)",
|
|
"nl-nl": "Dutch (Netherlands)",
|
|
"pl-pl": "Polish (Poland)",
|
|
"pt-pt": "Portuguese (Portugal)",
|
|
"ro-ro": "Romanian (Romania)",
|
|
"sk-sk": "Slovak (Slovakia)",
|
|
"sl-si": "Slovenian (Slovenia)",
|
|
"sq-xk": "Albanian (Kosovo)",
|
|
"sr-rs": "Serbian (Serbia)",
|
|
"sv-se": "Swedish (Sweden)",
|
|
"tr-tr": "Turkish (Turkey)",
|
|
"uk-ua": "Ukrainian (Ukraine)",
|
|
"vi-vn": "Vietnamese (Vietnam)",
|
|
"zh-cn": "Chinese (China)",
|
|
"zh-hk": "Chinese (Hong Kong)",
|
|
"zh-tw": "Chinese (Taiwan)",
|
|
}
|
|
|
|
|
|
def _get_campaign_prefix(json_filename):
|
|
"""Extract campaign prefix from JSON filename (text before first underscore)."""
|
|
name = os.path.splitext(os.path.basename(json_filename))[0]
|
|
return name.split("_")[0]
|
|
|
|
|
|
def _get_main_image_filename(filename_field):
|
|
"""
|
|
Extract the main campaign image filename from the Filename field.
|
|
Returns the first .tif filename converted to .jpg, or None.
|
|
"""
|
|
if not filename_field:
|
|
return None
|
|
filenames = [f.strip() for f in filename_field.split(",")]
|
|
for fn in filenames:
|
|
if fn.lower().endswith(".tif"):
|
|
return os.path.splitext(fn)[0] + ".jpg"
|
|
return None
|
|
|
|
|
|
def _get_all_image_filenames(filename_field):
|
|
"""
|
|
Extract all campaign image filenames from the Filename field.
|
|
Returns list of .tif filenames converted to .jpg.
|
|
"""
|
|
if not filename_field:
|
|
return []
|
|
filenames = [f.strip() for f in filename_field.split(",")]
|
|
results = []
|
|
for fn in filenames:
|
|
if fn.lower().endswith(".tif"):
|
|
results.append(os.path.splitext(fn)[0] + ".jpg")
|
|
return results
|
|
|
|
|
|
def _copy_campaign_images(json_data, output_dir, image_source_dir):
|
|
"""
|
|
Copy the first campaign image per unique Article id to output_dir/images/.
|
|
Returns a dict mapping Article id -> relative image path (or None).
|
|
"""
|
|
images_dir = os.path.join(output_dir, "images")
|
|
os.makedirs(images_dir, exist_ok=True)
|
|
|
|
# Collect unique article ids and their image filenames
|
|
article_images = {}
|
|
for record in json_data:
|
|
article_id = record.get("Article id", "")
|
|
if article_id in article_images:
|
|
continue
|
|
img_name = _get_main_image_filename(record.get("Filename", ""))
|
|
article_images[article_id] = img_name
|
|
|
|
# Copy images
|
|
image_map = {}
|
|
for article_id, img_name in article_images.items():
|
|
if not img_name:
|
|
image_map[article_id] = None
|
|
continue
|
|
|
|
src = os.path.join(image_source_dir, img_name)
|
|
if os.path.isfile(src):
|
|
dst = os.path.join(images_dir, img_name)
|
|
if not os.path.isfile(dst):
|
|
shutil.copy2(src, dst)
|
|
image_map[article_id] = f"images/{img_name}"
|
|
else:
|
|
image_map[article_id] = None
|
|
|
|
return image_map
|
|
|
|
|
|
def generate_html_site(json_data, output_dir, json_filename, image_base_path):
|
|
"""
|
|
Generate an HTML site from processed JSON data.
|
|
|
|
Args:
|
|
json_data: List of dicts (the processed JSON records)
|
|
output_dir: Path to write the HTML site
|
|
json_filename: Original JSON filename (for campaign prefix)
|
|
image_base_path: Base path for campaign images
|
|
"""
|
|
os.makedirs(output_dir, exist_ok=True)
|
|
|
|
campaign_prefix = _get_campaign_prefix(json_filename)
|
|
image_source_dir = os.path.join(
|
|
image_base_path, campaign_prefix, "Automation_LR"
|
|
)
|
|
|
|
# Copy images and get mapping
|
|
image_map = _copy_campaign_images(json_data, output_dir, image_source_dir)
|
|
|
|
# Derive campaign and season from first record
|
|
campaign_name = json_data[0].get("Campaign", campaign_prefix) if json_data else campaign_prefix
|
|
season = json_data[0].get("Season", "") if json_data else ""
|
|
|
|
# Collect all languages (lowercased for consistent keying)
|
|
languages = sorted(
|
|
set(r.get("Language", "").lower() for r in json_data if r.get("Language")),
|
|
)
|
|
|
|
# Build the language display names JSON for JS
|
|
lang_display = {}
|
|
for lang in languages:
|
|
lang_display[lang] = LANGUAGE_DISPLAY_NAMES.get(lang, lang)
|
|
|
|
# Serialise data for JS embedding
|
|
js_data = json.dumps(json_data, ensure_ascii=False)
|
|
js_image_map = json.dumps(image_map, ensure_ascii=False)
|
|
js_lang_display = json.dumps(lang_display, ensure_ascii=False)
|
|
|
|
generation_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
|
|
html = _build_html(
|
|
campaign_name=campaign_name,
|
|
season=season,
|
|
json_filename=json_filename,
|
|
js_data=js_data,
|
|
js_image_map=js_image_map,
|
|
js_lang_display=js_lang_display,
|
|
languages=languages,
|
|
lang_display=lang_display,
|
|
generation_time=generation_time,
|
|
)
|
|
|
|
output_path = os.path.join(output_dir, "index.html")
|
|
with open(output_path, "w", encoding="utf-8") as f:
|
|
f.write(html)
|
|
|
|
print(f"HTML site generated: {output_path}")
|
|
print(f" Campaign: {campaign_name} | Season: {season}")
|
|
print(f" Languages: {len(languages)} | Records: {len(json_data)}")
|
|
copied_count = sum(1 for v in image_map.values() if v is not None)
|
|
print(f" Images copied: {copied_count}/{len(image_map)}")
|
|
|
|
return output_path
|
|
|
|
|
|
def _build_html(
|
|
campaign_name,
|
|
season,
|
|
json_filename,
|
|
js_data,
|
|
js_image_map,
|
|
js_lang_display,
|
|
languages,
|
|
lang_display,
|
|
generation_time,
|
|
):
|
|
"""Build the complete self-contained HTML string."""
|
|
|
|
# Build language option tags
|
|
lang_options = ""
|
|
for lang in languages:
|
|
if lang == "en-gb":
|
|
continue # en-gb is always shown as the master column
|
|
display = lang_display.get(lang, lang)
|
|
lang_options += f' <option value="{lang}">{display}</option>\n'
|
|
|
|
# Pick a sensible default target language (first non-en-gb, or de-de if available)
|
|
default_target = ""
|
|
if "de-de" in languages:
|
|
default_target = "de-de"
|
|
elif "fr-fr" in languages:
|
|
default_target = "fr-fr"
|
|
else:
|
|
for l in languages:
|
|
if l != "en-gb":
|
|
default_target = l
|
|
break
|
|
|
|
html = f"""<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>H&M EMS - {campaign_name} | {season}</title>
|
|
<style>
|
|
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
|
|
body {{
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
|
background: #f0f0f0; color: #333; font-size: 13px;
|
|
}}
|
|
|
|
/* Header */
|
|
.header {{
|
|
background: #333; color: #fff; padding: 0 20px; height: 44px;
|
|
display: flex; align-items: center; justify-content: space-between;
|
|
}}
|
|
.header-left {{ display: flex; align-items: center; gap: 16px; }}
|
|
.header-brand {{ font-size: 18px; font-weight: 700; color: #cc0000; letter-spacing: 1px; }}
|
|
.header-campaign {{ font-size: 14px; font-weight: 500; }}
|
|
.header-campaign span {{ color: #aaa; font-weight: 400; margin-left: 6px; }}
|
|
.header-right {{ display: flex; align-items: center; gap: 10px; }}
|
|
.header-meta {{ font-size: 10px; color: #999; text-align: right; line-height: 1.4; }}
|
|
.btn {{
|
|
padding: 6px 14px; border: none; border-radius: 3px; cursor: pointer;
|
|
font-size: 11px; font-weight: 600; text-transform: uppercase;
|
|
letter-spacing: 0.5px; transition: background 0.2s;
|
|
}}
|
|
.btn-export {{ background: #cc0000; color: #fff; }}
|
|
.btn-export:hover {{ background: #a30000; }}
|
|
.btn-export:disabled {{ background: #888; cursor: default; }}
|
|
|
|
/* Toolbar */
|
|
.toolbar {{
|
|
background: #fff; border-bottom: 1px solid #ddd; padding: 0 20px; height: 38px;
|
|
display: flex; align-items: center; gap: 12px;
|
|
}}
|
|
.toolbar .stats {{
|
|
margin-left: auto; font-size: 11px; color: #888; white-space: nowrap;
|
|
}}
|
|
.toolbar .stats strong {{ color: #cc0000; }}
|
|
.approved-count {{ color: #4caf50; }}
|
|
|
|
.sticky-top {{ position: sticky; top: 0; z-index: 100; }}
|
|
.table-container {{ padding: 0 0 40px; overflow-x: auto; }}
|
|
|
|
.product-table {{
|
|
border-collapse: collapse; background: #fff;
|
|
box-shadow: 0 1px 3px rgba(0,0,0,0.1); min-width: 100%;
|
|
}}
|
|
.product-table thead th {{
|
|
background: #333; color: #fff; font-size: 10px; font-weight: 600;
|
|
text-transform: uppercase; letter-spacing: 0.4px; padding: 6px 8px;
|
|
text-align: left; white-space: nowrap; border-right: 1px solid #444;
|
|
}}
|
|
.product-table thead th:last-child {{ border-right: none; }}
|
|
.product-table thead th.lang-group-header {{
|
|
background: #3a3a3a; text-align: center; padding: 5px 6px;
|
|
border-left: 2px solid #666;
|
|
}}
|
|
.product-table thead th.lang-group-header.empty-col {{
|
|
background: #2a2a2a;
|
|
}}
|
|
.product-table thead th.lang-sub-header {{
|
|
background: #484848; font-size: 9px; padding: 4px 6px;
|
|
}}
|
|
.product-table thead th.lang-sub-header-first {{ border-left: 2px solid #666; }}
|
|
|
|
.product-table tbody td {{
|
|
padding: 5px 8px; border-bottom: 1px solid #e5e5e5;
|
|
border-right: 1px solid #e5e5e5; vertical-align: middle; font-size: 12px;
|
|
}}
|
|
.product-table tbody td:last-child {{ border-right: none; }}
|
|
.product-table tbody td.lang-group-first {{ border-left: 2px solid #ddd; }}
|
|
.product-table tbody td.empty-cell {{ background: #fafafa; }}
|
|
.product-table tbody tr:nth-child(even) {{ background: #f5f5f5; }}
|
|
.product-table tbody tr:nth-child(even) td.empty-cell {{ background: #f2f2f2; }}
|
|
.product-table tbody tr:hover {{ background: #eef4ff; }}
|
|
|
|
.col-visual {{ width: 90px; text-align: center; }}
|
|
.col-article {{ width: 90px; font-family: 'SF Mono','Consolas',monospace; font-size: 11px; }}
|
|
.col-gb-name {{ width: 160px; font-size: 12px; }}
|
|
|
|
.product-image {{ max-width: 80px; max-height: 80px; object-fit: contain; border-radius: 2px; }}
|
|
.image-placeholder {{
|
|
width: 60px; height: 60px; background: #e0e0e0; border-radius: 2px;
|
|
display: inline-flex; align-items: center; justify-content: center;
|
|
color: #aaa; font-size: 9px;
|
|
}}
|
|
|
|
.edit-input {{
|
|
width: 100%; padding: 3px 6px; border: 1px solid #ddd; border-radius: 2px;
|
|
font-size: 12px; font-family: inherit; background: #fff;
|
|
transition: border-color 0.15s, background 0.15s;
|
|
}}
|
|
.edit-input:focus {{
|
|
outline: none; border-color: #cc0000;
|
|
box-shadow: 0 0 0 2px rgba(204,0,0,0.1);
|
|
}}
|
|
.edit-input.changed {{ background: #fff8e1; border-color: #f5a623; }}
|
|
.edit-input.differs-from-gb {{ background: #fffde7; }}
|
|
.edit-input.changed.differs-from-gb {{ background: #fff3cd; border-color: #f5a623; }}
|
|
.edit-input:disabled {{
|
|
background: #f0f0f0; color: #999; border-color: #e0e0e0; cursor: not-allowed;
|
|
}}
|
|
|
|
.approve-btn {{
|
|
width: 24px; height: 24px; border-radius: 50%; border: 2px solid #ccc;
|
|
background: #fff; cursor: pointer; display: inline-flex;
|
|
align-items: center; justify-content: center; transition: all 0.15s;
|
|
font-size: 13px; color: #ccc; padding: 0; line-height: 1;
|
|
}}
|
|
.approve-btn:hover {{ border-color: #4caf50; color: #4caf50; }}
|
|
.approve-btn.approved {{ background: #4caf50; border-color: #4caf50; color: #fff; }}
|
|
.col-approve {{ width: 34px; text-align: center; }}
|
|
|
|
.lang-header-controls {{
|
|
display: inline-flex; align-items: center; gap: 6px;
|
|
}}
|
|
.lang-header-controls select {{
|
|
padding: 3px 4px; font-size: 11px; border: 1px solid #666;
|
|
border-radius: 2px; background: #555; color: #fff; max-width: 180px;
|
|
}}
|
|
.lang-header-controls select option {{ background: #444; color: #fff; }}
|
|
.lang-remove-btn {{
|
|
width: 18px; height: 18px; border-radius: 50%; border: 1px solid #888;
|
|
background: transparent; color: #aaa; cursor: pointer; font-size: 14px;
|
|
display: inline-flex; align-items: center; justify-content: center;
|
|
padding: 0; line-height: 1; transition: all 0.15s;
|
|
}}
|
|
.lang-remove-btn:hover {{ background: #cc0000; border-color: #cc0000; color: #fff; }}
|
|
|
|
.change-count {{
|
|
display: inline-block; background: #cc0000; color: #fff; border-radius: 10px;
|
|
padding: 1px 6px; font-size: 10px; font-weight: 600; margin-left: 4px;
|
|
min-width: 16px; text-align: center;
|
|
}}
|
|
.change-count.hidden {{ display: none; }}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<div class="sticky-top">
|
|
<div class="header">
|
|
<div class="header-left">
|
|
<div class="header-brand">H&M</div>
|
|
<div class="header-campaign">
|
|
Campaign {campaign_name}
|
|
<span>Season {season}</span>
|
|
</div>
|
|
</div>
|
|
<div class="header-right">
|
|
<div class="header-meta">
|
|
Source: {os.path.basename(json_filename)}<br>
|
|
Generated: {generation_time}
|
|
</div>
|
|
<button class="btn btn-export" id="exportBtn" onclick="exportChanges()" disabled>
|
|
Export Changes <span class="change-count hidden" id="changeBadge">0</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="toolbar">
|
|
<div class="stats">
|
|
<span id="productCount">0</span> products ·
|
|
<span id="langCount">{len(languages)}</span> languages ·
|
|
<strong><span id="editCount">0</span> edits</strong> ·
|
|
<span class="approved-count"><span id="approvedCount">0</span> approved</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="table-container">
|
|
<table class="product-table" id="productTable">
|
|
<thead id="tableHead"></thead>
|
|
<tbody id="tableBody"></tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<script>
|
|
// ========== Embedded Data ==========
|
|
const RAW_DATA = {js_data};
|
|
const IMAGE_MAP = {js_image_map};
|
|
const LANG_DISPLAY = {js_lang_display};
|
|
const MAX_LANG_COLS = 4;
|
|
|
|
// ========== State ==========
|
|
// langColumns entries: "" = empty/pending dropdown, "xx-yy" = selected language
|
|
let dataByArticle = {{}};
|
|
let articleOrder = [];
|
|
let langColumns = [];
|
|
let changes = {{}};
|
|
let approvals = {{}};
|
|
|
|
const ALL_LANGS = Object.keys(LANG_DISPLAY).filter(function(l) {{ return l !== "en-gb"; }}).sort();
|
|
|
|
function langOptionsHtml(selectedLang) {{
|
|
var h = '<option value="">-- Select Language --</option>';
|
|
ALL_LANGS.forEach(function(l) {{
|
|
var sel = (l === selectedLang) ? " selected" : "";
|
|
h += '<option value="' + l + '"' + sel + '>' + LANG_DISPLAY[l] + '</option>';
|
|
}});
|
|
return h;
|
|
}}
|
|
|
|
// Count how many columns have a language actually selected
|
|
function filledCount() {{
|
|
var n = 0;
|
|
langColumns.forEach(function(l) {{ if (l !== "") n++; }});
|
|
return n;
|
|
}}
|
|
|
|
// Ensure there is exactly one trailing empty dropdown (if under max)
|
|
function ensureTrailingEmpty() {{
|
|
// Remove any trailing empties beyond one
|
|
while (langColumns.length > 1 && langColumns[langColumns.length - 1] === "" && langColumns[langColumns.length - 2] === "") {{
|
|
langColumns.pop();
|
|
}}
|
|
// Add one empty if the last column is filled and we're under max
|
|
if (langColumns.length < MAX_LANG_COLS && (langColumns.length === 0 || langColumns[langColumns.length - 1] !== "")) {{
|
|
langColumns.push("");
|
|
}}
|
|
}}
|
|
|
|
// ========== Initialisation ==========
|
|
function init() {{
|
|
RAW_DATA.forEach(function(rec) {{
|
|
var artId = rec["Article id"];
|
|
var lang = (rec["Language"] || "").toLowerCase();
|
|
if (!dataByArticle[artId]) {{
|
|
dataByArticle[artId] = {{}};
|
|
articleOrder.push(artId);
|
|
}}
|
|
dataByArticle[artId][lang] = rec;
|
|
}});
|
|
|
|
document.getElementById("productCount").textContent = articleOrder.length;
|
|
|
|
// Start with one empty dropdown — no language pre-selected
|
|
langColumns = [""];
|
|
|
|
rebuildAll();
|
|
}}
|
|
|
|
// ========== Full rebuild ==========
|
|
function rebuildAll() {{
|
|
buildHead();
|
|
buildBody();
|
|
populateAllColumns();
|
|
updateApprovedCount();
|
|
}}
|
|
|
|
function buildHead() {{
|
|
var thead = document.getElementById("tableHead");
|
|
var r1 = '<tr>';
|
|
r1 += '<th class="col-visual" rowspan="2">Visual</th>';
|
|
r1 += '<th class="col-article" rowspan="2">Article ID</th>';
|
|
r1 += '<th class="col-gb-name" rowspan="2">Product Name (English)</th>';
|
|
|
|
langColumns.forEach(function(lang, idx) {{
|
|
var isEmpty = (lang === "");
|
|
var cls = "lang-group-header" + (isEmpty ? " empty-col" : "");
|
|
// Show remove button only on filled columns when there are multiple filled
|
|
var showRemove = !isEmpty && filledCount() > 1;
|
|
r1 += '<th class="' + cls + '" colspan="3">';
|
|
r1 += '<div class="lang-header-controls">';
|
|
r1 += '<select onchange="onLangColChange(' + idx + ', this.value)">' + langOptionsHtml(lang) + '</select>';
|
|
if (showRemove) {{
|
|
r1 += '<button class="lang-remove-btn" onclick="removeLanguageColumn(' + idx + ')" title="Remove">−</button>';
|
|
}}
|
|
r1 += '</div></th>';
|
|
}});
|
|
r1 += '</tr>';
|
|
|
|
var r2 = '<tr>';
|
|
langColumns.forEach(function(lang, idx) {{
|
|
var isEmpty = (lang === "");
|
|
var label1 = isEmpty ? "" : "Product Name";
|
|
var label2 = isEmpty ? "" : "Price";
|
|
var label3 = isEmpty ? "" : "OK";
|
|
r2 += '<th class="lang-sub-header lang-sub-header-first">' + label1 + '</th>';
|
|
r2 += '<th class="lang-sub-header">' + label2 + '</th>';
|
|
r2 += '<th class="lang-sub-header col-approve">' + label3 + '</th>';
|
|
}});
|
|
r2 += '</tr>';
|
|
|
|
thead.innerHTML = r1 + r2;
|
|
}}
|
|
|
|
function buildBody() {{
|
|
var tbody = document.getElementById("tableBody");
|
|
var html = "";
|
|
|
|
articleOrder.forEach(function(artId) {{
|
|
var gbRec = dataByArticle[artId]["en-gb"];
|
|
var gbName = gbRec ? (gbRec["Product name"] || "") : "";
|
|
var imgPath = IMAGE_MAP[artId];
|
|
|
|
var imgHtml = imgPath
|
|
? '<img class="product-image" src="' + imgPath + '" alt="' + artId + '" loading="lazy">'
|
|
: '<div class="image-placeholder">No Image</div>';
|
|
|
|
html += '<tr data-article="' + artId + '" id="row_' + artId + '">';
|
|
html += '<td class="col-visual">' + imgHtml + '</td>';
|
|
html += '<td class="col-article">' + artId + '</td>';
|
|
html += '<td class="col-gb-name">' + escapeHtml(gbName) + '</td>';
|
|
|
|
langColumns.forEach(function(lang, idx) {{
|
|
var cid = idx + '_' + artId;
|
|
if (lang === "") {{
|
|
// Empty column — no inputs, just grey cells
|
|
html += '<td class="lang-group-first empty-cell"></td>';
|
|
html += '<td class="empty-cell"></td>';
|
|
html += '<td class="col-approve empty-cell"></td>';
|
|
}} else {{
|
|
html += '<td class="lang-group-first"><input type="text" class="edit-input" id="name_' + cid + '" data-article="' + artId + '" data-field="Product name" data-colidx="' + idx + '" onchange="onEdit(this)" oninput="onInputChange(this)"></td>';
|
|
html += '<td><input type="text" class="edit-input" id="price_' + cid + '" data-article="' + artId + '" data-field="Price" data-colidx="' + idx + '" onchange="onEdit(this)" oninput="onInputChange(this)"></td>';
|
|
html += '<td class="col-approve"><button class="approve-btn" id="approve_' + cid + '" onclick="toggleApprove(\\x27' + artId + '\\x27,' + idx + ')" title="Approve">✓</button></td>';
|
|
}}
|
|
}});
|
|
|
|
html += '</tr>';
|
|
}});
|
|
|
|
tbody.innerHTML = html;
|
|
}}
|
|
|
|
// ========== Populate ==========
|
|
function populateAllColumns() {{
|
|
langColumns.forEach(function(lang, idx) {{
|
|
if (lang !== "") populateColumn(idx);
|
|
}});
|
|
}}
|
|
|
|
function populateColumn(idx) {{
|
|
var lang = langColumns[idx];
|
|
if (!lang) return;
|
|
|
|
articleOrder.forEach(function(artId) {{
|
|
var rec = dataByArticle[artId][lang];
|
|
var gbRec = dataByArticle[artId]["en-gb"];
|
|
var cid = idx + '_' + artId;
|
|
|
|
var nameInput = document.getElementById("name_" + cid);
|
|
var priceInput = document.getElementById("price_" + cid);
|
|
var approveBtn = document.getElementById("approve_" + cid);
|
|
if (!nameInput || !priceInput) return;
|
|
|
|
var targetName = rec ? (rec["Product name"] || "") : "";
|
|
var targetPrice = rec ? (rec["Price"] || "") : "";
|
|
var gbName = gbRec ? (gbRec["Product name"] || "") : "";
|
|
|
|
var nameChangeKey = artId + "::" + lang + "::Product name";
|
|
var priceChangeKey = artId + "::" + lang + "::Price";
|
|
var approveKey = artId + "::" + lang;
|
|
var isApproved = !!approvals[approveKey];
|
|
|
|
nameInput.value = changes[nameChangeKey] ? changes[nameChangeKey]["New value"] : targetName;
|
|
priceInput.value = changes[priceChangeKey] ? changes[priceChangeKey]["New value"] : targetPrice;
|
|
|
|
nameInput.classList.toggle("changed", !!changes[nameChangeKey]);
|
|
priceInput.classList.toggle("changed", !!changes[priceChangeKey]);
|
|
|
|
nameInput.setAttribute("data-original", targetName);
|
|
priceInput.setAttribute("data-original", targetPrice);
|
|
nameInput.setAttribute("data-lang", lang);
|
|
priceInput.setAttribute("data-lang", lang);
|
|
|
|
var differs = targetName !== "" && targetName.toUpperCase() !== gbName.toUpperCase();
|
|
nameInput.classList.toggle("differs-from-gb", differs);
|
|
|
|
nameInput.disabled = isApproved;
|
|
priceInput.disabled = isApproved;
|
|
if (approveBtn) approveBtn.classList.toggle("approved", isApproved);
|
|
}});
|
|
}}
|
|
|
|
// ========== Language column management ==========
|
|
function onLangColChange(idx, newLang) {{
|
|
langColumns[idx] = newLang;
|
|
ensureTrailingEmpty();
|
|
rebuildAll();
|
|
}}
|
|
|
|
function removeLanguageColumn(idx) {{
|
|
langColumns.splice(idx, 1);
|
|
if (langColumns.length === 0) langColumns.push("");
|
|
ensureTrailingEmpty();
|
|
rebuildAll();
|
|
}}
|
|
|
|
// ========== Event Handlers ==========
|
|
function onInputChange(el) {{
|
|
var original = el.getAttribute("data-original");
|
|
el.classList.toggle("changed", el.value !== original);
|
|
}}
|
|
|
|
function onEdit(el) {{
|
|
var artId = el.getAttribute("data-article");
|
|
var field = el.getAttribute("data-field");
|
|
var lang = el.getAttribute("data-lang");
|
|
var original = el.getAttribute("data-original");
|
|
var newVal = el.value;
|
|
var changeKey = artId + "::" + lang + "::" + field;
|
|
|
|
if (newVal !== original) {{
|
|
changes[changeKey] = {{
|
|
"Article id": artId,
|
|
"Language": lang,
|
|
"Field": field,
|
|
"Original value": original,
|
|
"New value": newVal,
|
|
"Country": getCountryForArticleLang(artId, lang),
|
|
"Product id": getProductIdForArticle(artId),
|
|
"Timestamp": new Date().toISOString()
|
|
}};
|
|
el.classList.add("changed");
|
|
}} else {{
|
|
delete changes[changeKey];
|
|
el.classList.remove("changed");
|
|
}}
|
|
updateEditCount();
|
|
}}
|
|
|
|
function toggleApprove(artId, colIdx) {{
|
|
var lang = langColumns[colIdx];
|
|
if (!lang) return;
|
|
var approveKey = artId + "::" + lang;
|
|
var cid = colIdx + '_' + artId;
|
|
var btn = document.getElementById("approve_" + cid);
|
|
var nameInput = document.getElementById("name_" + cid);
|
|
var priceInput = document.getElementById("price_" + cid);
|
|
|
|
if (approvals[approveKey]) {{
|
|
delete approvals[approveKey];
|
|
if (btn) btn.classList.remove("approved");
|
|
if (nameInput) nameInput.disabled = false;
|
|
if (priceInput) priceInput.disabled = false;
|
|
}} else {{
|
|
approvals[approveKey] = true;
|
|
if (btn) btn.classList.add("approved");
|
|
if (nameInput) nameInput.disabled = true;
|
|
if (priceInput) priceInput.disabled = true;
|
|
}}
|
|
updateApprovedCount();
|
|
}}
|
|
|
|
function getCountryForArticleLang(artId, lang) {{
|
|
var rec = dataByArticle[artId] && dataByArticle[artId][lang];
|
|
return rec ? (rec["Country"] || "") : "";
|
|
}}
|
|
function getProductIdForArticle(artId) {{
|
|
var rec = dataByArticle[artId] && dataByArticle[artId]["en-gb"];
|
|
return rec ? (rec["Product id"] || "") : "";
|
|
}}
|
|
|
|
function updateEditCount() {{
|
|
var count = Object.keys(changes).length;
|
|
document.getElementById("editCount").textContent = count;
|
|
var badge = document.getElementById("changeBadge");
|
|
var btn = document.getElementById("exportBtn");
|
|
if (count > 0) {{
|
|
badge.textContent = count;
|
|
badge.classList.remove("hidden");
|
|
btn.disabled = false;
|
|
}} else {{
|
|
badge.classList.add("hidden");
|
|
btn.disabled = true;
|
|
}}
|
|
}}
|
|
|
|
function updateApprovedCount() {{
|
|
document.getElementById("approvedCount").textContent = Object.keys(approvals).length;
|
|
}}
|
|
|
|
// ========== Export ==========
|
|
function exportChanges() {{
|
|
var changeList = Object.values(changes);
|
|
if (changeList.length === 0) return;
|
|
changeList.forEach(function(c) {{
|
|
c["Approved"] = !!approvals[c["Article id"] + "::" + c["Language"]];
|
|
}});
|
|
var exportData = {{
|
|
"export_date": new Date().toISOString(),
|
|
"source_file": "{os.path.basename(json_filename)}",
|
|
"total_changes": changeList.length,
|
|
"total_approved": Object.keys(approvals).length,
|
|
"changes": changeList
|
|
}};
|
|
var blob = new Blob([JSON.stringify(exportData, null, 2)], {{type: "application/json"}});
|
|
var url = URL.createObjectURL(blob);
|
|
var a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = "ems_changes_" + new Date().toISOString().slice(0,19).replace(/[:T]/g,"_") + ".json";
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
}}
|
|
|
|
function escapeHtml(str) {{
|
|
if (!str) return "";
|
|
return str.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");
|
|
}}
|
|
|
|
document.addEventListener("DOMContentLoaded", init);
|
|
</script>
|
|
|
|
</body>
|
|
</html>"""
|
|
|
|
return html
|
|
|
|
|
|
# ========== CLI entry point ==========
|
|
if __name__ == "__main__":
|
|
import sys
|
|
|
|
if len(sys.argv) < 2:
|
|
print("Usage: python html_generator.py <json_file> [output_dir] [image_base_path]")
|
|
print()
|
|
print("Arguments:")
|
|
print(" json_file Path to the processed JSON file")
|
|
print(" output_dir Output directory (default: ./html_output/<campaign>)")
|
|
print(" image_base_path Base path for campaign images (default: standard Box path)")
|
|
sys.exit(1)
|
|
|
|
json_file = sys.argv[1]
|
|
|
|
# Default image base path
|
|
default_image_base = (
|
|
"/Users/pauljohns/Library/CloudStorage/Box-Box/"
|
|
"H&M - Global Team/H_M_GLOBAL_TEAM/HM/COMPANY_ASSETS/"
|
|
"CAMPAIGN_IMAGES/2025"
|
|
)
|
|
|
|
# Determine output directory
|
|
if len(sys.argv) >= 3:
|
|
out_dir = sys.argv[2]
|
|
else:
|
|
prefix = _get_campaign_prefix(json_file)
|
|
out_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "html_output", prefix)
|
|
|
|
# Determine image base path
|
|
img_base = sys.argv[3] if len(sys.argv) >= 4 else default_image_base
|
|
|
|
# Load JSON
|
|
with open(json_file, "r", encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
|
|
print(f"Loaded {len(data)} records from {os.path.basename(json_file)}")
|
|
|
|
generate_html_site(data, out_dir, json_file, img_base)
|