7 Commits

18 changed files with 1722 additions and 207 deletions

1
editor/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
metadata/

View File

@ -1,9 +0,0 @@
* {
/*padding: 0;*/
/*margin: 0;*/
box-sizing: border-box;
}
body {
font-family: Ubuntu;
}

View File

@ -1,13 +0,0 @@
#filename {
font-size: 80%;
font-style: italic;
}
#unsaved {
color: #9d4916;
font-size: 80%;
&:not(.show) {
display: none;
}
}

View File

@ -4,25 +4,29 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Edit</title>
<link rel="stylesheet" href="/base.css">
<link rel="stylesheet" href="index.css">
<script src="index.js"></script>
<link rel="stylesheet" href="/static/css/base.css">
<link rel="stylesheet" href="/static/css/edit.css">
<script src="/static/js/edit.mjs" type="module"></script>
</head>
<body>
<header id="topbar">
<nav>
</nav>
<button id="improve-btn" class="template improve">
<img src="/static/images/improve.svg">
<img class="clicked" src="/static/images/improve_clicked.svg">
</button>
<header id="toolbar">
<a href="/">Home</a>
<button id="check-integrity">Check integrity</button>
<button id="improve-all">Improve all names</button>
<button id="save">Save</button>
<button id="reload">Reload</button>
<button id="toggle-notifs">Notifications</button>
</header>
<aside id="toolbar">
</aside>
<main id="main">
<h1>Edit <code id="filename"></code><span id="unsaved"> - Unsaved</span></h1>
<h1>Editing <code id="filename"></code><span id="unsaved"> - Unsaved</span></h1>
<div class="fields">
<div class="field">
<label for="title">Title</label>
<input type="text" id="title" name="title">
<input type="text" id="title" name="title" size="50">
</div>
</div>
<div class="audio">
@ -44,5 +48,63 @@
</table>
</div>
</main>
<div class="popup" id="integrity-popup">
<form class="container">
<h2 class="title">Integrity Error</h2>
<p class="description">
There seem to be a mismatch between the name of <span id="int-track-type"></span> track <span id="int-track-idx"></span> and its '<span id="int-field-name"></span>' field
</p>
<div class="original">
<h3>Current values</h3>
<div class="values">
<div class="name">
<label for="int-og-name">name</label>
<input type="text" readonly id="int-og-name">
</div>
<div class="field">
<label for="int-og-field" id="int-og-field-name"></label>
<input type="text" readonly id="int-og-field">
</div>
</div>
</div>
<div class="correction">
<h3>Correction</h3>
<div class="options">
<div class="option">
<input type="radio" value="nothing" name="int-corr-type" id="int-corr-nothing" checked>
<label for="int-corr-nothing">Do nothing</label>
</div>
<div class="option">
<input type="radio" value="name" name="int-corr-type" id="int-corr-name">
<label for="int-corr-name">Adapt name to field</label>
<div class="value">
<label for="int-corr-new-name">name</label>
<div class="arrow"></div>
<input type="text" readonly id="int-corr-new-name">
</div>
</div>
<div class="option">
<input type="radio" value="field" name="int-corr-type" id="int-corr-field">
<label for="int-corr-field">Adapt field to name</label>
<div class="value">
<label for="int-corr-new-field" id="int-corr-new-field-name"></label>
<div class="arrow"></div>
<input type="text" readonly id="int-corr-new-field">
</div>
</div>
</div>
</div>
<div class="buttons">
<button type="button" id="int-apply">Apply</button>
</div>
</form>
</div>
<aside id="notifs-hist">
<button id="close-notifs">Close</button>
<h2>Notifications</h2>
<div class="list"></div>
</aside>
<div id="notifs"></div>
</body>
</html>

View File

