UNPKG

shaka-player

Version:
1,732 lines • 50.8 kB
<!DOCTYPE html>
<!--
 Copyright 2016 Google LLC
 SPDX-License-Identifier: Apache-2.0
-->
<!--
  Translations dashboard for the Shaka Player UI.

  Read-only report: open this page from any web server that serves
  ui/locales/ (for example, the nightly demo on GitHub Pages).
  Editor: run `python3 build/translations.py`, which serves this page and
  saves edits straight to ui/locales/*.json.

  This page is a standalone tool.  It is not part of any build.
-->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>UI Translations</title>
<style>
@font-face {
  /* Tengwar glyphs for the Sindarin (sjn) translation, only loaded for the
   * Private Use Area codepoints they live in. */
  font-family: "Tengwar Telcontar";
  src: url("../../demo/TengwarTelcontar.woff2") format("woff2");
  unicode-range: U+E000-E0FF;
}

:root {
  color-scheme: light;
  --bg: #f6f7f9;
  --surface: #ffffff;
  --surface-2: #f0f2f5;
  --border: #dde1e6;
  --text: #1b1f24;
  --text-2: #5b6470;
  --accent: #1f6feb;
  --accent-text: #ffffff;
  --focus: #1f6feb;

  --st-ok: #2da44e;
  --st-ok-bg: #e6f4ea;
  --st-missing: #d1242f;
  --st-missing-bg: #fde8e8;
  --st-issue: #d4a106;
  --st-issue-bg: #fff6d6;
  --st-same: #e8cf6a;
  --st-same-bg: #fffbea;
  --st-orphan: #e16f24;
  --st-orphan-bg: #fff0e3;
  --st-empty: #eceef1;

  --warn-bg: #fff6d6;
  --warn-border: #e9cf72;
  --info-bg: #e8f0fe;
  --info-border: #b5ccf7;
  --shadow: 0 1px 2px rgba(0, 0, 0, 0.06), 0 2px 8px rgba(0, 0, 0, 0.04);
}

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    color-scheme: dark;
    --bg: #0f1216;
    --surface: #171b21;
    --surface-2: #1f242c;
    --border: #2c333d;
    --text: #e6e9ed;
    --text-2: #9aa4b0;
    --accent: #4c8dff;
    --accent-text: #0b0e12;
    --focus: #6ea2ff;

    --st-ok: #3fb950;
    --st-ok-bg: #12261a;
    --st-missing: #f85149;
    --st-missing-bg: #2d1415;
    --st-issue: #e3b341;
    --st-issue-bg: #2b2412;
    --st-same: #a8913e;
    --st-same-bg: #24210f;
    --st-orphan: #f0883e;
    --st-orphan-bg: #2d1c0f;
    --st-empty: #232931;

    --warn-bg: #2b2412;
    --warn-border: #6b5520;
    --info-bg: #13213a;
    --info-border: #2a4475;
    --shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
  }
}

* { box-sizing: border-box; }

html, body { margin: 0; }

body {
  background: var(--bg);
  color: var(--text);
  font: 14px/1.45 system-ui, -apple-system, "Segoe UI", Roboto,
      "Tengwar Telcontar", sans-serif;
}

button, input, select, textarea { font: inherit; color: inherit; }

:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }

a { color: var(--accent); }

code, .mono {
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: 12.5px;
}

.wrap { max-width: 1280px; margin: 0 auto; padding: 0 16px; }

header.top {
  background: var(--surface);
  border-bottom: 1px solid var(--border);
}

header.top .wrap {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px 16px;
  padding-top: 14px;
  padding-bottom: 0;
}

header.top h1 { font-size: 18px; margin: 0; font-weight: 650; }

.mode {
  font-size: 12px;
  padding: 2px 8px;
  border-radius: 999px;
  border: 1px solid var(--border);
  color: var(--text-2);
}
.mode.server { border-color: var(--st-ok); color: var(--st-ok); }

nav.tabs { display: flex; gap: 4px; width: 100%; margin-top: 6px; }

nav.tabs a {
  padding: 8px 12px;
  text-decoration: none;
  color: var(--text-2);
  border-bottom: 2px solid transparent;
  font-weight: 550;
}
nav.tabs a[aria-current="page"] {
  color: var(--text);
  border-bottom-color: var(--accent);
}

main { padding: 16px 0 96px; }

.stats {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
  gap: 12px;
  margin-bottom: 16px;
}
.stat {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 10px;
  padding: 12px 14px;
  box-shadow: var(--shadow);
}
.stat .v {
  font-size: 22px;
  font-weight: 650;
  font-variant-numeric: tabular-nums;
}
.stat .l { color: var(--text-2); font-size: 12.5px; }

.toolbar {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  align-items: center;
  margin-bottom: 12px;
}
.toolbar .grow { flex: 1 1 200px; }

input[type="search"], input[type="text"], select {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 7px 10px;
  min-width: 0;
}
input[type="search"] { width: 100%; }

.btn {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 7px 12px;
  cursor: pointer;
  font-weight: 550;
  white-space: nowrap;
}
.btn:hover { background: var(--surface-2); }
.btn.primary {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--accent-text);
}
.btn.primary:hover { filter: brightness(1.08); }
.btn.small { padding: 3px 8px; font-size: 12.5px; }
.btn:disabled { opacity: 0.5; cursor: default; filter: none; }

.chips { display: flex; flex-wrap: wrap; gap: 6px; }
.chip {
  border: 1px solid var(--border);
  background: var(--surface);
  border-radius: 999px;
  padding: 4px 10px;
  cursor: pointer;
  font-size: 13px;
  display: inline-flex;
  align-items: center;
  gap: 6px;
}
.chip[aria-pressed="true"] {
  border-color: var(--accent);
  box-shadow: inset 0 0 0 1px var(--accent);
}
.chip .n { color: var(--text-2); font-variant-numeric: tabular-nums; }

.dot {
  width: 10px;
  height: 10px;
  border-radius: 3px;
  display: inline-block;
  flex: none;
}
.s-ok { background: var(--st-ok); }
.s-missing { background: var(--st-missing); }
.s-issue { background: var(--st-issue); }
.s-same {
  background: repeating-linear-gradient(
      135deg, var(--st-same) 0 2px, var(--st-same-bg) 2px 4px);
  box-shadow: inset 0 0 0 1px var(--st-same);
}
.s-orphan { background: var(--st-orphan); }
.s-none { background: var(--st-empty); }

.card {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 10px;
  box-shadow: var(--shadow);
}

