From 82d02cfe766af4f92c426c45f75bdd1b3d1530bd Mon Sep 17 00:00:00 2001 From: LordBaryhobal Date: Tue, 29 Apr 2025 18:29:52 +0200 Subject: [PATCH] refactor: split editor in JS modules --- editor/public/edit/index.html | 19 +- editor/public/edit/index.js | 231 ------------------ editor/public/index.html | 3 +- editor/public/{ => static/css}/base.css | 0 .../{edit/index.css => static/css/edit.css} | 25 ++ editor/public/static/js/edit.mjs | 6 + editor/public/static/js/editor.mjs | 85 +++++++ editor/public/{ => static/js}/index.js | 0 editor/public/static/js/integrity_manager.mjs | 83 +++++++ editor/public/static/js/tracks_table.mjs | 172 +++++++++++++ editor/public/static/js/utils.mjs | 48 ++++ 11 files changed, 437 insertions(+), 235 deletions(-) delete mode 100644 editor/public/edit/index.js rename editor/public/{ => static/css}/base.css (100%) rename editor/public/{edit/index.css => static/css/edit.css} (75%) create mode 100644 editor/public/static/js/edit.mjs create mode 100644 editor/public/static/js/editor.mjs rename editor/public/{ => static/js}/index.js (100%) create mode 100644 editor/public/static/js/integrity_manager.mjs create mode 100644 editor/public/static/js/tracks_table.mjs create mode 100644 editor/public/static/js/utils.mjs diff --git a/editor/public/edit/index.html b/editor/public/edit/index.html index a394faa..3ede78f 100644 --- a/editor/public/edit/index.html +++ b/editor/public/edit/index.html @@ -4,9 +4,9 @@ Edit - - - + + +
@@ -44,5 +44,18 @@ + \ No newline at end of file diff --git a/editor/public/edit/index.js b/editor/public/edit/index.js deleted file mode 100644 index 824f3d1..0000000 --- a/editor/public/edit/index.js +++ /dev/null @@ -1,231 +0,0 @@ -/** @type TracksTable */ -let audioTable - -/** @type TracksTable */ -let subtitleTable - -let data = {} - -function flattenObj(obj) { - const res = {} - Object.entries(obj).forEach(([key, value]) => { - if (typeof value === "object") { - value = flattenObj(value) - Object.entries(value).forEach(([key2, value2]) => { - res[key + "/" + key2] = value2 - }) - } else { - res[key] = value - } - }) - return res -} - -class TracksTable { - OPTIONS = { - "language": ["fre", "eng"] - } - CONSTRAINTS = { - "flags/default": { - type: "hotone" - } - } - - constructor(table, callback) { - this.table = table - this.headers = this.table.querySelector("thead tr") - this.body = this.table.querySelector("tbody") - this.fields = [] - this.tracks = [] - this.callback = callback - this.hotones = {} - } - - showTracks(tracks) { - this.tracks = tracks.map(flattenObj) - this.clear() - if (tracks.length === 0) { - return - } - this.detectFields() - this.addHeaders() - this.tracks.forEach((track, i) => { - const tr = document.createElement("tr") - tr.dataset.i = i - this.fields.forEach(field => { - const td = tr.insertCell(-1) - const input = this.makeInput(field, track[field.key], i) - td.appendChild(input) - }) - this.body.appendChild(tr) - }) - } - - clear() { - this.headers.innerHTML = "" - this.body.innerHTML = "" - this.fields = [] - } - - detectFields() { - Object.entries(this.tracks[0]).forEach(([key, value]) => { - let type = { - boolean: "bool", - number: "num" - }[typeof value] ?? "str" - if (key === "language") { - type = "sel" - } - - const name = key.split("/").slice(-1)[0] - this.fields.push({name, type, key}) - }) - } - - addHeaders() { - this.fields.forEach(field => { - const th = document.createElement("th") - th.innerText = field.name - this.headers.appendChild(th) - }) - } - - makeInput(field, value, trackIdx) { - let input = document.createElement("input") - switch (field.type) { - case "num": - input.type = "number" - input.value = value - break - - case "str": - input.type = "text" - input.value = value - break - - case "bool": - input.type = "checkbox" - input.checked = value - const hotone = this.CONSTRAINTS[field.key]?.type == "hotone" - - if (hotone) { - if (value) { - if (field.key in this.hotones) { - alert("Error in metadata file: field ${field.name} is hotone but multiple tracks are enabled ") - } - this.hotones[field.key] = input - } - input.addEventListener("click", e => { - console.log("HOTONE") - if (!input.checked) { - console.log("Already checked, preventing uncheck") - e.preventDefault() - } else { - if (field.key in this.hotones) { - this.hotones[field.key].checked = false - this.hotones[field.key].dispatchEvent(new Event("change")) - } - this.hotones[field.key] = input - } - }) - } - input.addEventListener("change", () => { - this.callback(trackIdx, field.key, input.checked) - }) - break - - case "sel": - input = document.createElement("select") - const options = this.OPTIONS[field.name] - options.forEach(option => { - const opt = document.createElement("option") - opt.innerText = option - opt.value = option - input.appendChild(opt) - }) - input.value = value - - default: - break - } - input.dataset.key = field.key - return input - } -} - -function fetchData(filename) { - fetch(`/api/file/${filename}`).then(res => { - if (res.ok) { - return res.json() - } - return null - }).then(res => { - if (res !== null) { - data = res - displayData() - } - }) -} - -function displayData() { - document.getElementById("title").value = data.title - - audioTable.showTracks(data.audio_tracks) - subtitleTable.showTracks(data.subtitle_tracks) -} - -function setDirty() { - dirty = true - document.getElementById("unsaved").classList.add("show") -} - -function save(filename) { - fetch(`/api/file/${filename}`, { - method: "POST", - body: JSON.stringify(data), - headers: { - "Content-Type": "application/json" - } - }).then(res => { - if (res.ok) { - dirty = false - document.getElementById("unsaved").classList.remove("show") - } else { - alert(`Error ${res.status}: ${res.statusText}`) - } - }) -} - -function editTrack(listKey, trackIdx, key, value) { - const keyParts = key.split("/") - let obj = data[listKey][trackIdx] - for (const part of keyParts.slice(0, -1)) { - obj = obj[part] - } - obj[keyParts[keyParts.length - 1]] = value - setDirty() -} - -window.addEventListener("load", () => { - audioTable = new TracksTable(document.getElementById("audio-tracks"), (i, key, value) => { - editTrack("audio_tracks", i, key, value) - }) - subtitleTable = new TracksTable(document.getElementById("subtitle-tracks"), (i, key, value) => { - editTrack("subtitle_tracks", i, key, value) - }) - - const params = new URLSearchParams(window.location.search) - const file = params.get("f") - document.getElementById("filename").innerText = file - - fetchData(file) -}) - -window.addEventListener("keydown", e => { - if (e.key === "s" && e.ctrlKey) { - e.preventDefault() - const params = new URLSearchParams(window.location.search) - const file = params.get("f") - save(file) - } -}) \ No newline at end of file diff --git a/editor/public/index.html b/editor/public/index.html index cdf3ce5..02317a6 100644 --- a/editor/public/index.html +++ b/editor/public/index.html @@ -4,7 +4,8 @@ Metadata Editor - + +