@ -1,157 +0,0 @@
/** @type TracksTable */
let audioTable
/** @type TracksTable */
let subtitleTable
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"]
}
constructor(table) {
this.table = table
this.headers = this.table.querySelector("thead tr")
this.body = this.table.querySelector("tbody")
this.fields = []
this.tracks = []
}
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])
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) {
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
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
}
return input
}
}
function fetchData(filename) {
fetch("/api/file", {
method: "POST",
body: JSON.stringify({
file: filename
}),
headers: {
"Content-Type": "application/json"
}
}).then(res => {
if (res.ok) {
return res.json()
}
return null
}).then(res => {
if (res !== null) {
displayData(res)
}
})
}
function displayData(data) {
document.getElementById("title").value = data.title
audioTable.showTracks(data.audio_tracks)
subtitleTable.showTracks(data.subtitle_tracks)
}
window.addEventListener("load", () => {
audioTable = new TracksTable(document.getElementById("audio-tracks"))
subtitleTable = new TracksTable(document.getElementById("subtitle-tracks"))
const params = new URLSearchParams(window.location.search)
const file = params.get("f")
document.getElementById("filename").innerText = file
fetchData(file)
})

View File

@ -4,10 +4,18 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Metadata Editor</title>
<script src="index.js"></script>
<link rel="stylesheet" href="/static/css/base.css">
<script src="/static/js/index.js"></script>
</head>
<body>
<header>
<h1>Metadata Editor</h1>
</header>
<main>
<div>
<label for="file-sel">Choose a file</label>
<select name="file-sel" id="file-sel" oninput="selectFile(event)"></select>
</div>
</main>
</body>
</html>

View File

@ -0,0 +1,107 @@
* {
margin: 0;
box-sizing: border-box;
}
html, body {
height: 100%;
}
body {
font-family: Ubuntu;
margin: 0;
padding: 0;
}
.template {
display: none !important;
}
main {
padding: 1.2em;
}
header {
background-color: #2b2b2b;
padding: 1.2em;
grid-area: header;
display: flex;
gap: 0.8em;
color: white;
a, button {
padding: 0.4em 0.8em;
border: none;
color: black;
background-color: #e4e4e4;
font-size: inherit;
font-family: inherit;
text-decoration: none;
border-radius: 0.2em;
cursor: pointer;
&:hover {
background-color: #dbdbdb;
}
}
}
aside {
position: fixed;
right: 0;
top: 0;
bottom: 0;
background-color: white;
border-left: solid black 2px;
}
.notif {
--bg: #f0f0f0;
--border: #727272;
--fg: black;
--col: #f0f0f0;
&[data-type="success"] {
--bg: #e0ffe0;
--border: #727f72;
--fg: black;
--col: #8dff8d;
}
&[data-type="error"] {
--bg: #ffe0e0;
--border: #7f7272;
--fg: black;
--col: #ff8d8d;
}
&[data-type="warning"] {
--bg: #ffefe0;
--border: #7f7f72;
--fg: black;
--col: #ffc36a;
}
}
#notifs {
position: fixed;
top: 0;
left: 0;
right: 0;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 0.4em;
padding-top: 0.4em;
width: max-content;
.notif {
padding: 0.4em 0.8em;
border-radius: 0.6em;
background-color: var(--bg);
border: solid var(--border) 2px;
color: var(--fg);
max-width: 30em;
cursor: pointer;
}
}

View File