/* Languages table */
.table-scroll { overflow-x: auto; }
table.langs { width: 100%; border-collapse: collapse; }
table.langs th, table.langs td {
  padding: 9px 12px;
  border-bottom: 1px solid var(--border);
  text-align: left;
  white-space: nowrap;
}
table.langs th {
  font-size: 12px;
  color: var(--text-2);
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.03em;
}
table.langs th button {
  all: unset;
  cursor: pointer;
}
table.langs th button:focus-visible { outline: 2px solid var(--focus); }
table.langs tbody tr { cursor: pointer; }
table.langs tbody tr:hover { background: var(--surface-2); }
table.langs tbody tr:last-child td { border-bottom: 0; }
table.langs td.num {
  text-align: right;
  font-variant-numeric: tabular-nums;
}
table.langs .name { font-weight: 550; }
table.langs .code { color: var(--text-2); }

.bar {
  display: flex;
  width: 180px;
  height: 8px;
  border-radius: 4px;
  overflow: hidden;
  background: var(--st-empty);
}
.bar span { display: block; height: 100%; }
.pct {
  font-variant-numeric: tabular-nums;
  width: 44px;
  display: inline-block;
  text-align: right;
}
.cov { display: inline-flex; align-items: center; gap: 10px; }

.badge {
  display: inline-block;
  font-size: 11.5px;
  padding: 1px 7px;
  border-radius: 999px;
  border: 1px solid var(--border);
  color: var(--text-2);
  font-weight: 550;
}
.badge.google { color: var(--accent); border-color: var(--info-border); }
.badge.community { color: var(--st-ok); border-color: var(--st-ok); }
.badge.generated { color: var(--text-2); }
.badge.dirty {
  color: var(--st-orphan);
  border-color: var(--st-orphan);
}

/* Matrix */
.legend {
  display: flex;
  flex-wrap: wrap;
  gap: 6px 16px;
  font-size: 13px;
  color: var(--text-2);
}
.legend span { display: inline-flex; align-items: center; gap: 6px; }

.matrix-scroll {
  overflow: auto;
  max-height: calc(100vh - 230px);
  min-height: 300px;
}
table.matrix { border-collapse: separate; border-spacing: 2px; }
table.matrix th {
  position: sticky;
  background: var(--surface);
  z-index: 1;
  font-weight: 500;
}
table.matrix thead th {
  top: 0;
  vertical-align: bottom;
  padding: 4px 0;
  z-index: 2;
}
table.matrix thead th a {
  writing-mode: vertical-rl;
  transform: rotate(180deg);
  text-decoration: none;
  color: var(--text-2);
  font-size: 12px;
  display: inline-block;
}
table.matrix thead th a:hover { color: var(--accent); }
table.matrix thead th.corner { left: 0; z-index: 3; }
table.matrix tbody th {
  left: 0;
  text-align: left;
  padding: 0 10px 0 4px;
  white-space: nowrap;
}
table.matrix tbody th .mono { font-size: 12px; }
table.matrix tbody th .miss {
  color: var(--st-missing);
  font-size: 11.5px;
  margin-left: 6px;
  font-variant-numeric: tabular-nums;
}
table.matrix td { padding: 0; }
table.matrix td a {
  display: block;
  width: 16px;
  height: 16px;
  border-radius: 3px;
}
table.matrix td a:hover {
  outline: 2px solid var(--text);
  outline-offset: 0;
}

/* Editor */
.lang-head {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 6px 14px;
  margin-bottom: 12px;
}
.lang-head h2 { margin: 0; font-size: 20px; }

.note {
  border: 1px solid var(--info-border);
  background: var(--info-bg);
  border-radius: 8px;
  padding: 10px 12px;
  margin-bottom: 12px;
}
.note.warn { border-color: var(--warn-border); background: var(--warn-bg); }

.rows { display: flex; flex-direction: column; gap: 8px; }
.row {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1.1fr);
  gap: 6px 16px;
  padding: 12px 14px 12px 16px;
  background: var(--surface);
  border: 1px solid var(--border);
  border-left-width: 4px;
  border-radius: 8px;
}
.row.st-ok { border-left-color: var(--st-ok); }
.row.st-missing {
  border-left-color: var(--st-missing);
  background: var(--st-missing-bg);
}
.row.st-issue {
  border-left-color: var(--st-issue);
  background: var(--st-issue-bg);
}
.row.st-same {
  border-left-color: var(--st-same);
  background: var(--st-same-bg);
}
.row.st-orphan {
  border-left-color: var(--st-orphan);
  background: var(--st-orphan-bg);
}
.row.flash { animation: flash 1.2s ease-out; }
@keyframes flash {
  0% { box-shadow: 0 0 0 3px var(--accent); }
  100% { box-shadow: 0 0 0 0 transparent; }
}
.row .meta { min-width: 0; }
.row .key {
  display: flex;
  gap: 8px;
  align-items: center;
  flex-wrap: wrap;
}
.row .key code { font-weight: 600; }
.row .desc { color: var(--text-2); font-size: 13px; margin-top: 4px; }
.row .ref { margin-top: 6px; }
.row .ref .lbl {
  font-size: 11px;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--text-2);
}
.row .problem { font-size: 12.5px; margin-top: 4px; color: var(--text); }
.row .problem::before { content: "⚠ "; }
.row textarea {
  width: 100%;
  min-height: 42px;
  resize: vertical;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 6px;
  padding: 8px 10px;
  font-family: system-ui, -apple-system, "Segoe UI", Roboto,
      "Tengwar Telcontar", sans-serif;
}
.row textarea:read-only { background: var(--surface-2); }
.row .edit { display: flex; flex-direction: column; gap: 6px; }
.row .edit .acts {
  display: flex;
  gap: 6px;
  justify-content: flex-end;
  flex-wrap: wrap;
}
.row .changed {
  font-size: 12px;
  color: var(--st-orphan);
  margin-right: auto;
  align-self: center;
}

.savebar {
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  background: var(--surface);
  border-top: 1px solid var(--border);
  box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.08);
  z-index: 5;
}
.savebar .wrap {
  display: flex;
  gap: 8px;
  align-items: center;
  padding-top: 10px;
  padding-bottom: 10px;
  flex-wrap: wrap;
}
.savebar .msg { flex: 1 1 200px; color: var(--text-2); }

.empty {
  padding: 40px 16px;
  text-align: center;
  color: var(--text-2);
}

dialog {
  border: 1px solid var(--border);
  border-radius: 12px;
  background: var(--surface);
  color: var(--text);
  padding: 0;
  width: min(460px, calc(100vw - 32px));
  box-shadow: 0 10px 40px rgba(0, 0, 0, 0.25);
}
#ed-badges { display: inline-flex; gap: 6px; }

