hm_ems_report/html_generator.py
Vadym Samoilenko 5904326d0c Add H&M EMS Product Review Tool with complete project structure
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>
2026-03-11 21:05:03 +00:00

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&amp;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&amp;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 &middot;
<span id="langCount">{len(languages)}</span> languages &middot;
<strong><span id="editCount">0</span> edits</strong> &middot;
<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">&minus;</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">&#10003;</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,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");
}}
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)