@ -0,0 +1,372 @@
main {
display: flex;
flex-direction: column;
gap: 1.2em;
}
#toggle-notifs {
margin-left: auto;
}
#filename {
font-size: 80%;
font-style: italic;
}
#unsaved {
color: #9d4916;
font-size: 80%;
&:not(.show) {
display: none;
}
}
table {
border-collapse: collapse;
width: 100%;
th, td {
padding: 0.4em 0.8em;
}
tbody {
tr {
&:nth-child(even) {
background-color: #ececec;
}
td {
text-align: center;
position: relative;
}
}
}
}
input[type="text"], select {
font-size: inherit;
font-family: inherit;
}
input[type="checkbox"] {
--size: 0.6em;
--pad: 0.2em;
width: calc((var(--size) * 2 + var(--pad)) * 2);
height: calc((var(--size) + var(--pad)) * 2);
border-radius: calc(var(--size) + var(--pad));
appearance: none;
background-color: #e1e1e1;
position: relative;
cursor: pointer;
&::after {
content: "";
width: calc(var(--size) * 2);
height: calc(var(--size) * 2);
border-radius: calc(var(--size));
background-color: #9b9b9b;
position: absolute;
top: 50%;
left: var(--pad);
transform: translateY(-50%);
}
&:checked {
background-color: #daf0d1;
&::after {
left: auto;
right: var(--pad);
background-color: #6ee74a;
}
}
}
input[type="radio"] {
--size: 0.5em;
--pad: 0.3em;
width: calc((var(--size) + var(--pad)) * 2);
height: calc((var(--size) + var(--pad)) * 2);
border-radius: calc(var(--size) + var(--pad));
appearance: none;
background-color: #e1e1e1;
position: relative;
cursor: pointer;
&:checked {
background-color: #daf0d1;
&::after {
content: "";
width: calc(var(--size) * 2);
height: calc(var(--size) * 2);
border-radius: calc(var(--size));
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: #6ee74a;
}
}
}
label {
cursor: pointer;
}
button.improve {
width: 2em;
height: 2em;
border: none;
margin: 0;
padding: 0;
cursor: pointer;
position: absolute;
top: 50%;
transform: translateY(-50%);
margin-left: 0.4em;
border-radius: 0.4em;
background: none;
&:hover{
background-color: #8d8d8d42;
}
img {
position: absolute;
inset: 0;
width: inherit;
height: inherit;
object-fit: contain;
}
.clicked {
opacity: 0;
transition: opacity 0.2s;
}
&.clicked {
.clicked {
opacity: 1;
}
}
/*background: url("/static/images/improve.svg");
background-size: contain;
width: 2em;
height: 2em;
border: none;
margin: 0;
padding: 0;
cursor: pointer;
position: absolute;
top: 50%;
transform: translateY(-50%);
margin-left: 0.4em;
border-radius: 0.4em;
&:hover{
background-color: #8d8d8d42;
}
&::after {
content: "";
position: absolute;
background: url("/static/images/improve_clicked.svg");
background-size: contain;
width: inherit;
height: inherit;
position: absolute;
inset: 0;
opacity: 0;
transition: opacity 0.2s;
}
&.clicked {
&::after {
opacity: 1;
}
}*/
}
.popup {
display: grid;
place-items: center;
position: fixed;
inset: 0;
background-color: #5c5c5c5c;
&:not(.show) {
display: none;
}
.container {
padding: 1.2em;
border-radius: 0.8em;
background-color: white;
display: flex;
flex-direction: column;
gap: 0.8em;
.title {
text-align: center;
margin: 0.4em 0;
}
h2, h3 {
margin: 0;
margin-bottom: 0.4em;
}
.buttons {
display: flex;
justify-content: center;
gap: 0.8em;
button {
padding: 0.8em 1.6em;
background-color: #dfdfdf;
border: none;
cursor: pointer;
border-radius: 0.4em;
font-family: inherit;
font-size: inherit;
font-weight: bold;
&:hover {
background-color: #e7e7e7;
}
&:active {
background-color: #d7d7d7;
}
}
}
}
}
#integrity-popup {
h3 {
margin-bottom: 0.4em;
}
label {
font-weight: bold;
}
.description {
span {
font-weight: bold;
}
}
input[type="text"] {
width: 100%;
}
.original {
.values {
display: flex;
flex-direction: column;
gap: 0.2em;
padding-left: 1.2em;
}
.name, .field {
display: flex;
gap: 0.4em;
align-items: center;
}
}
.correction {
.options {
display: flex;
flex-direction: column;
gap: 0.4em;
.option {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.2em 0.4em;
align-items: center;
padding: 0.4em;
border: solid black 2px;
cursor: pointer;
border-radius: 0.2em;
&.selected {
border-color: #6ee74a;
}
input[type="radio"] {
grid-column: 1;
grid-row: 1;
}
label {
grid-column: 2;
grid-row: 1;
}
.arrow {
background: url("/static/images/arrow.svg");
width: 2em;
height: 2em;
background-size: contain;
}
.value {
grid-column: 2;
grid-row: 2;
display: flex;
align-items: center;
gap: 0.2em;
}
}
}
}
.select {
border: solid black 1px;
padding: 0.2em 0.4em;
background-color: #fafafa;
}
}
#notifs-hist {
padding: 0.8em;
height: 100%;
display: flex;
flex-direction: column;
&:not(.show) {
display: none;
}
#close-notifs {
align-self: flex-end;
background: none;
border: none;
padding: 0.4em 0.8em;
border-radius: 0.2em;
font-family: inherit;
font-size: inherit;
cursor: pointer;
&:hover {
background-color: #ebebeb;
}
}
.list {
display: flex;
flex-direction: column;
gap: 0.2em;
overflow-y: auto;
margin-top: 0.6em;
.notif {
border-left: solid var(--col) 4px;
padding: 0.4em;
}
}
}