dialog::backdrop { background: rgba(0, 0, 0, 0.35); }
dialog form { padding: 18px 20px; }
dialog h3 { margin: 0 0 12px; font-size: 17px; }
dialog label {
  display: block;
  font-weight: 550;
  margin-bottom: 4px;
}
dialog input[type="text"] { width: 100%; }
dialog .hint {
  font-size: 12.5px;
  color: var(--text-2);
  margin: 6px 0 0;
  min-height: 1.4em;
}
dialog .hint.err { color: var(--st-missing); }
dialog .acts {
  display: flex;
  gap: 8px;
  justify-content: flex-end;
  margin-top: 16px;
}

#toast {
  position: fixed;
  left: 50%;
  bottom: 76px;
  transform: translateX(-50%);
  background: var(--text);
  color: var(--bg);
  padding: 8px 14px;
  border-radius: 8px;
  font-size: 13px;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.2s;
  z-index: 10;
  max-width: calc(100vw - 32px);
}
#toast.show { opacity: 1; }

[hidden] { display: none !important; }

@media (max-width: 760px) {
  .row { grid-template-columns: minmax(0, 1fr); }
  .bar { width: 90px; }
  table.langs .hide-sm { display: none; }
}
</style>
</head>
<body>
<header class="top">
  <div class="wrap">
    <h1>Shaka Player UI translations</h1>
    <span id="mode" class="mode">Loading…</span>
    <nav class="tabs" aria-label="Views">
      <a href="#/languages" data-view="languages">Languages</a>
      <a href="#/matrix" data-view="matrix">Matrix</a>
      <a href="#/edit" data-view="edit" id="edit-tab" hidden>Edit</a>
    </nav>
  </div>
</header>

<main class="wrap">
  <div id="error" class="note warn" hidden></div>

  <!-- Languages overview -->
  <section id="view-languages" hidden>
    <div class="stats" id="stats"></div>
    <div class="toolbar">
      <div class="grow">
        <input type="search" id="lang-search"
            placeholder="Filter languages by name or code"
            aria-label="Filter languages">
      </div>
      <select id="lang-owner" aria-label="Maintainer">
        <option value="">All maintainers</option>
        <option value="google">Google</option>
        <option value="community">Community</option>
        <option value="generated">Generated</option>
      </select>
      <button class="btn primary" id="add-lang" type="button">
        + Add language
      </button>
    </div>
    <div class="card table-scroll">
      <table class="langs">
        <thead>
          <tr>
            <th><button type="button" data-sort="name">Language</button></th>
            <th class="hide-sm">Maintainer</th>
            <th><button type="button" data-sort="coverage">Coverage</button>
            </th>
            <th class="num">
              <button type="button" data-sort="missing">Missing</button>
            </th>
            <th class="num hide-sm">
              <button type="button" data-sort="issues">Check</button>
            </th>
            <th class="num hide-sm">
              <button type="button" data-sort="orphans">Obsolete</button>
            </th>
          </tr>
        </thead>
        <tbody id="lang-rows"></tbody>
      </table>
    </div>
  </section>

  <!-- Keys × languages heat map -->
  <section id="view-matrix" hidden>
    <div class="toolbar">
      <div class="legend" id="legend"></div>
      <span class="grow"></span>
      <label class="chip">
        <input type="checkbox" id="mx-gaps"> Only keys with gaps
      </label>
      <label class="chip">
        <input type="checkbox" id="mx-generated"> Show pseudo-locales
      </label>
    </div>
    <div class="card matrix-scroll">
      <table class="matrix" id="matrix"></table>
    </div>
  </section>

  <!-- Single language editor -->
  <section id="view-edit" hidden>
    <div class="lang-head">
      <h2 id="ed-title"></h2>
      <span id="ed-badges"></span>
      <span id="ed-cov" class="cov"></span>
    </div>
    <div id="ed-note" class="note" hidden></div>
    <div class="toolbar">
      <div class="chips" id="ed-filters" role="group"
          aria-label="Filter by status"></div>
      <div class="grow">
        <input type="search" id="ed-search"
            placeholder="Search keys, English or translations"
            aria-label="Search keys">
      </div>
    </div>
    <div class="rows" id="ed-rows"></div>
  </section>
</main>

<div class="savebar" id="savebar" hidden>
  <div class="wrap">
    <span class="msg" id="save-msg"></span>
    <button class="btn" id="revert" type="button">Revert</button>
    <button class="btn primary" id="save" type="button">Save</button>
  </div>
</div>

<dialog id="add-dialog">
  <form method="dialog" id="add-form">
    <h3>Add a language</h3>
    <label for="add-code">Locale code</label>
    <input type="text" id="add-code" autocomplete="off" spellcheck="false"
        placeholder="e.g. eu, gl, pt-AO">
    <p class="hint" id="add-hint">
      A BCP-47 code: language, optionally followed by a region or script.
    </p>
    <div class="acts">
      <button class="btn" value="cancel" type="submit"
          formnovalidate>Cancel</button>
      <button class="btn primary" value="ok" type="submit"
          id="add-ok" disabled>Create</button>
    </div>
  </form>
</dialog>

<div id="toast" role="status" aria-live="polite"></div>

<script>
'use strict';

/* ------------------------------------------------------------------ */
/* Configuration                                                      */
/* ------------------------------------------------------------------ */

// Pseudo-locales generated automatically by Google's internal systems.
const GENERATED = {
  'ar-XB': 'Pseudo-locale: right-to-left English',
  'en-XA': 'Pseudo-locale: accented English',
};

// Locales synced from Google's internal translation system (see
// ui/locales/README.md).  Everything else is maintained by the community,
// including any language added later.
const GOOGLE = new Set([
  'ar', 'az', 'be', 'bg', 'br', 'ca', 'cs', 'da', 'de', 'el', 'en', 'en-GB',
  'es', 'es-419', 'fa', 'fi', 'fil', 'fr', 'hi', 'hr', 'hu', 'id', 'it', 'iw',
  'ja', 'ko', 'lt', 'lv', 'nl', 'no', 'pl', 'pt-BR', 'pt-PT', 'ro', 'ru',
  'sk', 'sl', 'sr', 'sv', 'th', 'tr', 'uk', 'vi', 'zh', 'zh-HK', 'zh-TW',
]);

const SPECIAL_NAMES = {
  'sjn': 'Sindarin',
  'ar-XB': 'RTL pseudo-locale',
  'en-XA': 'Accented pseudo-locale',
};

