feat: add basic integrity checks + corrections

This commit is contained in:
2025-04-30 00:22:43 +02:00
parent 82d02cfe76
commit acf7b5047f
8 changed files with 656 additions and 56 deletions

View File

@ -1,6 +1,8 @@
import Editor from "./editor.mjs";
import * as utils from "./utils.mjs"
window.addEventListener("load", () => {
const editor = new Editor()
window.editor = editor
window.utils = utils
})

View File

@ -1,5 +1,6 @@
import TracksTable from "./tracks_table.mjs"
import IntegrityManager from "./integrity_manager.mjs"
import { updateObjectFromJoinedKey } from "./utils.mjs"
export default class Editor {
constructor() {
@ -74,12 +75,7 @@ export default class Editor {
}
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
updateObjectFromJoinedKey(this.data[listKey][trackIdx], key, value)
this.setDirty()
}
}

View File

@ -1,29 +1,123 @@
import { Track } from "./tracks_table.mjs"
import { findLanguage, isLanguageAlias, flattenObj } from "./utils.mjs"
import { findLanguage, isLanguageTagAlias, flattenObj, updateObjectFromJoinedKey, LANGUAGES } from "./utils.mjs"
class Mismatch {
constructor(track, key, value) {
/** @type {Track} */
this.track = track
/** @type {string} */
this.key = key
this.value = value
}
}
const CorrectionType = {
NOTHING: "nothing",
NAME: "name",
FIELD: "field"
}
class MismatchCorrection {
/**
*
* @param {Mismatch} mismatch
* @param {string} oldName
* @param {string} newName
*/
constructor(mismatch, oldName, newName) {
this.track = mismatch.track
this.key = mismatch.key
this.oldName = oldName
this.newName = newName
this.oldValue = this.track.fields[mismatch.key]
this.newValue = mismatch.value
}
apply(correctionType) {
switch (correctionType) {
case CorrectionType.NOTHING:
break
case CorrectionType.NAME:
this.track.editValue("name", this.newName)
break
case CorrectionType.FIELD:
this.track.editValue(this.key, this.newValue)
break
default:
throw new Error(
`Invalid correction type, must be one of [${Object.values(CorrectionType)}], got ${correctionType}`
)
}
}
}
export default class IntegrityManager {
IGNORE_KEYS = [
"type", "channels"
]
/**
*
* @param {import('./editor.mjs').default} editor
*/
constructor(editor) {
this.editor = editor
/** @type {Mismatch[]} */
this.mismatches = []
/** @type {?MismatchCorrection} */
this.correction = null
this.ignoreList = []
this.popup = document.getElementById("integrity-popup")
this.popup.querySelectorAll(".correction .option").forEach(opt => {
const radio = opt.querySelector("input[type='radio']")
opt.addEventListener("click", e => {
radio.click()
})
radio.addEventListener("input", () => {
if (radio.checked) {
const prev = this.popup.querySelector(".option.selected input[type='radio']:not(:checked)")
prev.parentElement.classList.remove("selected")
opt.classList.add("selected")
} else {
opt.classList.remove("selected")
}
})
if (radio.checked) {
opt.classList.add("selected")
}
})
this.popup.querySelector("#int-apply").addEventListener("click", () => {
const fd = new FormData(this.popup.querySelector("form"))
const correctionType = fd.get("int-corr-type")
this.correct(correctionType)
})
}
nextError() {
if (this.mismatches.length === 0) {
this.hideMismatchPopup()
return
}
const mismatch = this.mismatches.shift()
console.log("Next mismatch:", mismatch)
this.mismatches = this.mismatches.filter(m => m.track !== mismatch.track)
this.showMismatchPopup(mismatch)
}
checkIntegrity() {
for (const table of Object.values(this.editor.tables)) {
this.checkTableIntegrity(table)
}
console.log(this.mismatches)
this.nextError()
}
/**
@ -40,18 +134,25 @@ export default class IntegrityManager {
*
* @param {Track} track
*/
checkTrackIntegrity(track) {
checkTrackIntegrity(track, prepend=false) {
let fields = this.parseName(track.table.type, track.fields["name"])
fields = flattenObj(fields)
Object.entries(fields).map(([key, value]) => {
if (this.IGNORE_KEYS.includes(key)) {
return
}
if (this.ignoreList.some(o => o.track === track && o.key === key)) {
return
}
let equal = track.fields[key] === value
if (key === "language") {
equal = isLanguageAlias(value, track.fields[key])
equal = isLanguageTagAlias(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]}`)
this.addMismatchField(track, key, value, prepend)
//console.error(`Mismatch for field ${key}:\n- name: ${value}\n- track: ${track.fields[key]}`)
} else {
track.fields[key] = value
}
@ -59,25 +160,158 @@ export default class IntegrityManager {
}
parseName(trackType, name) {
/** @type {string} */
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")
case "audio":
const audioLang = findLanguage(lower)
if (audioLang !== null) {fields.language = audioLang}
const original = parts.includes("vo")
if (original) {fields.flags.original = original}
const ad = parts.includes("ad")
if (ad) {fields.flags.visual_impaired = ad}
const channels = lower.match(/\d+\.\d+/)
if (channels) {
fields.channels = channels[0]
}
break
case "subtitle":
const stLang = findLanguage(lower)
if (stLang !== null) {fields.language = stLang}
const forced = parts.includes("forced")
if (forced) {fields.flags.forced = forced}
const sdh = parts.includes("sdh")
if (sdh) {fields.flags.hearing_impaired = sdh}
if (parts.includes("pgs")) {
fields.type = "PGS"
} else {
fields.type = "SRT"
}
break
}
return fields
}
addMismatchField(track, key, value) {
this.mismatches.push(new Mismatch(track, key, value))
addMismatchField(track, key, value, prepend=false) {
const mismatch = new Mismatch(track, key, value)
if (prepend) {
this.mismatches.splice(0, 0, mismatch)
} else {
this.mismatches.push(mismatch)
}
}
/**
*
* @param {Mismatch} mismatch
*/
showMismatchPopup(mismatch) {
const q = sel => this.popup.querySelector(sel)
const track = mismatch.track
const table = track.table
const fieldProps = table.getFieldProps(mismatch.key)
const ogName = track.fields["name"]
const ogValue = track.fields[mismatch.key]
let fields = this.parseName(table.type, ogName)
let keys = Object.keys(flattenObj(fields))
Object.entries(flattenObj(track.fields)).forEach(([key, val]) => {
if (!keys.includes(key) || key == mismatch.key) {
updateObjectFromJoinedKey(fields, key, val)
}
})
const newName = this.reconstructName(table.type, fields)
this.correction = new MismatchCorrection(
mismatch, ogName, newName
)
let ogField = track.makeInput(fieldProps, ogValue, false)
let newField = track.makeInput(fieldProps, mismatch.value, false)
ogField = this.lockInput(ogField)
newField = this.lockInput(newField)
ogField.id = "int-og-field"
newField.id = "int-corr-new-field"
q("#int-track-type").innerText = table.type
q("#int-track-idx").innerText = track.idx
q("#int-field-name").innerText = fieldProps.name
q("#int-og-name").value = ogName
q("#int-og-field-name").innerText = fieldProps.name
q("#int-og-field").replaceWith(ogField)
q("#int-corr-new-name").value = newName
q("#int-corr-new-field-name").innerText = fieldProps.name
q("#int-corr-new-field").replaceWith(newField)
this.popup.classList.add("show")
}
reconstructName(tableType, fields) {
let name = LANGUAGES[fields.language].display
switch (tableType) {
case "audio":
if (fields.flags.original) {
name += " VO"
} else if (fields.language === "fr") {
name += " VFF"
} else if (fields.language === "fr-ca") {
name =+ " VFQ"
}
if (fields.channels) {
name += " : " + fields.channels
}
break
case "subtitle":
if (fields.flags.forced) {
name += " Forced"
} else {
name += " Full"
}
if (fields.flags.hearing_impaired) {
name += " SDH"
}
name += " : " + fields.type
break
}
return name
}
hideMismatchPopup() {
this.popup.classList.remove("show")
}
correct(correctionType) {
if (correctionType === CorrectionType.NOTHING) {
this.ignoreList.push({
track: this.correction.track,
key: this.correction.key
})
}
this.correction.apply(correctionType)
this.checkTrackIntegrity(this.correction.track, true)
this.nextError()
}
lockInput(input) {
input.readOnly = true
if (input.type === "checkbox") {
input.disabled = true
} else if (input.tagName === "SELECT") {
let text = document.createElement("div")
text.classList.add("select")
text.innerText = input.value
text.dataset.key = input.dataset.key
input = text
}
return input
}
}

View File

@ -1,24 +1,31 @@
import { flattenObj } from "./utils.mjs"
import { flattenObj, getLanguageOptions } from "./utils.mjs"
export class Track {
constructor(table, idx, fields) {
/** @type {TracksTable} */
this.table = table
/** @type {number} */
this.idx = idx
/** @type {object} */
this.fields = flattenObj(fields)
this.row = null
}
makeRow() {
const tr = document.createElement("tr")
tr.dataset.i = this.idx
this.row = document.createElement("tr")
this.row.dataset.i = this.idx
this.table.fields.forEach(field => {
const td = tr.insertCell(-1)
const td = this.row.insertCell(-1)
const input = this.makeInput(field, this.fields[field.key], this.idx)
td.appendChild(input)
})
return tr
return this.row
}
makeInput(field, value) {
makeInput(field, value, listeners=true) {
let input = document.createElement("input")
let getValue = () => input.value
switch (field.type) {
@ -39,7 +46,7 @@ export class Track {
getValue = () => input.checked
const hotone = this.table.CONSTRAINTS[field.key]?.type == "hotone"
if (hotone) {
if (listeners && 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`)
@ -62,11 +69,14 @@ export class Track {
case "sel":
input = document.createElement("select")
const options = this.table.OPTIONS[field.key]
let options = this.table.OPTIONS[field.key]
if (typeof options === "function") {
options = options()
}
options.forEach(option => {
const opt = document.createElement("option")
opt.innerText = option
opt.value = option
opt.innerText = option.display
opt.value = option.value
input.appendChild(opt)
})
input.value = value
@ -78,21 +88,34 @@ export class Track {
if (this.table.CONSTRAINTS[field.key]?.type === "readonly") {
input.disabled = true
}
input.addEventListener("change", () => {
this.editValue(field.key, getValue())
})
if (listeners) {
input.addEventListener("change", () => {
this.editValue(field.key, getValue())
})
}
return input
}
editValue(key, value) {
this.fields[key] = value
this.table.editTrack(this.idx, key, value)
const input = this.row.querySelector(`[data-key='${key}']`)
const fieldType = this.table.getFieldProps(key).type
switch (fieldType) {
case "bool":
input.checked = value
break
default:
input.value = value
break
}
}
}
export default class TracksTable {
OPTIONS = {
"language": ["fre", "eng"]
"language": getLanguageOptions
}
CONSTRAINTS = {
"flags/default": {
@ -124,6 +147,10 @@ export default class TracksTable {
this.hotones = {}
}
getFieldProps(key) {
return this.fields.find(f => f.key == key)
}
loadTracks(tracks) {
this.tracks = tracks.map((t, i) => new Track(this, i, t))
this.clear()

View File

@ -18,13 +18,49 @@ export function flattenObj(obj) {
return res
}
// Code (Flag): https://en.wikipedia.org/wiki/Regional_indicator_symbol
// Tag: https://en.wikipedia.org/wiki/List_of_ISO_639-2_codes
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"]
"fre-ca": {
display: "Français CA",
code: "ca",
aliases: ["fr-ca", "vfq", "quebec", "québec", "ca", "canada"]
},
"fre": {
display: "Français FR",
code: "fr",
aliases: ["fr", "fre", "fra", "french", "francais", "français", "vf", "vff", "france"]
},
"eng": {
display: "English",
code: "gb",
aliases: ["en", "eng", "ang", "english", "anglais", "uk", "gb", "usa", "british", "american", "amérique", "amerique", "angleterre", "royaume-uni"]
},
"deu": {
display: "Deutsch",
code: "de",
aliases: ["de", "deu", "ger", "german", "allemand", "deutsch", "germany", "allemagne"]
},
"kor": {
display: "Korean",
code: "kr",
aliases: ["ko", "kr", "kor", "cor", "korean", "coreen", "coréen", "corée", "coree", "korea"]
},
"jpn": {
display: "Japanese",
code: "jp",
aliases: ["ja", "jp", "jap", "japanese", "japonais", "japon", "japan"]
},
"tur": {
display: "Turkish",
code: "tr",
aliases: ["tu", "tr", "tur", "tür", "turkish", "turc", "turquie"]
},
"und": {
display: "Undefined",
code: "",
aliases: []
}
}
/**
@ -34,15 +70,43 @@ export const LANGUAGES = {
*/
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) {
const aliases = LANGUAGES[lang].aliases
const matches = aliases.map(a => {
return new RegExp("\\b" + a + "\\b").test(value)
})
if (matches.some(v => v)) {
return lang
}
}
return null
}
export function isLanguageAlias(langKey, value) {
return [langKey].concat(LANGUAGES[langKey]).includes(value)
export function isLanguageTagAlias(langTag, value) {
return LANGUAGES[langTag].aliases.includes(value)
}
export function updateObjectFromJoinedKey(obj, joinedKey, value) {
const keyParts = joinedKey.split("/")
for (const part of keyParts.slice(0, -1)) {
obj = obj[part]
}
obj[keyParts[keyParts.length - 1]] = value
}
/**
* @param {string} code
*/
export function makeFlag(code) {
return code.split("")
.map(c => String.fromCodePoint(
0x1f1e6 + c.codePointAt(0) - 97
))
.join("")
}
export function getLanguageOptions() {
return Object.entries(LANGUAGES).map(([tag, props]) => {
const flag = makeFlag(props.code)
return {value: tag, display: `${flag} ${props.display}`}
})
}