View File

@ -0,0 +1,82 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="64"
height="64"
viewBox="0 0 16.933333 16.933334"
version="1.1"
id="svg857"
inkscape:version="1.3.2 (1:1.3.2+202404261509+091e20ef0f)"
sodipodi:docname="after_icon.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<defs
id="defs851" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="6.9603479"
inkscape:cx="17.025011"
inkscape:cy="32.900654"
inkscape:document-units="mm"
inkscape:current-layer="layer1"
showgrid="true"
units="px"
inkscape:window-width="1366"
inkscape:window-height="716"
inkscape:window-x="0"
inkscape:window-y="24"
inkscape:window-maximized="1"
inkscape:showpageshadow="2"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1">
<inkscape:grid
type="xygrid"
id="grid1402"
empspacing="4"
originx="0"
originy="0"
spacingy="1.0583333"
spacingx="1.0583333"
units="px"
visible="true" />
</sodipodi:namedview>
<metadata
id="metadata854">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Calque 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-280.06665)">
<path
style="fill:none;fill-rule:evenodd;stroke:#000000;stroke-width:1.05833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 2.1166666,288.53332 H 14.816666"
id="path1406"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cc" />
<path
style="fill:none;stroke:#000000;stroke-width:1.05833;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="m 10.583333,285.35832 4.233333,3.175 -4.233333,3.175"
id="path1"
sodipodi:nodetypes="ccc" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@ -0,0 +1,77 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="64"
height="64"
viewBox="0 0 64 64"
version="1.1"
id="svg1"
inkscape:version="1.4.1 (1:1.4.1+202503302257+93de688d07)"
sodipodi:docname="improve.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
showgrid="true"
inkscape:zoom="8.8461329"
inkscape:cx="18.652218"
inkscape:cy="27.808761"
inkscape:window-width="2048"
inkscape:window-height="1228"
inkscape:window-x="0"
inkscape:window-y="24"
inkscape:window-maximized="1"
inkscape:current-layer="layer1">
<inkscape:grid
id="grid1"
units="px"
originx="0"
originy="0"
spacingx="1"
spacingy="1"
empcolor="#0099e5"
empopacity="0.30196078"
color="#0099e5"
opacity="0.14901961"
empspacing="8"
enabled="true"
visible="true" />
</sodipodi:namedview>
<defs
id="defs1" />
<g
inkscape:label="Calque 1"
inkscape:groupmode="layer"
id="layer1">
<g
id="g1"
transform="matrix(2,0,0,2,-30,-24)">
<path
style="fill:none;stroke:#000000;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="m 24,32 c 3,0 8,-5 8,-8 0,3 5,8 8,8 -3,0 -8,5 -8,8 0,-3 -5,-8 -8,-8 z"
id="path1"
sodipodi:nodetypes="ccccc" />
<path
style="fill:none;stroke:#000000;stroke-width:0.7;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="m 19,22 c 1.875,0 5,-3.125 5,-5 0,1.875 3.125,5 5,5 -1.875,0 -5,3.125 -5,5 0,-1.875 -3.125,-5 -5,-5 z"
id="path1-3"
sodipodi:nodetypes="ccccc" />
<path
style="fill:none;stroke:#000000;stroke-width:0.5;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="m 35,19 c 1.125,0 3,-1.875 3,-3 0,1.125 1.875,3 3,3 -1.125,0 -3,1.875 -3,3 0,-1.125 -1.875,-3 -3,-3 z"
id="path1-1"
sodipodi:nodetypes="ccccc" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@ -0,0 +1,78 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="64"
height="64"
viewBox="0 0 64 64"
version="1.1"
id="svg1"
inkscape:version="1.4.1 (1:1.4.1+202503302257+93de688d07)"
sodipodi:docname="improve_clicked.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
showgrid="true"
inkscape:zoom="8.8461329"
inkscape:cx="18.652218"
inkscape:cy="27.808761"
inkscape:window-width="2048"
inkscape:window-height="1228"
inkscape:window-x="0"
inkscape:window-y="24"
inkscape:window-maximized="1"
inkscape:current-layer="layer1">
<inkscape:grid
id="grid1"
units="px"
originx="0"
originy="0"
spacingx="1"
spacingy="1"
empcolor="#0099e5"
empopacity="0.30196078"
color="#0099e5"
opacity="0.14901961"
empspacing="8"
enabled="true"
visible="true" />
</sodipodi:namedview>
<defs
id="defs1" />
<g
inkscape:label="Calque 1"
inkscape:groupmode="layer"
id="layer1">
<g
id="g1"
transform="matrix(2,0,0,2,-30,-24)"
style="fill:#efe348;fill-opacity:1">
<path
style="fill:#efe348;stroke:#000000;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1;fill-opacity:1"
d="m 24,32 c 3,0 8,-5 8,-8 0,3 5,8 8,8 -3,0 -8,5 -8,8 0,-3 -5,-8 -8,-8 z"
id="path1"
sodipodi:nodetypes="ccccc" />
<path
style="fill:#efe348;stroke:#000000;stroke-width:0.7;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1;fill-opacity:1"
d="m 19,22 c 1.875,0 5,-3.125 5,-5 0,1.875 3.125,5 5,5 -1.875,0 -5,3.125 -5,5 0,-1.875 -3.125,-5 -5,-5 z"
id="path1-3"
sodipodi:nodetypes="ccccc" />
<path
style="fill:#efe348;stroke:#000000;stroke-width:0.5;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1;fill-opacity:1"
d="m 35,19 c 1.125,0 3,-1.875 3,-3 0,1.125 1.875,3 3,3 -1.125,0 -3,1.875 -3,3 0,-1.125 -1.875,-3 -3,-3 z"
id="path1-1"
sodipodi:nodetypes="ccccc" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@ -0,0 +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