// Messages that are names, and so usually stay the same in every language.
const NAMES = new Set(['AIRPLAY']);

const RTL_LANGUAGES = new Set(['ar', 'fa', 'he', 'iw', 'ps', 'ur', 'yi']);

const LOCALE_RE = /^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$/;

const GITHUB_LISTING_URL = 'https://api.github.com/repos/' +
    'shaka-project/shaka-player/contents/ui/locales?ref=main';

const STATUS = {
  ok: {label: 'Translated', cls: 's-ok'},
  missing: {label: 'Missing', cls: 's-missing'},
  issue: {label: 'Placeholders differ from English', cls: 's-issue'},
  same: {label: 'Identical to English', cls: 's-same'},
  orphan: {label: 'Obsolete (not in source.json)', cls: 's-orphan'},
  none: {label: 'Not present', cls: 's-none'},
};

/* ------------------------------------------------------------------ */
/* State                                                              */
/* ------------------------------------------------------------------ */

const state = {
  // 'server' when build/translations.py can save, otherwise 'static'.
  mode: 'static',
  // Message definitions from source.json.
  source: {},
  sourceKeys: [],
  // English reference text per key: the source message without the
  // ":example" part of each placeholder.
  english: {},
  // code -> {original, data, raw, escapeNonAscii, isNew}
  locales: {},
  langSort: {by: 'name', dir: 1},
  edFilter: 'all',
};

/* ------------------------------------------------------------------ */
/* Helpers                                                            */
/* ------------------------------------------------------------------ */

const $ = (id) => document.getElementById(id);

/**
 * Creates an element.  Children may be strings (as text) or nodes.
 */
function el(tag, attrs, ...children) {
  const node = document.createElement(tag);
  for (const [k, v] of Object.entries(attrs || {})) {
    if (v == null || v === false) {
      continue;
    }
    if (k === 'class') {
      node.className = v;
    } else if (k === 'text') {
      node.textContent = v;
    } else if (k.startsWith('on')) {
      node.addEventListener(k.slice(2), v);
    } else {
      node.setAttribute(k, v === true ? '' : v);
    }
  }
  for (const child of children.flat()) {
    if (child == null || child === false) {
      continue;
    }
    node.append(child instanceof Node ? child : String(child));
  }
  return node;
}

let displayNames = null;
try {
  displayNames = new Intl.DisplayNames(['en'], {type: 'language'});
} catch (e) {
  // Old browser: show codes only.
}

function languageName(code) {
  if (SPECIAL_NAMES[code]) {
    return SPECIAL_NAMES[code];
  }
  if (displayNames) {
    try {
      const name = displayNames.of(code);
      if (name && name !== code) {
        return name;
      }
    } catch (e) {
      // Invalid tag.
    }
  }
  return code;
}

function maintainer(code) {
  if (GENERATED[code]) {
    return 'generated';
  }
  return GOOGLE.has(code) ? 'google' : 'community';
}

function isEnglish(code) {
  return code === 'en' || code.startsWith('en-');
}

function isRtl(code) {
  return code === 'ar-XB' || RTL_LANGUAGES.has(code.split('-')[0]);
}

function placeholders(text) {
  // Also match "[NAME:example]", copied by mistake from source.json, so that
  // it is reported.  The accented pseudo-locale wraps whole messages in
  // brackets, which must not match.
  return (text.match(/\[[A-Z][A-Z0-9_]*(:[^\]\n]*)?\]/g) || []).sort();
}

function sameList(a, b) {
  return a.length === b.length && a.every((x, i) => x === b[i]);
}

let toastTimer = null;
function toast(message) {
  const t = $('toast');
  t.textContent = message;
  t.classList.add('show');
  clearTimeout(toastTimer);
  toastTimer = setTimeout(() => t.classList.remove('show'), 2600);
}

/* ------------------------------------------------------------------ */
/* Status computation                                                 */
/* ------------------------------------------------------------------ */

/** Returns the status of one key in one locale, plus a reason if any. */
function cellStatus(code, key) {
  const data = state.locales[code].data;
  const value = Object.prototype.hasOwnProperty.call(data, key) ?
      data[key] : null;
  if (!(key in state.source)) {
    return value == null ? {status: 'none'} : {status: 'orphan'};
  }
  if (value == null || value.trim() === '') {
    return {status: 'missing'};
  }
  const expected = placeholders(state.english[key]);
  const actual = placeholders(value);
  if (!sameList(expected, actual)) {
    const want = expected.length ? expected.join(' ') : 'none';
    const got = actual.length ? actual.join(' ') : 'none';
    return {
      status: 'issue',
      reason: `Placeholders should be ${want}, found ${got}.`,
    };
  }
  if (!isEnglish(code) && !NAMES.has(key) &&
      value === state.english[key]) {
    return {
      status: 'same',
      reason: 'Identical to English.  This is fine if the word is the ' +
          'same in this language, otherwise it is probably untranslated.',
    };
  }
  return {status: 'ok'};
}

function orphanKeys(code) {
  return Object.keys(state.locales[code].data)
      .filter((k) => !(k in state.source));
}

function summary(code) {
  const counts = {ok: 0, missing: 0, issue: 0, same: 0, orphan: 0};
  for (const key of state.sourceKeys) {
    counts[cellStatus(code, key).status]++;
  }
  counts.orphan = orphanKeys(code).length;
  const total = state.sourceKeys.length;
  const translated = total - counts.missing;
  return {
    ...counts,
    total,
    translated,
    coverage: total ? translated / total : 1,
  };
}

/* ------------------------------------------------------------------ */
/* Serialization: byte-identical to Python's json.dumps(data,        */
/* indent=2, sort_keys=True, ensure_ascii=...) + '\n', which is what  */
/* the "locales" check in build/check.py enforces.                     */
/* ------------------------------------------------------------------ */

function quote(text, escapeNonAscii) {
  let out = JSON.stringify(text);
  if (escapeNonAscii) {
    out = out.replace(/[\u0080-￿]/g,
        (c) => '\\u' + c.charCodeAt(0).toString(16).padStart(4, '0'));
  }
  return out;
}

function serialize(loc) {
  const keys = Object.keys(loc.data).sort();
  if (!keys.length) {
    return '{}\n';
  }
  const lines = keys.map((k) =>
    '  ' + quote(k, loc.escapeNonAscii) + ': ' +
    quote(loc.data[k], loc.escapeNonAscii));
  return '{\n' + lines.join(',\n') + '\n}\n';
}