Metadata Editor

diff --git a/editor/public/base.css b/editor/public/static/css/base.css similarity index 100% rename from editor/public/base.css rename to editor/public/static/css/base.css diff --git a/editor/public/edit/index.css b/editor/public/static/css/edit.css similarity index 75% rename from editor/public/edit/index.css rename to editor/public/static/css/edit.css index ccf53d9..7e72e24 100644 --- a/editor/public/edit/index.css +++ b/editor/public/static/css/edit.css @@ -65,4 +65,29 @@ input[type="checkbox"] { background-color: #6ee74a; } } +} + +.popup { + display: grid; + place-items: center; + position: fixed; + inset: 0; + background-color: #5c5c5c5c; + + &:not(.show) { + display: none; + } + + .container { + padding: 1.2em; + border-radius: 0.4em; + background-color: white; + display: flex; + flex-direction: column; + gap: 0.8em; + + .title { + text-align: center; + } + } } \ No newline at end of file diff --git a/editor/public/static/js/edit.mjs b/editor/public/static/js/edit.mjs new file mode 100644 index 0000000..1e1063c --- /dev/null +++ b/editor/public/static/js/edit.mjs @@ -0,0 +1,6 @@ +import Editor from "./editor.mjs"; + +window.addEventListener("load", () => { + const editor = new Editor() + window.editor = editor +}) diff --git a/editor/public/static/js/editor.mjs b/editor/public/static/js/editor.mjs new file mode 100644 index 0000000..b10a413 --- /dev/null +++ b/editor/public/static/js/editor.mjs @@ -0,0 +1,85 @@ +import TracksTable from "./tracks_table.mjs" +import IntegrityManager from "./integrity_manager.mjs" + +export default class Editor { + constructor() { + const params = new URLSearchParams(window.location.search) + this.filename = params.get("f") + window.addEventListener("keydown", e => { + if (e.key === "s" && e.ctrlKey) { + e.preventDefault() + this.save() + } + }) + + this.tables = { + audio: new TracksTable(this, "audio", "audio-tracks", "audio_tracks"), + subtitle: new TracksTable(this, "subtitle", "subtitle-tracks", "subtitle_tracks") + } + + this.data = {} + this.dirty = false + + this.integrity_mgr = new IntegrityManager(this) + + this.setup() + } + + setup() { + document.getElementById("filename").innerText = this.filename + this.fetchData() + } + + fetchData() { + fetch(`/api/file/${this.filename}`).then(res => { + if (res.ok) { + return res.json() + } + return null + }).then(res => { + if (res !== null) { + this.data = res + this.displayData() + } + }) + } + + displayData() { + document.getElementById("title").value = this.data.title + this.tables.audio.loadTracks(this.data.audio_tracks) + this.tables.subtitle.loadTracks(this.data.subtitle_tracks) + this.integrity_mgr.checkIntegrity() + } + + save() { + fetch(`/api/file/${this.filename}`, { + method: "POST", + body: JSON.stringify(this.data), + headers: { + "Content-Type": "application/json" + } + }).then(res => { + if (res.ok) { + this.dirty = false + document.getElementById("unsaved").classList.remove("show") + } else { + alert(`Error ${res.status}: ${res.statusText}`) + } + }) + } + + setDirty() { + this.dirty = true + document.getElementById("unsaved").classList.add("show") + } + + editTrack(listKey, trackIdx, key, value) { + const keyParts = key.split("/") + let obj = this.data[listKey][trackIdx] + for (const part of keyParts.slice(0, -1)) { + obj = obj[part] + } + obj[keyParts[keyParts.length - 1]] = value + this.setDirty() + } +} \ No newline at end of file diff --git a/editor/public/index.js b/editor/public/static/js/index.js similarity index 100% rename from editor/public/index.js rename to editor/public/static/js/index.js diff --git a/editor/public/static/js/integrity_manager.mjs b/editor/public/static/js/integrity_manager.mjs new file mode 100644 index 0000000..144fad3 --- /dev/null +++ b/editor/public/static/js/integrity_manager.mjs @@ -0,0 +1,83 @@ +import { Track } from "./tracks_table.mjs" +import { findLanguage, isLanguageAlias, flattenObj } from "./utils.mjs" + +class Mismatch { + constructor(track, key, value) { + this.track = track + this.key = key + this.value = value + } +} + +export default class IntegrityManager { + /** + * + * @param {import('./editor.mjs').default} editor + */ + constructor(editor) { + this.editor = editor + this.mismatches = [] + } + + checkIntegrity() { + for (const table of Object.values(this.editor.tables)) { + this.checkTableIntegrity(table) + } + console.log(this.mismatches) + } + + /** + * + * @param {import('./tracks_table.mjs').default} table + */ + checkTableIntegrity(table) { + for (const track of table.tracks) { + this.checkTrackIntegrity(track) + } + } + + /** + * + * @param {Track} track + */ + checkTrackIntegrity(track) { + let fields = this.parseName(track.table.type, track.fields["name"]) + fields = flattenObj(fields) + + Object.entries(fields).map(([key, value]) => { + let equal = track.fields[key] === value + if (key === "language") { + equal = isLanguageAlias(value, track.fields[key]) + } + if (!equal) { + this.addMismatchField(track, key, value) + console.error(`Mismatch for field ${key}:\n- name: ${value}\n- track: ${track.fields[key]}`) + } else { + track.fields[key] = value + } + }) + } + + parseName(trackType, name) { + const lower = name.toLowerCase() + const parts = lower.split(/\b/) + const fields = {flags: {}} + switch (trackType) { + case "subtitle": + let forced = parts.includes("forced") + let lang = findLanguage(lower) + if (forced) {fields.flags.forced = forced} + if (lang !== null) {fields.language = lang} + let ad = parts.includes("sdh") || parts.includes("ad") + if (ad) {fields.flags.hearing_impaired = ad} + let original = parts.includes("vo") + if (original) {fields.flags.original = original} + break + } + return fields + } + + addMismatchField(track, key, value) { + this.mismatches.push(new Mismatch(track, key, value)) + } +} \ No newline at end of file diff --git a/editor/public/static/js/tracks_table.mjs b/editor/public/static/js/tracks_table.mjs new file mode 100644 index 0000000..bee0e32 --- /dev/null +++ b/editor/public/static/js/tracks_table.mjs @@ -0,0 +1,172 @@ +import { flattenObj } from "./utils.mjs" + +export class Track { + constructor(table, idx, fields) { + this.table = table + this.idx = idx + this.fields = flattenObj(fields) + } + + makeRow() { + const tr = document.createElement("tr") + tr.dataset.i = this.idx + this.table.fields.forEach(field => { + const td = tr.insertCell(-1) + const input = this.makeInput(field, this.fields[field.key], this.idx) + td.appendChild(input) + }) + return tr + } + + makeInput(field, value) { + let input = document.createElement("input") + let getValue = () => input.value + switch (field.type) { + case "num": + input.type = "number" + input.value = value + getValue = () => +input.value + break + + case "str": + input.type = "text" + input.value = value + break + + case "bool": + input.type = "checkbox" + input.checked = value + getValue = () => input.checked + const hotone = this.table.CONSTRAINTS[field.key]?.type == "hotone" + + if (hotone) { + if (value) { + if (field.key in this.table.hotones) { + alert(`Error in metadata file: field ${field.name} is hotone but multiple tracks are enabled`) + } + this.table.hotones[field.key] = input + } + input.addEventListener("click", e => { + if (!input.checked) { + e.preventDefault() + } else { + if (field.key in this.table.hotones) { + this.table.hotones[field.key].checked = false + this.table.hotones[field.key].dispatchEvent(new Event("change")) + } + this.table.hotones[field.key] = input + } + }) + } + break + + case "sel": + input = document.createElement("select") + const options = this.table.OPTIONS[field.key] + options.forEach(option => { + const opt = document.createElement("option") + opt.innerText = option + opt.value = option + input.appendChild(opt) + }) + input.value = value + + default: + break + } + input.dataset.key = field.key + if (this.table.CONSTRAINTS[field.key]?.type === "readonly") { + input.disabled = true + } + input.addEventListener("change", () => { + this.editValue(field.key, getValue()) + }) + return input + } + + editValue(key, value) { + this.fields[key] = value + this.table.editTrack(this.idx, key, value) + } +} + +export default class TracksTable { + OPTIONS = { + "language": ["fre", "eng"] + } + CONSTRAINTS = { + "flags/default": { + type: "hotone" + }, + "index": { + type: "readonly" + }, + "channels": { + type: "readonly" + } + } + + /** + * @param {import('./editor.mjs').default} editor The parent editor + * @param {string} type The type of tracks. One of `['audio', 'subtitle']` + * @param {string} tableId The id of the table element + * @param {string} dataKey The key of the tracks list inside of the data object + */ + constructor(editor, type, tableId, dataKey) { + this.editor = editor + this.type = type + this.table = document.getElementById(tableId) + this.headers = this.table.querySelector("thead tr") + this.body = this.table.querySelector("tbody") + this.fields = [] + this.tracks = [] + this.dataKey = dataKey + this.hotones = {} + } + + loadTracks(tracks) { + this.tracks = tracks.map((t, i) => new Track(this, i, t)) + this.clear() + if (tracks.length === 0) { + return + } + this.detectFields() + this.addHeaders() + this.tracks.forEach(track => { + this.body.appendChild(track.makeRow()) + }) + } + + clear() { + this.headers.innerHTML = "" + this.body.innerHTML = "" + this.fields = [] + } + + detectFields() { + Object.entries(this.tracks[0].fields).forEach(([key, value]) => { + let type = { + boolean: "bool", + number: "num" + }[typeof value] ?? "str" + if (key === "language") { + type = "sel" + } + + const name = key.split("/").slice(-1)[0] + this.fields.push({name, type, key}) + }) + } + + addHeaders() { + this.fields.forEach(field => { + const th = document.createElement("th") + th.innerText = field.name + this.headers.appendChild(th) + }) + } + + editTrack(trackIdx, fieldKey, fieldValue) { + this.editor.editTrack(this.dataKey, trackIdx, fieldKey, fieldValue) + } +} diff --git a/editor/public/static/js/utils.mjs b/editor/public/static/js/utils.mjs new file mode 100644 index 0000000..d69e01b --- /dev/null +++ b/editor/public/static/js/utils.mjs @@ -0,0 +1,48 @@ +/** + * Flattens an object recursively. Nested keys are joined with slashes ('/') + * @param {object} obj The object to flatten + * @returns {object} The flattened object + */ +export function flattenObj(obj) { + const res = {} + Object.entries(obj).forEach(([key, value]) => { + if (typeof value === "object") { + value = flattenObj(value) + Object.entries(value).forEach(([key2, value2]) => { + res[key + "/" + key2] = value2 + }) + } else { + res[key] = value + } + }) + return res +} + +export const LANGUAGES = { + "fr": ["fre", "fra", "french", "francais", "français", "vf", "vff"], + "fr-ca": ["vfq", "quebec", "québec"], + "en": ["eng", "ang", "english", "anglais"], + "de": ["deu", "ger", "german", "allemand"], + "ko": ["kor", "cor", "korean", "coreen", "coréen"], + "ja": ["jap", "japanese", "japonais"] +} + +/** + * Tries to find a language name in the given string + * @param {string} value The string in which to search for a language + * @returns {?string} The language key if it could be determined, null otherwise + */ +export function findLanguage(value) { + for (const lang in LANGUAGES) { + const aliases = [lang].concat(LANGUAGES[lang]) + const matches = aliases.map(a => new RegExp("\\b" + a + "\\b").test(value)).some(v => v) + if (matches) { + return lang + } + } + return null +} + +export function isLanguageAlias(langKey, value) { + return [langKey].concat(LANGUAGES[langKey]).includes(value) +} \ No newline at end of file