@ -0,0 +1,131 @@
import TracksTable from "./tracks_table.mjs"
import IntegrityManager from "./integrity_manager.mjs"
import { updateObjectFromJoinedKey } from "./utils.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)
document.getElementById("check-integrity").addEventListener("click", () => this.checkIntegrity())
document.getElementById("improve-all").addEventListener("click", () => this.improveAllNames())
document.getElementById("save").addEventListener("click", () => this.save())
document.getElementById("reload").addEventListener("click", () => window.location.reload())
document.getElementById("toggle-notifs").addEventListener("click", () => this.toggleNotifications())
document.getElementById("close-notifs").addEventListener("click", () => this.closeNotifications())
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)
}
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")
this.notify("Saved successfully !", "success")
} else {
this.notify(`Error ${res.status}: ${res.statusText}`, "error", 10000)
}
})
}
setDirty() {
this.dirty = true
document.getElementById("unsaved").classList.add("show")
}
editTrack(listKey, trackIdx, key, value) {
updateObjectFromJoinedKey(this.data[listKey][trackIdx], key, value)
this.setDirty()
}
notify(text, type, duration=5000) {
const list = document.getElementById("notifs")
const hist = document.getElementById("notifs-hist").querySelector(".list")
const notif = document.createElement("div")
notif.classList.add("notif")
notif.dataset.type = type
notif.innerText = text
list.appendChild(notif)
setTimeout(() => notif.remove(), duration)
notif.addEventListener("click", () => notif.remove())
hist.prepend(notif.cloneNode(true))
}
checkIntegrity() {
if (this.integrity_mgr.checkIntegrity()) {
this.notify("No integrity error detected !", "success")
}
}
improveAllNames() {
this.integrity_mgr.improveAllNames()
this.notify("Improved all names !", "success")
}
toggleNotifications() {
const hist = document.getElementById("notifs-hist")
if (hist.classList.contains("show")) {
this.closeNotifications()
} else {
this.openNotifications()
}
}
openNotifications() {
const hist = document.getElementById("notifs-hist")
hist.classList.add("show")
}
closeNotifications() {
const hist = document.getElementById("notifs-hist")
hist.classList.remove("show")
}
}