function isDirty(code) {
  const loc = state.locales[code];
  return loc.isNew || serialize(loc) !== loc.raw;
}

function dirtyLocales() {
  return Object.keys(state.locales).filter(isDirty);
}

/* ------------------------------------------------------------------ */
/* Loading                                                            */
/* ------------------------------------------------------------------ */

async function fetchText(url) {
  const response = await fetch(url, {cache: 'no-store'});
  if (!response.ok) {
    throw new Error(`${url}: HTTP ${response.status}`);
  }
  return response.text();
}

/** Returns the locale codes, and sets state.mode. */
async function listLocales() {
  // 1. Local editor server (build/translations.py).
  try {
    const response = await fetch('/api/locales', {cache: 'no-store'});
    const type = response.headers.get('Content-Type') || '';
    if (response.ok && type.includes('json')) {
      state.mode = 'server';
      return (await response.json()).locales;
    }
  } catch (e) {
    // Not served by the editor server.
  }

  // 2. A static server with directory listings (python3 -m http.server).
  try {
    const html = await fetchText('./');
    const codes = new Set();
    for (const match of html.matchAll(/href="([^"/?#]+)\.json"/g)) {
      codes.add(decodeURIComponent(match[1]));
    }
    codes.delete('source');
    codes.delete('app_manifest');
    if (codes.size) {
      return [...codes].filter((c) => LOCALE_RE.test(c)).sort();
    }
  } catch (e) {
    // No directory listing.
  }

  // 3. GitHub Pages: ask GitHub which files exist on main, then load them
  //    from this deployment.
  const listing = JSON.parse(await fetchText(GITHUB_LISTING_URL));
  return listing
      .map((f) => f.name)
      .filter((n) => n.endsWith('.json') && n !== 'source.json')
      .map((n) => n.slice(0, -5))
      .filter((c) => LOCALE_RE.test(c))
      .sort();
}

async function load() {
  const sourceText = await fetchText('source.json');
  state.source = JSON.parse(sourceText);
  state.sourceKeys = Object.keys(state.source);
  for (const key of state.sourceKeys) {
    state.english[key] = state.source[key].message
        .replace(/\[([A-Z0-9_]+):[^\]]*\]/g, '[$1]');
  }

  const codes = await listLocales();
  const results = await Promise.allSettled(
      codes.map((code) => fetchText(`${code}.json`)));
  const failed = [];
  results.forEach((result, i) => {
    const code = codes[i];
    if (result.status !== 'fulfilled') {
      failed.push(code);
      return;
    }
    const raw = result.value;
    const data = JSON.parse(raw);
    state.locales[code] = {
      original: {...data},
      data,
      raw,
      escapeNonAscii: /\\u[0-9a-fA-F]{4}/.test(raw),
      isNew: false,
    };
  });

  // Self-check: saving an untouched file must not change it.
  for (const code of Object.keys(state.locales)) {
    if (isDirty(code)) {
      console.warn(`${code}.json would be reformatted when saved.`);
    }
  }

  if (failed.length) {
    showError(`Could not load: ${failed.map((c) => c + '.json').join(', ')}`);
  }
}

function showError(message) {
  const box = $('error');
  box.textContent = message;
  box.hidden = false;
}

/* ------------------------------------------------------------------ */
/* Languages view                                                     */
/* ------------------------------------------------------------------ */

function coverageBar(sum) {
  const pct = (n) => (100 * n / sum.total) + '%';
  const ok = sum.ok;
  return el('span', {class: 'cov'},
      el('span', {class: 'bar', 'aria-hidden': 'true'},
          el('span', {class: 's-ok', style: `width:${pct(ok)}`}),
          el('span', {class: 's-same', style: `width:${pct(sum.same)}`}),
          el('span', {class: 's-issue', style: `width:${pct(sum.issue)}`})),
      el('span', {class: 'pct', text: Math.floor(sum.coverage * 100) + '%'}));
}

function badges(code) {
  const who = maintainer(code);
  const label = {google: 'Google', community: 'Community',
    generated: 'Generated'}[who];
  const result = [el('span', {class: `badge ${who}`, text: label})];
  if (isDirty(code)) {
    result.push(el('span', {class: 'badge dirty',
      text: state.locales[code].isNew ? 'new, unsaved' : 'unsaved'}));
  }
  return result;
}

function renderStats() {
  const codes = Object.keys(state.locales);
  const sums = codes.map(summary);
  const complete = sums.filter((s) => s.missing === 0).length;
  const missing = sums.reduce((n, s) => n + s.missing, 0);
  const avg = sums.length ?
      sums.reduce((n, s) => n + s.coverage, 0) / sums.length : 0;
  const stat = (v, l) => el('div', {class: 'stat'},
      el('div', {class: 'v', text: v}), el('div', {class: 'l', text: l}));
  $('stats').replaceChildren(
      stat(codes.length, 'languages'),
      stat(state.sourceKeys.length, 'messages in source.json'),
      stat(`${complete} / ${codes.length}`, 'fully translated'),
      stat(Math.round(avg * 100) + '%', 'average coverage'),
      stat(missing, 'missing translations'));
}

function renderLanguages() {
  renderStats();
  const query = $('lang-search').value.trim().toLowerCase();
  const owner = $('lang-owner').value;
  const rows = Object.keys(state.locales)
      .map((code) => ({code, name: languageName(code), sum: summary(code)}))
      .filter((r) => !owner || maintainer(r.code) === owner)
      .filter((r) => !query || r.code.toLowerCase().includes(query) ||
          r.name.toLowerCase().includes(query));

  const {by, dir} = state.langSort;
  const value = {
    name: (r) => r.name.toLowerCase(),
    coverage: (r) => r.sum.coverage,
    missing: (r) => r.sum.missing,
    issues: (r) => r.sum.issue + r.sum.same,
    orphans: (r) => r.sum.orphan,
  }[by];
  rows.sort((a, b) => {
    const x = value(a);
    const y = value(b);
    return (x < y ? -1 : x > y ? 1 : a.code.localeCompare(b.code)) * dir;
  });

  document.querySelectorAll('table.langs th button').forEach((b) => {
    const active = b.dataset.sort === by;
    b.closest('th').setAttribute('aria-sort',
        active ? (dir > 0 ? 'ascending' : 'descending') : 'none');
    b.textContent = b.textContent.replace(/ [▲▼]$/, '') +
        (active ? (dir > 0 ? ' ▲' : ' ▼') : '');
  });

  const body = $('lang-rows');
  if (!rows.length) {
    body.replaceChildren(el('tr', {},
        el('td', {colspan: 6, class: 'empty', text: 'No languages match.'})));
    return;
  }
  body.replaceChildren(...rows.map(({code, name, sum}) => el('tr', {
    tabindex: 0,
    onclick: () => location.hash = `#/edit/${code}`,
    onkeydown: (e) => {
      if (e.key === 'Enter') {
        location.hash = `#/edit/${code}`;
      }
    },
  },
  el('td', {},
      el('span', {class: 'name', text: name}), ' ',
      el('span', {class: 'code mono', text: code}), ' ',
      badges(code)[1]),
  el('td', {class: 'hide-sm'}, badges(code)[0]),
  el('td', {}, coverageBar(sum)),
  el('td', {class: 'num', text: sum.missing || '–'}),
  el('td', {class: 'num hide-sm', text: (sum.issue + sum.same) || '–'}),
  el('td', {class: 'num hide-sm', text: sum.orphan || '–'}))));
}