View File

@ -0,0 +1,378 @@
import { Track } from "./tracks_table.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"
}
const WORDS = {
forced: {
default: "Forced",
fre: "Forcés",
"fre-ca": "Forcés"
},
full: {
default: "Full",
fre: "Complets",
"fre-ca": "Complets"
},
}
function containsWord(parts, word) {
return Object.values(WORDS[word]).some(w => parts.includes(w))
}
function getWord(word, lang) {
const words = WORDS[word]
return words[lang] ?? words.default
}
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_details"
]
/**
*
* @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() {
this.ignoreList = []
this.mismatches = []
for (const table of Object.values(this.editor.tables)) {
this.checkTableIntegrity(table)
}
if (this.mismatches.length === 0) {
return true
}
this.nextError()
return false
}
/**
*
* @param {import('./tracks_table.mjs').default} table
*/
checkTableIntegrity(table) {
for (const track of table.tracks) {
this.checkTrackIntegrity(track)
}
}
/**
*
* @param {Track} 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 = isLanguageTagAlias(value, track.fields[key])
}
if (!equal) {
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
}
})
}
parseName(trackType, name) {
/** @type {string} */
const lower = name.toLowerCase()
const parts = lower.split(/\b/)
const fields = {flags: {}}
switch (trackType) {
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_details = channels[0]
}
break
case "subtitle":
const stLang = findLanguage(lower)
if (stLang !== null) {fields.language = stLang}
const isForced = containsWord(parts, "forced")
const isFull = containsWord(parts, "full")
if (isForced) {
fields.flags.forced = true
} else if (isFull) {
fields.flags.forced = false
}
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, 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 === "fre") {
name += " VFF"
} else if (fields.language === "fre-ca") {
name += " VFQ"
}*/
if (fields.flags.visual_impaired) {
name += " AD"
}
if (fields.channels_details) {
name += " / " + fields.channels_details
}
break
case "subtitle":
if (fields.flags.forced) {
name += " " + getWord("forced", fields.language)
} else {
name += " " + getWord("full", fields.language)
}
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
}
improveAllNames() {
for (const table of Object.values(this.editor.tables)) {
for (const track of table.tracks) {
this.improveName(track)
}
}
}
/**
*
* @param {Track} track
*/
improveName(track) {
let nameFields = this.parseName(track.table.type, track.fields["name"])
const keys = Object.keys(flattenObj(nameFields))
Object.entries(flattenObj(track.fields)).forEach(([key, val]) => {
if (!keys.includes(key)) {
updateObjectFromJoinedKey(nameFields, key, val)
}
})
const name = this.reconstructName(track.table.type, nameFields)
track.editValue("name", name)
}
}