/* ------------------------------------------------------------------ */
/* Matrix view                                                        */
/* ------------------------------------------------------------------ */

function renderLegend() {
  $('legend').replaceChildren(...['ok', 'missing', 'issue', 'same', 'orphan']
      .map((s) => el('span', {},
          el('i', {class: `dot ${STATUS[s].cls}`}), STATUS[s].label)));
}

function renderMatrix() {
  const showGenerated = $('mx-generated').checked;
  const onlyGaps = $('mx-gaps').checked;
  const codes = Object.keys(state.locales).sort()
      .filter((c) => showGenerated || !GENERATED[c]);

  const allOrphans = new Set();
  for (const code of codes) {
    orphanKeys(code).forEach((k) => allOrphans.add(k));
  }
  let keys = [...state.sourceKeys, ...[...allOrphans].sort()];

  const statuses = {};
  for (const key of keys) {
    statuses[key] = codes.map((code) => cellStatus(code, key));
  }
  if (onlyGaps) {
    keys = keys.filter((k) => statuses[k].some((s) => s.status !== 'ok'));
  }

  const head = el('tr', {}, el('th', {class: 'corner'}),
      codes.map((code) => el('th', {scope: 'col'},
          el('a', {href: `#/edit/${code}`, title: languageName(code),
            text: code}))));

  const body = keys.map((key) => {
    const missing = statuses[key].filter((s) => s.status === 'missing').length;
    return el('tr', {},
        el('th', {scope: 'row', title: state.source[key] ?
            state.source[key].description : STATUS.orphan.label},
        el('span', {class: 'mono', text: key}),
        missing ? el('span', {class: 'miss', text: `${missing} missing`}) :
            null),
        statuses[key].map((s, i) => el('td', {},
            el('a', {
              class: STATUS[s.status].cls,
              href: `#/edit/${codes[i]}/${key}`,
              title: `${codes[i]} · ${key}: ${STATUS[s.status].label}`,
              'aria-label': `${codes[i]} ${key}: ${STATUS[s.status].label}`,
            }))));
  });

  $('matrix').replaceChildren(el('thead', {}, head), el('tbody', {}, body));
}

/* ------------------------------------------------------------------ */
/* Editor view                                                        */
/* ------------------------------------------------------------------ */

let editing = null;

function editorKeys(code) {
  return [...state.sourceKeys, ...orphanKeys(code)];
}

function renderEditor(code, focusKey) {
  const loc = state.locales[code];
  if (!loc) {
    showError(`Unknown language "${code}".`);
    location.hash = '#/languages';
    return;
  }
  const changedLanguage = editing !== code;
  editing = code;
  if (changedLanguage) {
    $('ed-search').value = '';
    state.edFilter = 'all';
  }
  const who = maintainer(code);
  const readOnly = who === 'generated';
  const sum = summary(code);

  $('edit-tab').hidden = false;
  $('edit-tab').href = `#/edit/${code}`;
  $('edit-tab').textContent = `Edit: ${languageName(code)}`;
  $('ed-title').replaceChildren(languageName(code), ' ',
      el('span', {class: 'code mono', text: code}));
  $('ed-badges').replaceChildren(...badges(code));
  $('ed-cov').replaceChildren(coverageBar(sum),
      el('span', {class: 'code',
        text: `${sum.translated} of ${sum.total} translated`}));

  const note = $('ed-note');
  note.hidden = false;
  note.className = 'note';
  if (readOnly) {
    note.textContent = `${GENERATED[code]}.  It is generated automatically ` +
        'by Google\'s internal systems and cannot be edited here.';
  } else if (who === 'google') {
    note.className = 'note warn';
    note.textContent = 'This translation is maintained by Google\'s ' +
        'internal translation system.  Edits are welcome, but a Googler ' +
        'needs to push them back into that system, or they may be ' +
        'overwritten by the next sync.';
  } else {
    note.textContent = 'This translation is maintained by the community.';
  }

  // Filter chips.
  const counts = {all: 0, missing: 0, issue: 0, same: 0, orphan: 0, ok: 0};
  for (const key of editorKeys(code)) {
    counts.all++;
    counts[cellStatus(code, key).status]++;
  }
  const filters = [
    ['all', 'All', null],
    ['missing', 'Missing', 's-missing'],
    ['issue', 'Placeholders', 's-issue'],
    ['same', 'Same as English', 's-same'],
    ['orphan', 'Obsolete', 's-orphan'],
    ['ok', 'Translated', 's-ok'],
  ].filter(([id]) => id === 'all' || counts[id]);
  if (!filters.some(([id]) => id === state.edFilter)) {
    state.edFilter = 'all';
  }
  $('ed-filters').replaceChildren(...filters.map(([id, label, cls]) =>
    el('button', {
      type: 'button',
      class: 'chip',
      'aria-pressed': String(state.edFilter === id),
      onclick: () => {
        state.edFilter = id;
        renderEditor(code);
      },
    }, cls ? el('i', {class: `dot ${cls}`}) : null, label,
    el('span', {class: 'n', text: counts[id]}))));

  // Rows.
  const query = $('ed-search').value.trim().toLowerCase();
  const rows = [];
  for (const key of editorKeys(code)) {
    const st = cellStatus(code, key);
    if (state.edFilter !== 'all' && st.status !== state.edFilter &&
        key !== focusKey) {
      continue;
    }
    const value = loc.data[key] != null ? loc.data[key] : '';
    const english = state.english[key];
    if (query && key !== focusKey &&
        ![key, english || '', value].some(
            (s) => s.toLowerCase().includes(query))) {
      continue;
    }
    rows.push(editorRow(code, key, st, readOnly));
  }
  $('ed-rows').replaceChildren(...(rows.length ? rows :
      [el('div', {class: 'card empty', text: 'Nothing to show.'})]));

  if (focusKey) {
    const row = document.querySelector(
        `.row[data-key="${CSS.escape(focusKey)}"]`);
    if (row) {
      row.scrollIntoView({block: 'center'});
      row.classList.add('flash');
      const input = row.querySelector('textarea');
      if (input && !readOnly) {
        input.focus({preventScroll: true});
      }
    }
  }
  updateSaveBar();
}