View File

@ -0,0 +1,242 @@
import { findLanguage, 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() {
this.row = document.createElement("tr")
this.row.dataset.i = this.idx
this.table.fields.forEach(field => {
const td = this.row.insertCell(-1)
const input = this.makeInput(field, this.fields[field.key])
td.appendChild(input)
if (field.key === "name") {
const btn = document.getElementById("improve-btn").cloneNode(true)
btn.id = null
btn.classList.remove("template")
btn.addEventListener("click", () => {
this.improveName()
btn.classList.add("clicked")
setTimeout(() => btn.classList.remove("clicked"), 1000)
})
td.appendChild(btn)
}
})
return this.row
}
makeInput(field, value, listeners=true) {
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"
getValue = () => input.checked
const onehot = this.table.CONSTRAINTS[field.key]?.type == "onehot"
if (listeners && onehot) {
if (value) {
if (field.key in this.table.onehots) {
this.table.editor.notify(
`Error in metadata file: field '${field.name}' is onehot but multiple tracks are enabled. Only the first one will be enabled`,
"error",
20000
)
value = false
} else {
this.table.onehots[field.key] = input
}
}
input.addEventListener("click", e => {
if (!input.checked) {
e.preventDefault()
} else {
if (field.key in this.table.onehots) {
this.table.onehots[field.key].checked = false
this.table.onehots[field.key].dispatchEvent(new Event("change"))
}
this.table.onehots[field.key] = input
}
})
}
input.checked = value
break
case "sel":
input = document.createElement("select")
let options = this.table.OPTIONS[field.key]
if (typeof options === "function") {
options = options()
}
options.forEach(option => {
const opt = document.createElement("option")
opt.innerText = option.display
opt.value = option.value
input.appendChild(opt)
})
if (field.key === "language") {
const lang = findLanguage(value)
if (lang === null) {
this.table.editor.notify(
`Unknown language '${value}' for ${this.table.type} track ${this.idx}`,
"error",
20000
)
} else if (lang !== value) {
this.table.editor.notify(
`Language of ${this.table.type} track ${this.idx} was corrected (${value} -> ${lang})`,
"warning"
)
value = lang
}
}
input.value = value
default:
break
}
input.name = field.key + "[]"
input.dataset.key = field.key
if (this.table.CONSTRAINTS[field.key]?.type === "readonly") {
input.disabled = true
}
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
}
}
improveName() {
this.table.editor.integrity_mgr.improveName(this)
}
}
export default class TracksTable {
OPTIONS = {
"language": getLanguageOptions
}
CONSTRAINTS = {
"flags/default": {
type: "onehot"
},
"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.onehots = {}
}
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()
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)
}
}

View File