function editorRow(code, key, st, readOnly) {
  const loc = state.locales[code];
  const def = state.source[key];
  const original = loc.original[key];
  const value = loc.data[key] != null ? loc.data[key] : '';
  const changed = loc.data[key] !== original;

  const textarea = el('textarea', {
    'rows': 1,
    'lang': code,
    'dir': isRtl(code) ? 'rtl' : 'auto',
    'aria-label': `${key} in ${languageName(code)}`,
    'placeholder': st.status === 'missing' ? 'Missing translation' : null,
    'readonly': readOnly,
    'spellcheck': 'true',
  });
  textarea.value = value;
  textarea.addEventListener('input', () => {
    // Empty means "remove the key", which makes it fall back to English.
    if (textarea.value === '') {
      delete loc.data[key];
    } else {
      loc.data[key] = textarea.value;
    }
    refreshRow(row, code, key, readOnly);
    updateSaveBar();
  });
  textarea.addEventListener('change', () => renderEditorHeader(code));

  const acts = el('div', {class: 'acts'});
  const row = el('div', {class: `row st-${st.status}`, 'data-key': key},
      el('div', {class: 'meta'},
          el('div', {class: 'key'},
              el('i', {class: `dot ${STATUS[st.status].cls}`,
                title: STATUS[st.status].label}),
              el('code', {text: key})),
          def ? el('div', {class: 'desc', text: def.description}) :
              el('div', {class: 'desc',
                text: 'This key no longer exists in source.json, so the ' +
                    'player never uses it.  It can be removed.'}),
          def ? el('div', {class: 'ref'},
              el('span', {class: 'lbl', text: 'English  '}),
              el('span', {text: state.english[key]}),
              def.meaning ?
                  el('span', {class: 'code', text: ` (${def.meaning})`}) :
                  null) : null,
          el('div', {class: 'problem', hidden: !st.reason,
            text: st.reason || ''})),
      el('div', {class: 'edit'}, textarea, acts));
  row._textarea = textarea;
  row._acts = acts;
  fillRowActions(row, code, key, readOnly, changed);
  return row;
}

function fillRowActions(row, code, key, readOnly, changed) {
  const loc = state.locales[code];
  const acts = row._acts;
  const textarea = row._textarea;
  const items = [];
  if (changed) {
    items.push(el('span', {class: 'changed',
      text: loc.original[key] == null ? 'Added' :
          loc.data[key] == null ? 'Removed' : 'Changed'}));
  }
  if (!readOnly) {
    if (key in state.source && loc.data[key] == null) {
      items.push(el('button', {
        type: 'button', class: 'btn small',
        title: 'Start from the English text',
        onclick: () => {
          textarea.value = state.english[key];
          textarea.dispatchEvent(new Event('input'));
          textarea.focus();
        },
      }, 'Copy English'));
    }
    if (changed) {
      items.push(el('button', {
        type: 'button', class: 'btn small',
        onclick: () => {
          textarea.value = loc.original[key] != null ? loc.original[key] : '';
          textarea.dispatchEvent(new Event('input'));
        },
      }, 'Undo'));
    }
    if (!(key in state.source) && loc.data[key] != null) {
      items.push(el('button', {
        type: 'button', class: 'btn small',
        onclick: () => {
          textarea.value = '';
          textarea.dispatchEvent(new Event('input'));
        },
      }, 'Remove'));
    }
  }
  acts.replaceChildren(...items);
  acts.hidden = !items.length;
}

function refreshRow(row, code, key, readOnly) {
  const loc = state.locales[code];
  const st = cellStatus(code, key);
  row.className = `row st-${st.status}`;
  const dot = row.querySelector('.key .dot');
  dot.className = `dot ${STATUS[st.status].cls}`;
  dot.title = STATUS[st.status].label;
  const problem = row.querySelector('.problem');
  problem.hidden = !st.reason;
  problem.textContent = st.reason || '';
  row._textarea.placeholder =
      st.status === 'missing' ? 'Missing translation' : '';
  fillRowActions(row, code, key, readOnly,
      loc.data[key] !== loc.original[key]);
}

function renderEditorHeader(code) {
  if (editing !== code) {
    return;
  }
  const sum = summary(code);
  $('ed-badges').replaceChildren(...badges(code));
  $('ed-cov').replaceChildren(coverageBar(sum),
      el('span', {class: 'code',
        text: `${sum.translated} of ${sum.total} translated`}));
}

/* ------------------------------------------------------------------ */
/* Saving                                                             */
/* ------------------------------------------------------------------ */

function changeCount(code) {
  const loc = state.locales[code];
  const keys = new Set([...Object.keys(loc.data),
    ...Object.keys(loc.original)]);
  return [...keys].filter((k) => loc.data[k] !== loc.original[k]).length;
}

function updateSaveBar() {
  const bar = $('savebar');
  const onEditor = currentView() === 'edit' && editing;
  if (!onEditor || !isDirty(editing)) {
    bar.hidden = true;
    return;
  }
  const loc = state.locales[editing];
  const n = changeCount(editing);
  const file = `ui/locales/${editing}.json`;
  bar.hidden = false;
  $('save-msg').textContent = loc.isNew && !n ?
      `New file ${file}` :
      `${n} unsaved change${n === 1 ? '' : 's'} in ${file}`;
  $('save').textContent = state.mode === 'server' ?
      'Save' : `Download ${editing}.json`;
  $('revert').hidden = loc.isNew;
}

async function save() {
  const code = editing;
  const loc = state.locales[code];
  const text = serialize(loc);

  if (state.mode !== 'server') {
    const blob = new Blob([text], {type: 'application/json'});
    const link = el('a', {href: URL.createObjectURL(blob),
      download: `${code}.json`});
    document.body.append(link);
    link.click();
    link.remove();
    setTimeout(() => URL.revokeObjectURL(link.href), 1000);
    toast(`Downloaded ${code}.json.  Copy it to ui/locales/ to keep it.`);
    return;
  }

  $('save').disabled = true;
  try {
    const response = await fetch(`/api/locales/${encodeURIComponent(code)}`, {
      method: 'PUT',
      headers: {'Content-Type': 'application/json'},
      body: text,
    });
    const result = await response.json().catch(() => ({}));
    if (!response.ok) {
      throw new Error(result.error || `HTTP ${response.status}`);
    }
    loc.raw = text;
    loc.original = {...loc.data};
    loc.isNew = false;
    toast(`Saved ui/locales/${code}.json`);
    renderEditor(code);
  } catch (e) {
    toast(`Could not save: ${e.message}`);
  } finally {
    $('save').disabled = false;
  }
}

function revert() {
  const loc = state.locales[editing];
  loc.data = {...loc.original};
  renderEditor(editing);
  toast('Changes reverted.');
}

/* ------------------------------------------------------------------ */
/* Add language                                                       */
/* ------------------------------------------------------------------ */

function validateNewCode() {
  const code = $('add-code').value.trim();
  const hint = $('add-hint');
  let error = '';
  if (!code) {
    hint.textContent =
        'A BCP-47 code: language, optionally followed by a region or script.';
  } else if (!LOCALE_RE.test(code)) {
    error = 'Use a lowercase language code, optionally followed by ' +
        'subtags, e.g. "eu" or "pt-AO".';
  } else if (state.locales[code]) {
    error = `${languageName(code)} (${code}) already exists.`;
  } else {
    const name = languageName(code);
    hint.textContent = name === code ?
        `"${code}" is not a language this browser knows; double-check it.` :
        `Creates ui/locales/${code}.json for ${name}.`;
  }
  if (error) {
    hint.textContent = error;
  }
  hint.classList.toggle('err', !!error);
  $('add-ok').disabled = !code || !!error;
}

function openAddDialog() {
  $('add-code').value = '';
  validateNewCode();
  $('add-dialog').showModal();
  $('add-code').focus();
}

function addLanguage(code) {
  state.locales[code] = {
    original: {},
    data: {},
    raw: '',
    escapeNonAscii: false,
    isNew: true,
  };
  toast(state.mode === 'server' ?
      `Added ${languageName(code)}.  Save to create ${code}.json.` :
      `Added ${languageName(code)}.  Download the file when done.`);
  location.hash = `#/edit/${code}`;
}

/* ------------------------------------------------------------------ */
/* Routing                                                            */
/* ------------------------------------------------------------------ */

function parseHash() {
  const parts = location.hash.replace(/^#\/?/, '').split('/')
      .map(decodeURIComponent);
  return {view: parts[0] || 'languages', code: parts[1], key: parts[2]};
}

function currentView() {
  return parseHash().view;
}

function route() {
  let {view, code, key} = parseHash();
  if (view === 'edit' && !code) {
    if (editing) {
      code = editing;
    } else {
      view = 'languages';
    }
  }
  if (!['languages', 'matrix', 'edit'].includes(view)) {
    view = 'languages';
  }
  for (const v of ['languages', 'matrix', 'edit']) {
    $(`view-${v}`).hidden = v !== view;
  }
  document.querySelectorAll('nav.tabs a').forEach((a) => {
    if (a.dataset.view === view) {
      a.setAttribute('aria-current', 'page');
    } else {
      a.removeAttribute('aria-current');
    }
  });

  if (view === 'languages') {
    renderLanguages();
  } else if (view === 'matrix') {
    renderMatrix();
  } else {
    renderEditor(code, key);
    if (!key) {
      window.scrollTo(0, 0);
    }
  }
  updateSaveBar();
}

/* ------------------------------------------------------------------ */
/* Startup                                                            */
/* ------------------------------------------------------------------ */

function bindEvents() {
  window.addEventListener('hashchange', route);
  $('lang-search').addEventListener('input', renderLanguages);
  $('lang-owner').addEventListener('change', renderLanguages);
  document.querySelectorAll('table.langs th button').forEach((b) => {
    b.addEventListener('click', () => {
      const by = b.dataset.sort;
      state.langSort = {
        by,
        dir: state.langSort.by === by ? -state.langSort.dir :
            (by === 'name' ? 1 : -1),
      };
      renderLanguages();
    });
  });
  $('mx-gaps').addEventListener('change', renderMatrix);
  $('mx-generated').addEventListener('change', renderMatrix);

  let searchTimer = null;
  $('ed-search').addEventListener('input', () => {
    clearTimeout(searchTimer);
    searchTimer = setTimeout(() => renderEditor(editing), 120);
  });

  $('save').addEventListener('click', save);
  $('revert').addEventListener('click', revert);
  document.addEventListener('keydown', (e) => {
    if ((e.metaKey || e.ctrlKey) && e.key === 's' &&
        currentView() === 'edit' && editing && isDirty(editing)) {
      e.preventDefault();
      save();
    }
  });

  $('add-lang').addEventListener('click', openAddDialog);
  $('add-code').addEventListener('input', validateNewCode);
  $('add-code').addEventListener('keydown', (e) => {
    // Enter would otherwise trigger the first button, which is "Cancel".
    if (e.key === 'Enter') {
      e.preventDefault();
      if (!$('add-ok').disabled) {
        $('add-dialog').close('ok');
      }
    }
  });
  $('add-dialog').addEventListener('close', () => {
    const code = $('add-code').value.trim();
    if ($('add-dialog').returnValue === 'ok' && !$('add-ok').disabled) {
      addLanguage(code);
    }
  });

  // Chrome does not repaint a textarea that was drawn before the Tengwar font
  // finished loading, which leaves Sindarin text as empty boxes.
  if (document.fonts) {
    document.fonts.addEventListener('loadingdone', () => {
      document.querySelectorAll('.row textarea').forEach((t) => {
        if (t !== document.activeElement) {
          const value = t.value;
          t.value = '';
          t.value = value;
        }
      });
    });
  }

  window.addEventListener('beforeunload', (e) => {
    if (dirtyLocales().length) {
      e.preventDefault();
      e.returnValue = '';
    }
  });
}

async function main() {
  bindEvents();
  renderLegend();
  try {
    await load();
  } catch (e) {
    $('mode').textContent = 'Error';
    showError('Could not load the translations: ' + e.message +
        (location.protocol === 'file:' ?
          '.  Browsers block loading files from file:// URLs; run ' +
          '"python3 build/translations.py" instead.' : ''));
    return;
  }
  const mode = $('mode');
  if (state.mode === 'server') {
    mode.textContent = 'Editor: saves to ui/locales/';
    mode.classList.add('server');
  } else {
    mode.textContent = 'Read-only report: edits are downloaded as files';
  }
  route();
}

main();
</script>
</body>
</html>