@ -0,0 +1,116 @@
/**
* 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
}
// 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 = {
"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", "fra", "french", "francais", "français", "vf", "vff", "france"]
},
"eng": {
display: "English",
code: "gb",
aliases: ["en", "ang", "english", "anglais", "uk", "gb", "usa", "british", "american", "amérique", "amerique", "angleterre", "royaume-uni"]
},
"deu": {
display: "Deutsch",
code: "de",
aliases: ["de", "ger", "german", "allemand", "deutsch", "germany", "allemagne"]
},
"kor": {
display: "Korean",
code: "kr",
aliases: ["ko", "kr", "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", "tür", "turkish", "turc", "turquie"]
},
"und": {
display: "Undefined",
code: "",
aliases: []
}
}
export function getLanguageAliases(langTag) {
return (langTag === "und" ? [] : [langTag]).concat(LANGUAGES[langTag].aliases)
}
/**
* 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 = getLanguageAliases(lang)
const matches = aliases.some(a => {
return new RegExp("\\b" + a + "\\b").test(value)
})
if (matches) {
return lang
}
}
return null
}
export function isLanguageTagAlias(langTag, value) {
return getLanguageAliases(langTag).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}`}
})
}

View File

@ -4,9 +4,10 @@ import json
import os
import socketserver
from typing import Optional
from urllib.parse import urlparse, parse_qs
from urllib.parse import urlparse, parse_qs, unquote
PORT = 8000
MAX_SIZE = 10e6
class MyHandler(SimpleHTTPRequestHandler):
DATA_DIR = "metadata"
@ -23,7 +24,12 @@ class MyHandler(SimpleHTTPRequestHandler):
def read_body_data(self):
self.log_message("Reading body data")
try:
raw_data = self.rfile.read(int(self.headers["Content-Length"]))
size: int = int(self.headers["Content-Length"])
if size > MAX_SIZE:
self.send_error(HTTPStatus.CONTENT_TOO_LARGE)
self.log_error(f"Payload is too big ({MAX_SIZE=}B)")
return False
raw_data = self.rfile.read(size)
self.data = json.loads(raw_data)
except:
self.send_error(HTTPStatus.NOT_ACCEPTABLE, "Malformed JSON body")
@ -32,6 +38,7 @@ class MyHandler(SimpleHTTPRequestHandler):
return True
def do_GET(self):
self.path = unquote(self.path)
self.query = parse_qs(urlparse(self.path).query)
if self.path.startswith("/api/"):
self.handle_api_get(self.path.removeprefix("/api/").removesuffix("/"))
@ -39,6 +46,7 @@ class MyHandler(SimpleHTTPRequestHandler):
super().do_GET()
def do_POST(self):
self.path = unquote(self.path)
self.query = parse_qs(urlparse(self.path).query)
if self.path.startswith("/api/"):
self.handle_api_post(self.path.removeprefix("/api/").removesuffix("/"))
@ -50,21 +58,32 @@ class MyHandler(SimpleHTTPRequestHandler):
if path == "files":
files: list[str] = self.get_files()
self.send_json(files)
return
def handle_api_post(self, path: str):
if path == "file":
if self.read_body_data():
data = self.get_file(self.data["file"])
elif path.startswith("file"):
filename: str = path.split("/", 1)[1]
data = self.read_file(filename)
if data is None:
self.send_error(HTTPStatus.NOT_FOUND)
self.log_message("File not found")
else:
self.log_message("Got file")
self.send_json(data)
else:
self.send_response(HTTPStatus.NOT_FOUND, f"Unknown path {path}")
self.end_headers()
def handle_api_post(self, path: str):
if path.startswith("file"):
if self.read_body_data():
filename: str = path.split("/", 1)[1]
if self.write_file(filename, self.data):
self.send_response(HTTPStatus.OK)
self.end_headers()
else:
self.send_response(HTTPStatus.NOT_FOUND, f"Unknown path {path}")
self.end_headers()
def send_json(self, data: dict|list):
self.send_response(200)
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps(data).encode("utf-8"))
@ -72,13 +91,26 @@ class MyHandler(SimpleHTTPRequestHandler):
def get_files(self):
return os.listdir(self.DATA_DIR)
def get_file(self, filename: str) -> Optional[dict|list]:
def read_file(self, filename: str) -> Optional[dict|list]:
if filename not in self.get_files():
return None
with open(os.path.join(self.DATA_DIR, filename), "r") as f:
data = json.load(f)
return data
def write_file(self, filename: str, data: dict|list) -> bool:
if filename not in self.get_files():
self.send_error(HTTPStatus.NOT_FOUND)
return False
try:
with open(os.path.join(self.DATA_DIR, filename), "w") as f:
json.dump(data, f, indent=2)
except:
self.send_error(HTTPStatus.INTERNAL_SERVER_ERROR)
return False
return True
def main():
with socketserver.TCPServer(("", PORT), MyHandler) as httpd: