home · contact · privacy
Move JS code into separate files, and over-all refactoring, minor other code improvem... master
authorChristian Heller <c.heller@plomlompom.de>
Fri, 2 Jan 2026 00:12:08 +0000 (01:12 +0100)
committerChristian Heller <c.heller@plomlompom.de>
Fri, 2 Jan 2026 00:12:08 +0000 (01:12 +0100)
30 files changed:
src/templates/_base.js [new file with mode: 0644]
src/templates/_base.tmpl
src/templates/_macros.tmpl
src/templates/downloads.html [new file with mode: 0644]
src/templates/downloads.js [new file with mode: 0644]
src/templates/downloads.tmpl [deleted file]
src/templates/file.html [new file with mode: 0644]
src/templates/file.js [new file with mode: 0644]
src/templates/file_data.tmpl [deleted file]
src/templates/files.html [new file with mode: 0644]
src/templates/files.js [new file with mode: 0644]
src/templates/files.tmpl [deleted file]
src/templates/playlist.html [new file with mode: 0644]
src/templates/playlist.js [new file with mode: 0644]
src/templates/playlist.tmpl [deleted file]
src/templates/tags.html [new file with mode: 0644]
src/templates/tags.js [new file with mode: 0644]
src/templates/tags.tmpl [deleted file]
src/templates/yt_queries.html [new file with mode: 0644]
src/templates/yt_queries.js [new file with mode: 0644]
src/templates/yt_queries.tmpl [deleted file]
src/templates/yt_query.html [new file with mode: 0644]
src/templates/yt_query.js [new file with mode: 0644]
src/templates/yt_result.html [new file with mode: 0644]
src/templates/yt_result.js [new file with mode: 0644]
src/templates/yt_result.tmpl [deleted file]
src/templates/yt_results.tmpl [deleted file]
src/ytplom/http.py
src/ytplom/misc.py
src/ytplom/sync.py

diff --git a/src/templates/_base.js b/src/templates/_base.js
new file mode 100644 (file)
index 0000000..0aa2c25
--- /dev/null
@@ -0,0 +1,533 @@
+/*
+global
+EventSource
+clearInterval
+console
+document
+fetch
+setInterval
+setTimeout
+window
+*/
+/*
+eslint
+"array-element-newline": [
+    "error",
+    "consistent",
+    "multiline"
+],
+"capitalized-comments": [
+    "error",
+    "never"
+],
+"function-paren-newline": "off",
+"max-lines": [
+    "error",
+    {"max": 450, "skipBlankLines": true, "skipComments": true}
+],
+"max-lines-per-function": [
+    "error",
+    84
+],
+"max-params": [
+    "error",
+    5
+],
+"max-statements": [
+    "error",
+    22
+],
+"multiline-ternary": [
+    "error",
+    "always-multiline"
+],
+"newline-after-var": "off",
+"newline-before-return": "off",
+"no-console": [
+    "error",
+    { "allow": ["dir", "log", "warn", "error"] }
+],
+"no-extra-parens": "off",
+"no-nested-ternary": "off",
+"no-ternary": "off",
+"padded-blocks": [
+    "error",
+    "never"
+],
+"one-var": [
+    "error",
+    "consecutive"
+],
+*/
+
+const
+    CLS_REVERSE = "reverse",
+    CLS_SORTABLE_ROW = "sortable_row",
+    CLS_SORTING = "sorting",
+    DEC_BASE = 10,
+    DUR_MS_IN_S = 1000,
+    DUR_S_IN_M = 60,
+    DUR_S_MIN = 0,
+    DUR_S_TIMESTAMP_INC = 1,
+    DUR_S_TO_RETRY = 5,
+    FACTOR_IDENTICAL = 1,
+    FACTOR_INVERT = -1,
+    ID_PLAYER_CTLS = "player_controls",
+    ID_SORTABLE_TABLE = "sortable_table",
+    IS_GT = 1,
+    IS_LTE = -1,
+    PATH_EVENTS = "/events",
+    PATH_PLAYER = "/player",
+    STATUS_CODE_OK = 200;
+export const
+    SYMBOL_DOWN = "v",
+    SYMBOL_UP = "^";
+export const
+    BUTTONS_UP_DOWN = [
+        [SYMBOL_UP, "up"],
+        [SYMBOL_DOWN, "down"]
+    ],
+    CMD_ADD_NEXT = "inject",
+    CMD_ADD_PLAY = "injectplay",
+    CMD_RM = "rm",
+    IDX_PATH_ID = 2,
+    IDX_START = 0,
+    LABEL_ADD_NEXT = "add as next",
+    LABEL_ADD_PLAY = "add and play",
+    LEN_EMPTY = 0,
+    PARAM_TAG_NEEDED = "needed_tag",
+    PATH_DOWNLOADS_JSON = "/downloads.json",
+    PATH_FILES = "/files",
+    PATH_FILES_JSON = "/files.json",
+    PATH_PREFIX_DOWNLOAD = "/download/",
+    PATH_PREFIX_FILE = "/file/",
+    PATH_PREFIX_FILE_JSON = "/file.json/",
+    PATH_PREFIX_YT_RESULT = "/yt_result/",
+    PREFIX_CLICKABLE = "clickable",
+    PREFIX_MINUS = "-",
+    SYMBOL_RM = "x",
+    eventHandlers = {
+        "ping" (
+        ) {
+            // dummy to keep connection alive
+        }
+    };
+let
+    eventsParams = "",
+    eventsStream = null,
+    playerDiv = null,
+    timestampInterval = null;
+
+const connectEvents = (
+) => {
+    eventsStream = new EventSource(`${PATH_EVENTS}?${eventsParams}`);
+    eventsStream.onmessage = (
+        event
+    ) => {
+        const payload = JSON.parse(event.data);
+        Object.keys(payload).forEach(
+            (key) => eventHandlers[key](payload[key]));
+    };
+    eventsStream.onerror = (
+        error
+    ) => {
+        const whileConnecting = eventsStream.readyState ===
+                                eventsStream.CONNECTING;
+        console.log(
+            `Error on ${PATH_EVENTS} connection:`,
+            error
+        );
+        eventsStream.close();
+        if (whileConnecting) {
+            console.log("Error seemed connection-related, trying reconnect.");
+            setTimeout(
+                connectEvents,
+                DUR_S_TO_RETRY * DUR_MS_IN_S
+            );
+        } else {
+            console.log(
+                "Error does not seem connection-related, therefore aborting."
+            );
+        }
+    };
+};
+
+export const subscribeEvents = (
+    params = ""
+) => {
+    eventsParams = params;
+    window.addEventListener(
+        "load",
+        connectEvents
+    );
+};
+
+export const addChildTo = (
+    tag,
+    parent,
+    attrs = {}
+) => {
+    const el = document.createElement(tag);
+    parent.appendChild(el);
+    for (const [key, value] of Object.entries(attrs)) {
+        el[key] = value;
+    }
+    return el;
+};
+
+export const addATo = (
+    parent,
+    textContent,
+    href
+) => addChildTo(
+    "a",
+    parent,
+    {
+        href,
+        textContent
+    }
+);
+
+export const wrappedFetch = async (
+    target,
+    verbose,
+    fetchKwargs
+) => {
+    if (verbose) {
+        console.log(
+            `Fetching ${target}, kwargs:`,
+            fetchKwargs
+        );
+    }
+    try {
+        const response = await fetch(
+            target,
+            fetchKwargs
+        );
+        if (response.status !== STATUS_CODE_OK) {
+            const err = Error(
+                `Unexpected response status: ${response.status}`);
+            err.response = response;
+            throw err;
+        }
+        return response;
+    } catch (error) {
+        console.log(`Error on sending to ${target}: ${error.message}`);
+        console.dir(error);
+        throw error;
+    }
+};
+
+export const wrappedPost = async (
+    target,
+    body,
+    verbose = false
+) => {
+    const response = await wrappedFetch(
+        target,
+        verbose,
+        {
+            "body": JSON.stringify(body),
+            "headers": {"Content-Type": "application/json"},
+            "method": "POST"
+        }
+    );
+    return response;
+};
+
+export const wrappedCommand = (
+    target,
+    command
+) => wrappedPost(
+    target,
+    {"command": [command]}
+);
+
+export const playerCommand = (command) => {
+    wrappedCommand(
+        PATH_PLAYER,
+        command
+    );
+};
+
+export const addButtonTo = (
+    parent,
+    label,
+    disabled,
+    onclick
+) => addChildTo(
+    "button",
+    parent,
+    {
+        disabled,
+        onclick,
+        "textContent": label
+    }
+);
+
+export const addPlayerBtnTo = (
+    parent,
+    label,
+    command,
+    disabled = false
+) => addButtonTo(
+    parent,
+    label,
+    disabled,
+    () => playerCommand(command)
+);
+
+export const addTextTo = (
+    parent,
+    text
+) => {
+    const node = document.createTextNode(text);
+    parent.appendChild(node);
+    return node;
+};
+
+export const addTdTo = (
+    parent,
+    attrs = {}
+) => addChildTo(
+    "td",
+    parent,
+    attrs
+);
+
+export const addATdTo = (
+    parent,
+    text,
+    href
+) => addATo(
+    addTdTo(parent),
+    text,
+    href
+);
+
+export const addTrTo = (
+    parent
+) => addChildTo(
+    "tr",
+    parent
+);
+
+export const addTagLinksTo = (
+    parent,
+    tags
+) => {
+    tags.forEach(
+        (
+            tag
+        ) => {
+            addATo(
+                parent,
+                tag,
+                `${PATH_FILES}?${PARAM_TAG_NEEDED}=${encodeURIComponent(tag)}`
+            );
+            addTextTo(
+                parent,
+                " "
+            );
+        }
+    );
+};
+
+eventHandlers.player = (
+    data
+) => {
+    const
+        addTextToDiv = (
+            text
+        ) => addTextTo(
+            playerDiv,
+            text
+        );
+    const // eslint-disable-line one-var -- to provide addTextToDiv above
+        addPlayerBtnToDiv = (
+            label,
+            command
+        ) => addPlayerBtnTo(
+            playerDiv,
+            label,
+            command,
+            !data.can_play
+        ),
+        addSeparator = (
+        ) => addTextToDiv(
+            " · "
+        );
+    playerDiv.innerHTML = "";
+    addPlayerBtnToDiv(
+        "prev",
+        "prev"
+    );
+    addPlayerBtnToDiv(
+        "next",
+        "next"
+    );
+    addPlayerBtnToDiv(
+        data.is_playing ? "pause" : "play",
+        "play"
+    );
+    addSeparator();
+    addTextToDiv(
+        data.is_running ? (data.is_playing ? "playing" : "paused") : "stopped"
+    );
+    if (data.title_digest) {
+        const formatSeconds = (totalSeconds) => {
+            if (totalSeconds < DUR_S_MIN) {
+                return "?";
+            }
+            const
+                minutes = Math.floor(totalSeconds / DUR_S_IN_M),
+                seconds = totalSeconds % DUR_S_IN_M;
+            return `${minutes}:${seconds < DEC_BASE ? "0" : ""}${seconds}`;
+        };
+        addTextToDiv(" (");
+        const timestampSpan = addChildTo(
+            "span",
+            playerDiv
+        );
+        clearInterval(timestampInterval);
+        let {timestamp} = data;
+        timestampSpan.textContent = formatSeconds(timestamp);
+        if (data.is_playing) {
+            timestampInterval = setInterval(
+                () => {
+                    timestamp += DUR_S_TIMESTAMP_INC;
+                    timestampSpan.textContent = formatSeconds(timestamp);
+                },
+                DUR_MS_IN_S / data.speed
+            );
+        }
+        addTextToDiv(`/${formatSeconds(data.duration)}): `);
+        addATo(
+            playerDiv,
+            data.title,
+            `${PATH_PREFIX_FILE}${data.title_digest}`
+        );
+        addSeparator();
+        addTagLinksTo(
+            playerDiv,
+            data.title_tags
+        );
+    }
+};
+
+export const sortableTableSetup = {
+    "itemsForSortableTable": null,
+    "populateListItemRow": null,
+    "sortCols": null,
+    "sortKey": null
+};
+
+export const drawTable = (
+    idTable,
+    clsTableRow,
+    items,
+    populateRow,
+    emptyFirst = true
+) => {
+    const table = document.getElementById(idTable);
+    if (emptyFirst) {
+        Array.from(
+            document.getElementsByClassName(clsTableRow)
+        ).forEach((row) => row.remove());
+    }
+    items.forEach((item, idx, arr) => {
+        const tr = addTrTo(table);
+        tr.classList.add(clsTableRow);
+        populateRow(
+            tr,
+            item,
+            idx,
+            arr
+        );
+    });
+};
+
+export const drawSortableTable = (
+) => {
+    sortableTableSetup.itemsForSortableTable.sort(
+        (
+            cmpA,
+            cmpB
+        ) => {
+            let
+                inverter = FACTOR_IDENTICAL,
+                {sortKey} = sortableTableSetup;
+            if (sortKey.startsWith(PREFIX_MINUS)) {
+                inverter = FACTOR_INVERT;
+                sortKey = sortKey.substring(PREFIX_MINUS.length);
+            }
+            return inverter * (cmpA[sortKey] > cmpB[sortKey] ? IS_GT : IS_LTE);
+        }
+    );
+    drawTable(
+        ID_SORTABLE_TABLE,
+        CLS_SORTABLE_ROW,
+        sortableTableSetup.itemsForSortableTable,
+        sortableTableSetup.populateListItemRow
+    );
+};
+
+export const assignOnclicks = (items, onclick) => {
+    items.forEach((item) => {
+        const el = document.getElementById(`${PREFIX_CLICKABLE}:${item}`);
+        el.onclick = (
+        ) => onclick(
+            el,
+            item
+        );
+    });
+};
+
+window.addEventListener(
+    "beforeunload",
+    () => {
+        if (eventsStream) {
+            eventsStream.close();
+        }
+    }
+);
+
+window.addEventListener(
+    "load",
+    () => {
+        playerDiv = document.getElementById(ID_PLAYER_CTLS);
+
+        if (sortableTableSetup.populateListItemRow) {
+            drawSortableTable();
+            assignOnclicks(
+                sortableTableSetup.sortCols,
+                (
+                    button,
+                    colName
+                ) => {
+                    Array.from(
+                        document.getElementsByClassName(CLS_SORTING)
+                    ).forEach(
+                        (
+                            btnIdx
+                        ) => {
+                            if (btnIdx !== button) {
+                                btnIdx.classList.remove(CLS_SORTING);
+                                btnIdx.classList.remove(CLS_REVERSE);
+                            } else if (btnIdx.classList.contains(CLS_SORTING)) {
+                                btnIdx.classList.toggle(CLS_REVERSE);
+                            }
+                        }
+                    );
+                    button.classList.add(CLS_SORTING);
+                    const prefix = button.classList.contains(CLS_REVERSE)
+                        ? ""
+                        : PREFIX_MINUS;
+                    sortableTableSetup.sortKey = `${prefix}${colName}`;
+                    drawSortableTable();
+                }
+            );
+        }
+    }
+);
+
index 1845b4a13b99f384ce851e7f080656ae112caa11..1827aebe75800d85848fbe3c79925114ab6a1174 100644 (file)
-{% import '_macros.tmpl' as macros %}
+{% macro section_link(pagename, target, display_name = false) %}
+{% if target != pagename %}<a href="/{{target}}">{% endif %}
+{% if display_name %}{{display_name}}{% else %}{{target}}{% endif %}
+{% if target != pagename %}</a>{% endif %}
+{% endmacro %}
+
 <!DOCTYPE html>
 <html>
+
 <head>
 <meta charset="UTF-8">
-<script>
-const MS_IN_S = 1000;
-const RETRY_INTERVAL_S = 5;
-const PATH_EVENTS = "/{{page_names.events}}";
-const PATH_PLAYER = "/{{page_names.player}}";
-const PATH_PREFIX_FILE = "/{{page_names.file}}/";
-const PATH_FILES = "/{{page_names.files}}";
-
-function add_child_to(tag, parent, attrs={}) {
-    const el = document.createElement(tag);
-    parent.appendChild(el);
-    for (const [key, value] of Object.entries(attrs)) {
-        el[key] = value;
-    }
-    return el;
-}
-
-function add_a_to(parent, text, href) {
-    add_child_to('a', parent, {textContent: text, href: href});
-}
-
-function add_player_btn_to(parent, label, command, disabled=false) {
-    add_child_to('button', parent, {
-        textContent: label,
-        onclick: function() { player_command(command) },
-        disabled: disabled
-    });
-}
-
-function add_text_to(parent, text) {
-    const node = document.createTextNode(text);
-    parent.appendChild(node);
-    return node;
-}
-
-var events_params = "";
-var events_stream = null;
-var event_handlers = {'ping': function(data) {}};
-
-window.addEventListener("beforeunload", function() {
-    if (events_stream) {
-        events_stream.close();
-    }
-});
-
-function connect_events() {
-    events_stream = new EventSource(`${PATH_EVENTS}?${events_params}`);
-    events_stream.onmessage = function(event) {
-        const payload = JSON.parse(event.data);
-        for (const [key, data] of Object.entries(payload)) { event_handlers[key](data) }
-    }
-    events_stream.onerror = function(error) {
-        const while_connecting = events_stream.readyState == events_stream.CONNECTING;
-        console.log(`Error on ${PATH_EVENTS} connection:`, error);
-        events_stream.close();
-        if (while_connecting) {
-            console.log("Error seemed connection-related, trying reconnect.");
-            setTimeout(connect_events, RETRY_INTERVAL_S * MS_IN_S);
-        } else {
-            console.log("Error does not seem connection-related, therefore aborting.");
-        }
-    }
-}
-
-async function wrapped_fetch(target, fetch_kwargs=null, verbose=false) {
-    if (verbose) {
-        console.log(`Trying to fetch ${target}, kwargs:`, fetch_kwargs);
-    }
-    try {
-        const response = await (fetch_kwargs ? fetch(target, fetch_kwargs) : fetch(target));
-        if (200 != response.status) {
-            console.log(`Got unexpected fetch response from ${target}:`, response);
-        } else {
-            return response;
-        }
-    } catch(error) {
-        console.log(`Error on sending to ${target}:`, error);
-    }
+<script type="module" src="/{{pagename}}.js"></script>
+<style>
+body {
+    background-color: {{background_color}};
 }
-
-async function wrapped_post(target, body, verbose=false) {
-    const fetch_kwargs = {
-        method: "POST",
-        headers: {"Content-Type": "application/json"},
-        body: JSON.stringify(body)
-    };
-    const response = await wrapped_fetch(target, fetch_kwargs, verbose);
-    return response;
+table {
+    width: 100%;
 }
-
-function player_command(command) {
-    wrapped_post(PATH_PLAYER, {command: [command]})
+td, th {
+    vertical-align: top;
+    text-align: left;
+    margin: 0;
+    padding: 0;
 }
-
-function add_tag_links_to(parent_element, tags) {
-    tags.forEach((tag) => {
-        add_a_to(parent_element, tag, `${PATH_FILES}?needed_tag=${encodeURIComponent(tag)}`);
-        add_text_to(parent_element, ' ');
-    });
+#header {
+    position: sticky;
+    top: 0;
 }
-
-var timestamp_interval = null;
-
-event_handlers.player = function(data) {
-    const div = document.getElementById("player_controls");
-    div.innerHTML = "";
-    add_player_btn_to(div, "prev", "prev", !data.can_play);
-    add_player_btn_to(div, "next", "next", !data.can_play);
-    add_player_btn_to(div, data.is_playing ? "pause" : "play", "play", !data.can_play);
-    add_text_to(div, " · ");
-    add_text_to(div, data.is_running ? (data.is_playing ? "playing" : "paused")
-                                     : "stopped");
-    if (data.title_digest) {
-        function format_seconds(total_seconds) {
-            if (total_seconds < 0) {
-               return "?";
-            }
-            const seconds = total_seconds % 60;
-            const minutes = Math.floor(total_seconds / 60);
-            return `${minutes}:` + (seconds < 10 ? '0' : '') + `${seconds}`
-        }
-        add_text_to(div, " (");
-        const timestamp_span = add_child_to("span", div);
-        clearInterval(timestamp_interval);
-        let timestamp = data.timestamp;
-        timestamp_span.textContent = format_seconds(timestamp);
-        if (data.is_playing) {
-            timestamp_interval = setInterval(function() {
-                 timestamp += 1;
-                 timestamp_span.textContent = format_seconds(timestamp);
-            }, MS_IN_S / data.speed);
-        }
-        add_text_to(div, "/" + format_seconds(data.duration) + "): ");
-        add_a_to(div, data.title, `${PATH_PREFIX_FILE}${data.title_digest}`);
-        add_text_to(div, " · ");
-        add_tag_links_to(div, data.title_tags);
-
-    }
-};
-
-{% block script %}
-{% endblock %}
-
-connect_events();
-</script>
-<style>
-body { background-color: {{background_color}}; }
-table { width: 100%; }
-td, th { vertical-align: top; text-align: left; margin: 0; padding: 0; }
-#header { position: sticky; top: 0; background-color: {{background_color}}; }
 {% block css %}
 {% endblock %}
 </style>
 </head>
+
 <body>
 <div id="header">
-{{ macros.link_if("playlist" != selected, page_names.playlist) }}
-· {{ macros.link_if("files" != selected, page_names.files) }}
-· {{ macros.link_if("tags" != selected, page_names.tags) }}
-· {{ macros.link_if("yt_queries" != selected, page_names.yt_queries, "queries") }}
-· {{ macros.link_if("downloads" != selected, page_names.downloads) }}
+{{ section_link(pagename, "playlist") }} ·
+{{ section_link(pagename, "files") }} ·
+{{ section_link(pagename, "tags") }} ·
+{{ section_link(pagename, "yt_queries", "queries") }} ·
+{{ section_link(pagename, "downloads") }}
 <hr />
+
 <div id="player_controls"></div>
 <hr />
+
 {% block body %}
 {% endblock %}
+
 </body>
 </html>
index 8f5457fbf582913ab5f4ca71f80dbd90ba700637..94bab91f0ea30afdb3fd34bd69633c7f49ae6097 100644 (file)
@@ -1,53 +1,13 @@
-{% macro link_if(cond, target, display_name = false ) %}
-{% if cond %}<a href="/{{target}}">{% endif %}
-{% if display_name %}{{display_name}}{% else %}{{target}}{% endif %}
-{% if cond %}</a>{% endif %}
-{% endmacro %}
-
-
-
 {% macro css_sortable_table() %}
-button.sorter { background-color: transparent; }
-button.sorting { background-color: white; color: black; }
-button.reverse { background-color: black; color: white; }
-{% endmacro %}
-
-
-
-{% macro js_manage_sortable_table(default_sort_key) %}
-var sort_key = "{{default_sort_key}}";
-
-function draw_sortable_table() {
-    items_for_sortable_table.sort((a, b) => {
-        let inverter = 1;
-        let _sort_key = sort_key;
-        if (sort_key[0] == '-') {
-            inverter = -1;
-            _sort_key = sort_key.substring(1);
-        }
-        return inverter * ((a[_sort_key] > b[_sort_key]) ? 1 : -1);
-    });
-    const table = document.getElementById("sortable_table");
-    Array.from(document.getElementsByClassName("sortable_row")).forEach((row) => row.remove());
-    items_for_sortable_table.forEach((item) => {
-        const tr = add_child_to('tr', table);
-        tr.classList.add("sortable_row");
-        populate_list_item_row(tr, item);
-    });
+button.sorter {
+    background-color: transparent;
+}
+button.sorting {
+    background-color: white;
+    color: black;
 }
-
-function sort_by(button, key) {
-    Array.from(document.getElementsByClassName("sorting")).forEach((btn_i) => {
-       if (btn_i != button) {
-           btn_i.classList.remove("sorting");
-           btn_i.classList.remove("reverse");
-       } else if (btn_i.classList.contains("sorting")) {
-           btn_i.classList.toggle("reverse");
-       }
-    });
-    button.classList.add("sorting");
-    sort_key = button.classList.contains("reverse") ? `-${key}` : key;
-    draw_sortable_table();
+button.reverse {
+    background-color: black;
+    color: white;
 }
 {% endmacro %}
-
diff --git a/src/templates/downloads.html b/src/templates/downloads.html
new file mode 100644 (file)
index 0000000..d785c32
--- /dev/null
@@ -0,0 +1,7 @@
+{% extends '_base.tmpl' %}
+
+
+{% block body %}
+<table id="download_rows">
+</table>
+{% endblock %}
diff --git a/src/templates/downloads.js b/src/templates/downloads.js
new file mode 100644 (file)
index 0000000..c855fc1
--- /dev/null
@@ -0,0 +1,176 @@
+/*
+eslint
+"capitalized-comments": [
+    "error",
+    "never"
+],
+"line-comment-position": [
+    "error",
+    { "position": "beside" }
+],
+"max-lines-per-function": [
+    "error",
+    112
+],
+"max-params": [
+    "error",
+    4
+],
+"multiline-ternary": [
+    "error",
+    "always-multiline"
+],
+"newline-after-var": "off",
+"no-extra-parens": "off",
+"no-inline-comments": "off",
+"no-multi-spaces": [
+    "error",
+    { "ignoreEOLComments": true }
+],
+"no-ternary": "off",
+"padded-blocks": [
+    "error",
+    "never"
+],
+*/
+
+import {
+    BUTTONS_UP_DOWN,
+    CMD_RM,
+    IDX_START,
+    PATH_DOWNLOADS_JSON,
+    PATH_PREFIX_FILE,
+    PATH_PREFIX_YT_RESULT,
+    SYMBOL_DOWN,
+    SYMBOL_RM,
+    SYMBOL_UP,
+    addATdTo,
+    addButtonTo,
+    addTdTo,
+    drawTable,
+    eventHandlers,
+    subscribeEvents,
+    wrappedCommand
+} from "./_base.js";
+
+const
+    CLS_ROW = "download_row",
+    IDX_INC = 1,
+    ID_TABLE = "download_rows",
+    PARAM_DOWNLOADS = "downloads=1";
+
+eventHandlers.downloads = (data) => {
+    const
+        addCommandButtonTo = (
+            parent,
+            label,
+            disabled,
+            command
+        ) => addButtonTo(
+            parent,
+            label,
+            disabled,
+            () => wrappedCommand(
+                PATH_DOWNLOADS_JSON,
+                command
+            )
+        ),
+        drawDownloadTable = (
+            items,
+            populateRow,
+            emptyFirst = true
+        ) => drawTable(
+            ID_TABLE,
+            CLS_ROW,
+            items,
+            populateRow,
+            emptyFirst
+        );
+    drawDownloadTable(
+        data.downloaded,
+        (
+            tr,
+            downloaded
+        ) => {
+            addATdTo(  // col 1
+                tr,
+                downloaded.path,
+                `${PATH_PREFIX_FILE}${downloaded.digest}`
+            );
+            addATdTo(  // col 2
+                tr,
+                downloaded.title,
+                `${PATH_PREFIX_YT_RESULT}${downloaded.yt_id}`
+            );
+        }
+    );
+    drawDownloadTable(
+        data.downloading ? [data.downloading] : [],
+        (
+            tr,
+            downloading
+        ) => {
+            const tdEntryControl = addTdTo(tr);  // col 1
+            addCommandButtonTo(
+                tdEntryControl,
+                SYMBOL_RM,
+                false,
+                CMD_RM
+            );
+            addTdTo(  // col 2
+                tr,
+                {"textContent": downloading.status}
+            );
+            addATdTo(  // col 3
+                tr,
+                downloading.title,
+                `${PATH_PREFIX_YT_RESULT}${data.downloading.yt_id}`
+            );
+        },
+        false
+    );
+    drawDownloadTable(
+        data.to_download,
+        (
+            tr,
+            toDownload,
+            idx,
+            arr
+        ) => {
+            addCommandButtonTo(  // col 1
+                tr,
+                SYMBOL_RM,
+                false,
+                `${CMD_RM}_${idx}`
+            );
+            const tdEntryControl = addTdTo(tr);  // col 2
+            for (
+                const [
+                    symbol,
+                    prefix
+                ] of BUTTONS_UP_DOWN
+            ) {
+                const disabled =
+                    (idx === IDX_START &&
+                     symbol === SYMBOL_UP) ||
+                    ((idx + IDX_INC) === arr.length &&
+                      symbol === SYMBOL_DOWN);
+                addCommandButtonTo(
+                    tdEntryControl,
+                    symbol,
+                    disabled,
+                    `${prefix}_${idx}`
+                );
+            }
+            addATdTo(  // col 3
+                tr,
+                toDownload.title,
+                `${PATH_PREFIX_YT_RESULT}${toDownload.yt_id}`
+            );
+        },
+        false
+    );
+};
+
+subscribeEvents(PARAM_DOWNLOADS);
+
diff --git a/src/templates/downloads.tmpl b/src/templates/downloads.tmpl
deleted file mode 100644 (file)
index 2ed03f8..0000000
+++ /dev/null
@@ -1,125 +0,0 @@
-{% extends '_base.tmpl' %}
-
-
-{% block script %}
-events_params += 'downloads=1';
-event_handlers.downloads = function(data) {
-    let table = document.getElementById('downloaded_rows');
-    table.innerHTML = '';
-    for (let i = 0; i < data.downloaded.length; i++) {
-        const download_ = data.downloaded[i];
-        const tr = add_child_to(
-            tag='tr',
-            parent=table);
-        add_a_to(
-            parent=add_child_to(
-                tag='td',
-                parent=tr),
-            text=download_.path,
-            href="/{{page_names.file}}/" + download_.digest);
-        add_a_to(
-            parent=add_child_to(
-                tag='td',
-                parent=tr),
-            text=download_.title,
-            href="/{{page_names.yt_result}}/" + download_.yt_id);
-    }
-
-    add_child_to(
-        tag='td',
-        parent=add_child_to(
-            tag='tr',
-            parent=table),
-        attrs={
-            textContent: 'DOWNLOADING'});
-
-    if (data.downloading) {
-        const tr = add_child_to(
-            tag='tr',
-            parent=table);
-        // col 1
-        const td_entry_control = add_child_to(
-            tag='td',
-            parent=tr);
-        add_child_to(
-            tag='button',
-            parent=td_entry_control,
-            attrs={
-                textContent: 'x',
-                onclick: function() {
-                    wrapped_post(
-                        target="{{page_names.downloads_json}}",
-                        body={
-                            command: ['abort']}); }
-           })
-        // col 2
-        add_child_to(
-            tag='td',
-            parent=tr,
-            attrs={
-                textContent: data.downloading.status});
-        // col 3
-        add_a_to(
-            parent=add_child_to(
-                tag='td',
-                parent=tr),
-            text=data.downloading.title,
-            href="/{{page_names.yt_result}}/" + data.downloading.yt_id);
-    }
-
-    for (let i = 0; i < data.to_download.length; i++) {
-        const download_ = data.to_download[i];
-        if (download_.status == 'downloaded') {
-            continue; 
-        } 
-        const tr = add_child_to(
-            tag='tr',
-            parent=table);
-        // col 1
-        add_child_to(
-            tag='button',
-            parent=tr,
-            attrs={
-                textContent: 'x',
-                onclick: function() {
-                    wrapped_post(
-                        target="{{page_names.downloads_json}}",
-                        body={
-                            command: [`unqueue_${i}`]}); }
-           })
-        // col 2
-        const td_entry_control = add_child_to(
-            tag='td',
-            parent=tr);
-        for (const [symbol, prefix] of [['^', 'up'],
-                                        ['v', 'down']]) {
-            add_child_to(
-                tag='button',
-                parent=td_entry_control,
-                attrs={
-                    textContent: symbol,
-                    onclick: function() {
-                        wrapped_post(
-                            target="{{page_names.downloads_json}}",
-                            body={
-                                command: [`${prefix}_${i}`]}); }
-               })
-        }
-        // col 3
-        add_a_to(
-            parent=add_child_to(
-                tag='td',
-                parent=tr),
-            text=download_.title,
-            href="/{{page_names.yt_result}}/" + download_.yt_id);
-    }
-}
-{% endblock %}
-
-
-{% block body %}
-<table id="downloaded_rows">
-</table>
-<table id="to_download_rows">
-</table>
-{% endblock %}
diff --git a/src/templates/file.html b/src/templates/file.html
new file mode 100644 (file)
index 0000000..1a7f27e
--- /dev/null
@@ -0,0 +1,87 @@
+{% extends '_base.tmpl' %}
+
+
+{% block css %}
+td.top_field {
+    width: 100%;
+}
+td.tag_checkboxes {
+    width: 1em;
+}
+td.dangerous {
+    text-align: right
+}
+td.dangerous > form > input[type=submit], td.dangerous > button {
+    background-color: black;
+    color: red;
+}
+{% endblock %}
+
+
+{% block body %}
+</div>
+
+<table id="file_table">
+
+<tr>
+<th>path:</th>
+<td class="top_field">{{file.rel_path}}</td>
+</tr>
+
+<tr>
+<th>present:</th>
+<td id="presence">
+</td>
+</tr>
+
+<tr>
+<th>YouTube&nbsp;ID:</th>
+<td><a href="/yt_result/{{file.yt_id}}">{{file.yt_id}}</a>
+</tr>
+
+<tr>
+<th>duration</th>
+<td>{{file.duration()}}</td>
+</tr>
+
+<tr>
+<th>tags</th>
+<td>
+<table id="tags_table">
+{% if allow_edit %}
+<tr>
+<td class="tag_checkboxes"><button id="clickable:add_tag_button">add:</button></td>
+<td>
+<input id="added_tag" list="unused_tags" autocomplete="off" />
+<datalist id="unused_tags" />
+</datalist>
+</td>
+</tr>
+{% endif %}
+</table>
+</td>
+</tr>
+
+{% if allow_edit %}
+<tr>
+<th>options</th>
+<td>
+<table>
+<tr>
+<td>
+<input id="clickable:sync_checkbox" type="checkbox"/> do sync<br />
+</td>
+<td class="dangerous">
+<button id="clickable:unlink_button"/>delete locally</button>
+<form action="/file_kill/{{file.digest.b64}}" method="POST" />
+<input type="submit" value="KILL" />
+</form>
+</td>
+</tr>
+</table>
+</td>
+</tr>
+{% endif %}
+
+</table>
+{% endblock %}
diff --git a/src/templates/file.js b/src/templates/file.js
new file mode 100644 (file)
index 0000000..75e1773
--- /dev/null
@@ -0,0 +1,215 @@
+/* global
+document,
+window
+*/
+/*
+eslint
+"function-paren-newline": "off",
+"no-extra-parens": "off",
+"max-lines-per-function": [
+    "error",
+    73
+],
+"max-statements": [
+    "error",
+    16
+],
+"newline-after-var": "off",
+"padded-blocks": [
+    "error",
+    "never"
+],
+*/
+import {
+    CMD_ADD_NEXT,
+    CMD_ADD_PLAY,
+    IDX_PATH_ID,
+    LABEL_ADD_NEXT,
+    LABEL_ADD_PLAY,
+    LEN_EMPTY,
+    PATH_FILES,
+    PATH_PREFIX_FILE,
+    PATH_PREFIX_FILE_JSON,
+    PREFIX_CLICKABLE,
+    addATo,
+    addChildTo,
+    addPlayerBtnTo,
+    addTdTo,
+    addTextTo,
+    assignOnclicks,
+    drawTable,
+    subscribeEvents,
+    wrappedFetch,
+    wrappedPost
+} from "./_base.js";
+
+const
+    CLS_TAG_CHECKBOX = "tag_checkboxes",
+    CLS_TAG_LISTED = "listed_tags",
+    EDIT_BUTTONS = [
+        "add_tag_button",
+        "sync_checkbox",
+        "unlink_button"
+    ],
+    FLAG_NO_SYNC = "do not sync",
+    IDX_TAG_CHECKBOX_TD = 0,
+    IDX_TAG_CHECKBOX_TD_INPUT = 0,
+    IDX_TAG_LINK_TD = 1,
+    IDX_TAG_LINK_TD_A = 0,
+    ID_BTN_ADD_TAG = `${PREFIX_CLICKABLE}:add_tag_button`,
+    ID_BTN_SYNC = `${PREFIX_CLICKABLE}:sync_checkbox`,
+    ID_BTN_UNLINK = `${PREFIX_CLICKABLE}:unlink_button`,
+    ID_PRESENCE = "presence",
+    ID_TAGS_TABLE = "tags_table",
+    ID_TAGS_UNUSED = "unused_tags",
+    ID_TAG_ADDED = "added_tag",
+    LABEL_ABSENT = "no",
+    LABEL_PRESENT = "yes",
+    PARAM_TAG_NEEDED = "needed_tag",
+    PATH_FILE_JSON = (
+        PATH_PREFIX_FILE_JSON +
+        window.location.pathname.split("/")[IDX_PATH_ID]
+    );
+
+let
+    allowEdit = null;
+
+const updateFileData = async () => { // eslint-disable-line one-var
+    const
+        fetched = await wrappedFetch(PATH_FILE_JSON),
+        fileData = await fetched.json(),
+        tdPresent = document.getElementById(ID_PRESENCE);
+    drawTable(
+        ID_TAGS_TABLE,
+        CLS_TAG_LISTED,
+        fileData.tags_showable,
+        (
+            tr,
+            tag
+        ) => {
+            if (allowEdit) {
+                const tdCheckbox = addTdTo(tr);
+                tdCheckbox.classList.add(CLS_TAG_CHECKBOX);
+                addChildTo(
+                    "input",
+                    tdCheckbox,
+                    {
+                        "checked": true,
+                        /* eslint-disable-next-line no-use-before-define */
+                        "onclick": sendUpdate,
+                        "type": "checkbox"
+                    }
+                );
+            }
+            addATo(
+                addTdTo(tr),
+                tag,
+                `${PATH_FILES}?${PARAM_TAG_NEEDED}=${encodeURIComponent(tag)}`
+            );
+        }
+    );
+    if (allowEdit) {
+        const dataList = document.getElementById(ID_TAGS_UNUSED);
+        dataList.innerHTML = "";
+        document.getElementById(ID_BTN_SYNC).checked =
+            !fileData.flags.includes(FLAG_NO_SYNC);
+        document.getElementById(ID_TAG_ADDED).value = "";
+        fileData.unused_tags.forEach(
+            (tag) => addChildTo(
+                "option",
+                dataList,
+                {"textContent": tag}
+            )
+        );
+    }
+    tdPresent.innerHTML = "";
+    if (fileData.present) {
+        addATo(
+            tdPresent,
+            LABEL_PRESENT,
+            `${PATH_PREFIX_FILE}${fileData.yt_id}`
+        );
+        addTextTo(
+            tdPresent,
+            " "
+        );
+        addPlayerBtnTo(
+            tdPresent,
+            LABEL_ADD_NEXT,
+            `${CMD_ADD_NEXT}_${fileData.digest}`
+        );
+        addPlayerBtnTo(
+            tdPresent,
+            LABEL_ADD_PLAY,
+            `${CMD_ADD_PLAY}_${fileData.digest}`
+        );
+    } else {
+        tdPresent.textContent = LABEL_ABSENT;
+    }
+};
+
+const sendUpdate = async ( // eslint-disable-line one-var
+    button
+) => {
+    const
+        addedTag = document.getElementById(ID_TAG_ADDED).value,
+        flags = [],
+        tags = [];
+    Array.from(document.getElementsByClassName(CLS_TAG_LISTED)).forEach(
+        (tr) => {
+            if (
+                tr.children[
+                    IDX_TAG_CHECKBOX_TD
+                ].children[
+                    IDX_TAG_CHECKBOX_TD_INPUT
+                ].checked
+            ) {
+                tags.push(
+                    tr.children[
+                        IDX_TAG_LINK_TD
+                    ].children[
+                        IDX_TAG_LINK_TD_A
+                    ].textContent
+                );
+            }
+        }
+    );
+    if (
+        addedTag.length > LEN_EMPTY &&
+        button === document.getElementById(ID_BTN_ADD_TAG)
+    ) {
+        tags.push(addedTag);
+    }
+    if (!document.getElementById(ID_BTN_SYNC).checked) {
+        flags.push(FLAG_NO_SYNC);
+    }
+    await wrappedPost(
+        PATH_FILE_JSON,
+        {
+            "delete_locally": (
+                button ===
+                document.getElementById(ID_BTN_UNLINK)
+            ),
+            flags,
+            tags
+        }
+    );
+    updateFileData();
+};
+
+window.addEventListener(
+    "load",
+    () => {
+        allowEdit = document.getElementById(ID_TAG_ADDED) !== null;
+        updateFileData();
+        if (allowEdit) {
+            assignOnclicks(
+                EDIT_BUTTONS,
+                (btn) => sendUpdate(btn)
+            );
+        }
+    }
+);
+
+subscribeEvents();
+
diff --git a/src/templates/file_data.tmpl b/src/templates/file_data.tmpl
deleted file mode 100644 (file)
index e1247a3..0000000
+++ /dev/null
@@ -1,141 +0,0 @@
-{% extends '_base.tmpl' %}
-
-
-{% block script %}
-const PATH_FILE_JSON = `/{{page_names.file_json}}/` + "{{file.digest.b64}}";
-
-async function update_file_data() {
-    const file_data = await wrapped_fetch(PATH_FILE_JSON).then((response) => response.json());
-    Array.from(document.getElementsByClassName("listed_tags")).forEach((row) => row.remove());
-    file_data.tags_showable.forEach((tag) => {
-        const tr = add_child_to("tr", document.getElementById("tags_table"));
-        tr.classList.add("listed_tags");
-        {% if allow_edit %}
-        td_checkbox = add_child_to("td", tr);
-        td_checkbox.classList.add("tag_checkboxes");
-        add_child_to("input", td_checkbox, {type: "checkbox", checked: true, onclick: send_update});
-        {% endif %}
-        add_a_to(add_child_to("td", add_child_to("td", tr)), tag,
-                 `${PATH_FILES}?needed_tag=${encodeURIComponent(tag)}`);
-    });
-    {% if allow_edit %}
-    document.getElementById("sync_checkbox").checked = ! file_data.flags.includes("do not sync");
-    document.getElementById("added_tag").value = "";
-    const datalist = document.getElementById("unused_tags");
-    datalist.innerHTML = "";
-    file_data.unused_tags.forEach((tag) => { add_child_to("option", datalist, {textContent: tag}); });
-    {% endif %}
-    td_present = document.getElementById("presence");
-    td_present.innerHTML = "";
-    if (file_data.present) {
-        add_a_to(td_present, "yes", "/{{page_names.download}}/{{file.yt_id}}");
-        add_text_to(td_present, " ");
-        add_player_btn_to(td_present, 'add as next', `inject_${file_data.digest}`);
-        add_player_btn_to(td_present, 'add and play', `injectplay_${file_data.digest}`);
-    } else {
-        td_present.textContent = "no";
-    }
-}
-
-async function send_update(button) {
-    let tags = []
-    Array.from(document.getElementsByClassName("listed_tags")).forEach((tr) => {
-        if (tr.children[0].children[0].checked) {
-            tags.push(tr.children[1].children[0].textContent);
-        }
-    });
-    const added_tag = document.getElementById("added_tag").value;
-    if (added_tag.length > 0 && button == document.getElementById("add_tag_button")) {
-        tags.push(added_tag);
-    }
-    let flags = []
-    if (! document.getElementById("sync_checkbox").checked) {
-        flags.push("do not sync");
-    }
-    await wrapped_post(PATH_FILE_JSON, {
-        flags: flags,
-        tags: tags,
-        delete_locally: button == document.getElementById("unlink_button")
-    });
-    update_file_data();
-}
-
-window.addEventListener('load', update_file_data);
-{% endblock %}
-
-
-{% block css %}
-td.top_field { width: 100%; }
-td.tag_checkboxes { width: 1em; }
-td.dangerous { text-align: right}
-td.dangerous > form > input[type=submit], td.dangerous > button { background-color: black; color: red; }
-{% endblock %}
-
-
-{% block body %}
-</div>
-
-<table>
-
-<tr>
-<th>path:</th>
-<td class="top_field">{{file.rel_path}}</td>
-</tr>
-
-<tr>
-<th>present:</th>
-<td id="presence">
-</td>
-</tr>
-
-<tr>
-<th>YouTube&nbsp;ID:</th>
-<td><a href="/{{page_names.yt_result}}/{{file.yt_id}}">{{file.yt_id}}</a>
-</tr>
-
-<tr>
-<th>duration</th>
-<td>{{file.duration()}}</td>
-</tr>
-
-<tr>
-<th>tags</th>
-<td>
-<table id="tags_table">
-{% if allow_edit %}
-<tr>
-<td class="tag_checkboxes"><button id="add_tag_button" onclick="send_update(this)" >add:</button></td>
-<td>
-<input id="added_tag" list="unused_tags" autocomplete="off" />
-<datalist id="unused_tags" />
-</datalist>
-</td>
-</tr>
-{% endif %}
-</table>
-</td>
-</tr>
-
-{% if allow_edit %}
-<tr>
-<th>options</th>
-<td>
-<table>
-<tr>
-<td>
-<input id="sync_checkbox" type="checkbox" onclick="send_update()" /> do sync<br />
-</td>
-<td class="dangerous">
-<button id="unlink_button" onclick="send_update(this)" />delete locally</button>
-<form action="/{{page_names.file_kill}}/{{file.digest.b64}}" method="POST" />
-<input type="submit" value="KILL" />
-</form>
-</td>
-</tr>
-</table>
-</td>
-</tr>
-{% endif %}
-
-</table>
-{% endblock %}
diff --git a/src/templates/files.html b/src/templates/files.html
new file mode 100644 (file)
index 0000000..183301a
--- /dev/null
@@ -0,0 +1,33 @@
+{% extends '_base.tmpl' %}
+{% import '_macros.tmpl' as macros %}
+
+
+{% block css %}
+{{ macros.css_sortable_table() }}
+{% endblock %}
+
+
+{% block body %}
+filename pattern: <input id="input_filter_path" /><br />
+{% if allow_show_absent %}
+show absent: <input id="input_show_absent" type="checkbox"/><br />
+{% endif %}
+needed tags: <select id="tags_select"></select><br />
+<span id="tags"></span>
+<hr />
+</div>
+<p>
+known files (shown: <span id="files_count">?</span>):
+<button id="inject_all">add all as next</button>
+</p>
+<table id="sortable_table">
+<tr>
+<th><button id="clickable:size" class="sorter">size</button></th>
+<th><button id="clickable:duration" class="sorter">duration</button></th>
+<th>actions</th>
+<th>tags <button id="clickable:n_tags" class="sorter">count</button></th>
+<th><button id="clickable:rel_path" class="sorter">path</button></th>
+<th><button id="clickable:-last_updates" class="sorter">last update</button></th>
+</tr>
+</table>
+{% endblock %}
diff --git a/src/templates/files.js b/src/templates/files.js
new file mode 100644 (file)
index 0000000..7629481
--- /dev/null
@@ -0,0 +1,215 @@
+/* global
+URLSearchParams,
+console,
+document,
+window,
+*/
+/*
+eslint
+"function-paren-newline": "off",
+"newline-after-var": "off",
+no-console: [
+    "error",
+    { "allow": ["dir", "log", "warn", "error"] }
+],
+"max-statements": [
+    "error",
+    13
+],
+"padded-blocks": [
+    "error",
+    "never"
+],
+*/
+import {
+    CMD_ADD_NEXT,
+    CMD_ADD_PLAY,
+    IDX_START,
+    LABEL_ADD_NEXT,
+    LABEL_ADD_PLAY,
+    LEN_EMPTY,
+    PARAM_TAG_NEEDED,
+    PATH_FILES_JSON,
+    PATH_PREFIX_FILE,
+    PREFIX_MINUS,
+    SYMBOL_RM,
+    addATdTo,
+    addButtonTo,
+    addChildTo,
+    addPlayerBtnTo,
+    addTagLinksTo,
+    addTdTo,
+    addTextTo,
+    drawSortableTable,
+    playerCommand,
+    sortableTableSetup,
+    subscribeEvents,
+    wrappedFetch
+} from "./_base.js";
+
+const
+    COLNAME_DURATION = "duration",
+    COLNAME_LAST_UPDATES = "last_updates",
+    COLNAME_N_TAGS = "n_tags",
+    COLNAME_REL_PATH = "rel_path",
+    COLNAME_SIZE = "size",
+    ID_ADD_ALL = "inject_all",
+    ID_FILES_COUNT = "files_count",
+    ID_INPUT_FILTER_PATH = "input_filter_path",
+    ID_INPUT_SHOW_ABSENT = "input_show_absent",
+    ID_TAGS = "tags",
+    ID_TAGS_SELECT = "tags_select",
+    LABEL_ADD = "add",
+    PARAM_FILTER_PATH = "filter_path",
+    PARAM_SHOW_ABSENT = "show_absent",
+    TAGS_SHOWABLE = {{showable_tags|tojson|safe}};
+
+let
+    inputFilterPath = null,
+    inputShowAbsent = null,
+    neededTags = new URLSearchParams(
+        window.location.search
+    ).getAll(PARAM_TAG_NEEDED),
+    tagsSelect = null,
+    tagsSpan = null;
+sortableTableSetup.itemsForSortableTable = [];
+sortableTableSetup.sortCols = [
+    COLNAME_SIZE,
+    COLNAME_DURATION,
+    COLNAME_N_TAGS,
+    COLNAME_REL_PATH,
+    `${PREFIX_MINUS}${COLNAME_LAST_UPDATES}`
+];
+sortableTableSetup.sortKey = COLNAME_REL_PATH;
+sortableTableSetup.populateListItemRow = (
+    tr,
+    file
+) => {
+    addTdTo(
+        tr,
+        {"textContent": file.size}
+    );
+    addTdTo(
+        tr,
+        {"textContent": file.duration}
+    );
+    const tdInject = addTdTo(tr);
+    addPlayerBtnTo(
+        tdInject,
+        LABEL_ADD_NEXT,
+        `${CMD_ADD_NEXT}_${file.digest}`,
+        !file.present
+    );
+    addPlayerBtnTo(
+        tdInject,
+        LABEL_ADD_PLAY,
+        `${CMD_ADD_PLAY}_${file.digest}`,
+        !file.present
+    );
+    addTagLinksTo(
+        addTdTo(tr),
+        file.tags_showable
+    );
+    addATdTo(
+        tr,
+        file.rel_path,
+        `${PATH_PREFIX_FILE}${file.digest}`
+    );
+    addTdTo(
+        tr,
+        {"textContent": file.last_update}
+    );
+};
+
+const updateFilesList = async ( // eslint-disable-line one-var
+) => {
+    const filterPath = encodeURIComponent(inputFilterPath.value);
+    let target = `${PATH_FILES_JSON}?${PARAM_FILTER_PATH}=${filterPath}`;
+    if (inputShowAbsent && inputShowAbsent.checked) {
+        target = `${target}&${PARAM_SHOW_ABSENT}=1`;
+    }
+    neededTags.forEach((tag) => {
+        target = `${target}&${PARAM_TAG_NEEDED}=${encodeURIComponent(tag)}`;
+    });
+    try {
+        const fetched = await wrappedFetch(target);
+        sortableTableSetup.itemsForSortableTable = await fetched.json();
+        document.getElementById(ID_FILES_COUNT).textContent =
+            `${sortableTableSetup.itemsForSortableTable.length}`;
+        drawSortableTable();
+    } catch (error) {
+        console.log(`Error updating ${target} files list: ${error.message}`);
+        console.dir(error);
+        throw error;
+    }
+};
+
+
+const updateFilterInputs = ( // eslint-disable-line one-var
+) => {
+    tagsSpan.innerHTML = "";
+    while (tagsSelect.options.length > LEN_EMPTY) {
+        tagsSelect.remove(IDX_START);
+    }
+    addChildTo(
+        "option",
+        tagsSelect,
+        {"textContent": LABEL_ADD}
+    );
+    TAGS_SHOWABLE.forEach((tag) => {
+        if (!neededTags.includes(tag)) {
+            addChildTo(
+                "option",
+                tagsSelect,
+                {"textContent": tag}
+            );
+        }
+    });
+    neededTags.forEach((chosenTag) => {
+        addTextTo(
+            tagsSpan,
+            ` ${chosenTag} `
+        );
+        addButtonTo(
+            tagsSpan,
+            SYMBOL_RM,
+            false,
+            () => {
+                neededTags = neededTags.filter((tag) => tag !== chosenTag);
+                updateFilterInputs();
+            }
+        );
+    });
+    updateFilesList();
+};
+
+window.addEventListener(
+    "load",
+    () => {
+        inputFilterPath = document.getElementById(ID_INPUT_FILTER_PATH);
+        inputFilterPath.oninput = updateFilesList;
+        tagsSelect = document.getElementById(ID_TAGS_SELECT);
+        tagsSpan = document.getElementById(ID_TAGS);
+        updateFilterInputs();
+        inputShowAbsent = document.getElementById(ID_INPUT_SHOW_ABSENT);
+        if (inputShowAbsent) {
+            inputShowAbsent.onclick = updateFilesList;
+        }
+        document.getElementById(ID_ADD_ALL).onclick = () => {
+            sortableTableSetup.itemsForSortableTable.forEach(
+                (file) => playerCommand(`${CMD_ADD_NEXT}_${file.digest}`)
+            );
+        };
+        tagsSelect.onchange = () => {
+            if (tagsSelect.selectedIndex > IDX_START) {
+                neededTags.push(
+                    document.getElementById(ID_TAGS_SELECT).value
+                );
+            }
+            updateFilterInputs();
+        };
+    }
+);
+
+subscribeEvents();
+
diff --git a/src/templates/files.tmpl b/src/templates/files.tmpl
deleted file mode 100644 (file)
index 6091cbd..0000000
+++ /dev/null
@@ -1,108 +0,0 @@
-{% extends '_base.tmpl' %}
-
-
-{% block script %}
-const PATH_FILES_JSON = "/{{page_names.files_json}}";
-
-const all_tags = {{showable_tags|tojson|safe}};
-var needed_tags = {{needed_tags|tojson|safe}};
-
-function select_tag() {
-    if (tags_select.selectedIndex < 1) {
-        return;
-    }
-    const chosen_tag = document.getElementById('tags_select').value;
-    needed_tags.push(chosen_tag);
-    update_filter_inputs();
-}
-
-function update_filter_inputs() {
-    const tags_select = document.getElementById('tags_select');
-    while (tags_select.options.length > 0) {
-        tags_select.remove(0);
-    }
-    add_child_to('option', tags_select,  {textContent: 'add'});
-    all_tags.forEach((tag) => {
-        if (needed_tags.includes(tag)) {
-            return;
-        }
-        add_child_to('option', tags_select, {textContent: tag});
-    });
-    const tags_div = document.getElementById("tags");
-    tags_div.innerHTML = '';
-    needed_tags.forEach((chosen_tag) => {
-        const tag_text_node = add_text_to(tags_div, ` ${chosen_tag} `);
-        add_child_to('button', tags_div, {
-            textContent: 'x',
-            onclick: function() {
-                tag_text_node.remove();
-                btn_del.remove();
-                needed_tags = needed_tags.filter(tag => tag !== chosen_tag);
-                update_filter_inputs();
-            }
-        });
-    });
-    update_files_list();
-}
-
-async function update_files_list() {
-    const filter_path = encodeURIComponent(document.getElementById("input_filter_path").value);
-    let target = `${PATH_FILES_JSON}?filter_path=${filter_path}`;
-    {% if allow_show_absent %}
-    if (document.getElementById("input_show_absent").checked) { target = `${target}&show_absent=1`; }
-    {% endif %}
-    needed_tags.forEach((tag) => target = `${target}&needed_tag=${encodeURIComponent(tag)}`);
-    items_for_sortable_table = await wrapped_fetch(target).then((response) => response.json());
-    document.getElementById("files_count").textContent = `${items_for_sortable_table.length}`;
-    draw_sortable_table();
-}
-
-function inject_all() {
-    items_for_sortable_table.forEach((file) => { player_command(`inject_${file.digest}`) });
-}
-
-{{ macros.js_manage_sortable_table("rel_path") }}
-var items_for_sortable_table = [];
-function populate_list_item_row(tr, file) {
-    add_child_to('td', tr, {textContent: file.size});
-    add_child_to('td', tr, {textContent: file.duration});
-    const td_inject = add_child_to('td', tr);
-    add_player_btn_to(td_inject, 'add as next', `inject_${file.digest}`, !file.present)
-    add_player_btn_to(td_inject, 'add as play', `injectplay_${file.digest}`, !file.present)
-    add_tag_links_to(add_child_to('td', tr), file.tags_showable);
-    add_a_to(add_child_to('td', tr), file.rel_path, `${PATH_PREFIX_FILE}${file.digest}`);
-    add_child_to('td', tr, {textContent: file.last_update});
-}
-window.addEventListener('load', update_filter_inputs);
-{% endblock %}
-
-
-{% block css %}
-{{ macros.css_sortable_table() }}
-{% endblock %}
-
-
-{% block body %}
-filename pattern: <input id="input_filter_path" oninput="update_files_list()"  /><br />
-{% if allow_show_absent %}
-show absent: <input id="input_show_absent" type="checkbox" onclick="update_files_list()" /><br />
-{% endif %}
-needed tags: <select id="tags_select" onchange="select_tag()"></select><br />
-<span id="tags"></span>
-<hr />
-</div>
-<p>
-known files (shown: <span id="files_count">?</span>):
-<button onclick="inject_all();">add all as next</button>
-</p>
-<table id="sortable_table">
-<tr>
-<th><button class="sorter" onclick="sort_by(this, 'size'); ">size</button></th>
-<th><button class="sorter" onclick="sort_by(this, 'duration'); ">duration</button></th>
-<th>actions</th>
-<th>tags <button class="sorter" onclick="sort_by(this, 'n_tags'); ">count</button></th>
-<th><button class="sorter" onclick="sort_by(this, 'rel_path'); ">path</button></th>
-<th><button class="sorter" onclick="sort_by(this, '-last_update'); ">last update</button></th>
-</tr>
-</table>
-{% endblock %}
diff --git a/src/templates/playlist.html b/src/templates/playlist.html
new file mode 100644 (file)
index 0000000..3f55331
--- /dev/null
@@ -0,0 +1,21 @@
+{% extends '_base.tmpl' %}
+
+
+{% block css %}
+html {
+    scroll-padding-top: 7.5em; /* so anchor jumps pad off sticky header */
+}
+td.entry_control {
+    width: 6em;
+}
+{% endblock %}
+
+
+{% block body %}
+<a href="#playing">jump to playing</a>
+<button id="clickable:rebuild">rebuild</button>
+<button id="clickable:empty">empty</button>
+<hr />
+<table id="playlist_rows">
+</table>
+{% endblock %}
diff --git a/src/templates/playlist.js b/src/templates/playlist.js
new file mode 100644 (file)
index 0000000..890e0d4
--- /dev/null
@@ -0,0 +1,140 @@
+/* global
+window
+*/
+/*
+eslint
+"capitalized-comments": [
+    "error",
+    "never"
+],
+"function-paren-newline": "off",
+"line-comment-position": [
+    "error",
+    { "position": "beside" }
+],
+"max-lines-per-function": [
+    "error",
+    51
+],
+"max-statements": [
+    "error",
+    12
+],
+"newline-after-var": "off",
+"no-inline-comments": "off",
+"no-multi-spaces": [
+    "error",
+    { "ignoreEOLComments": true }
+],
+"padded-blocks": [
+    "error",
+    "never"
+],
+ */
+import {
+    BUTTONS_UP_DOWN,
+    CMD_RM,
+    PATH_PREFIX_FILE,
+    SYMBOL_RM,
+    addATdTo,
+    addATo,
+    addPlayerBtnTo,
+    addTdTo,
+    assignOnclicks,
+    drawTable,
+    eventHandlers,
+    playerCommand,
+    subscribeEvents
+} from "./_base.js";
+
+const
+    BUTTONS_ENTRY = [
+        [
+            ">",
+            "jump"
+        ]
+    ].concat(BUTTONS_UP_DOWN).concat([
+        [
+            SYMBOL_RM,
+            CMD_RM
+        ]
+    ]),
+    BUTTONS_PLAYLIST = [
+        "empty",
+        "rebuild"
+    ],
+    CLS_ENTRY_CTL = "entry_control",
+    CLS_PLAYLIST_ROW = "playlist_row",
+    ID_TABLE = `${CLS_PLAYLIST_ROW}s`,
+    PARAM_PLAYLIST = "playlist",
+    TOK_ANCHOR = "#",
+    TOK_PLAYING = "playing";
+
+let
+    firstLoad = true;
+
+eventHandlers.playlist = (
+    update
+) => { // update playlist
+    drawTable(
+        ID_TABLE,
+        CLS_PLAYLIST_ROW,
+        update.playlist_files,
+        (
+            tr,
+            file,
+            idx
+        ) => {
+            addATo(
+                addTdTo(
+                    tr,
+                    {"id": `${idx}`}
+                ),
+                TOK_ANCHOR,
+                `#${idx}`
+            );
+            const tdEntryControl = addTdTo(tr);
+            tdEntryControl.classList.add(CLS_ENTRY_CTL);
+            addATdTo(
+                tr,
+                file.rel_path,
+                `${PATH_PREFIX_FILE}${file.digest}`
+            );
+            if (idx === update.idx) { // currently playing
+                tdEntryControl.textContent = TOK_PLAYING;
+                tdEntryControl.id = TOK_PLAYING;
+                if (firstLoad) {        // replace anchor jump to #playing
+                    firstLoad = false;  // (initially un-built, won"t work)
+                    tdEntryControl.scrollIntoView({"block": "center"});
+                }
+            } else { // only non-playing items get playlist manip. buttons
+                for (
+                    const [
+                        symbol,
+                        prefix
+                    ] of BUTTONS_ENTRY
+                ) {
+                    addPlayerBtnTo(
+                        tdEntryControl,
+                        symbol,
+                        `${prefix}_${idx}`
+                    );
+                }
+            }
+        }
+    );
+};
+
+window.addEventListener(
+    "load",
+    () => assignOnclicks(
+        BUTTONS_PLAYLIST,
+        (
+            ignore,
+            command
+        ) => playerCommand(command)
+    )
+);
+
+subscribeEvents(`${PARAM_PLAYLIST}=1`);
+
diff --git a/src/templates/playlist.tmpl b/src/templates/playlist.tmpl
deleted file mode 100644 (file)
index f956ea2..0000000
+++ /dev/null
@@ -1,55 +0,0 @@
-{% extends '_base.tmpl' %}
-
-
-{% block css %}
-html { scroll-padding-top: 7.5em; } /* so anchor jumps pad off sticky header */
-td.entry_control { width: 6em; }
-{% endblock %}
-
-
-{% block script %}
-const CLS_PLAYLIST_ROW = 'playlist_row';
-events_params += 'playlist=1';
-var first_load = true;
-
-event_handlers.playlist = function(data) {  // update playlist
-    const table = document.getElementById('playlist_rows');
-    var old_rows = document.getElementsByClassName(CLS_PLAYLIST_ROW);
-    while (old_rows[0]) { old_rows[0].remove(); }
-    for (let i = 0; i < data.playlist_files.length; i++) {
-        const file = data.playlist_files[i];
-        const tr = add_child_to('tr', table);
-        tr.classList.add(CLS_PLAYLIST_ROW);
-        add_a_to(add_child_to('td', tr, {id: `${i}`}), '#', `#${i}`);
-        const td_entry_control = add_child_to('td', tr);
-        td_entry_control.classList.add('entry_control');
-        if (i == data.idx) {
-            td_entry_control.textContent = 'playing';
-            td_entry_control.id = 'playing';
-            if (first_load) {       // to replace anchor jump to #playing, which on first
-                first_load = false; // load will not work yet since element not built yet
-                td_entry_control.scrollIntoView({block: 'center'});
-            }
-        } else {
-            for (const [symbol, prefix] of [['>', 'jump'],
-                                            ['^', 'up'],
-                                            ['v', 'down'],
-                                            ['x', 'rm']]) {
-                add_player_btn_to(td_entry_control, symbol, `${prefix}_${i}`);
-            }
-        }
-        add_a_to(add_child_to('td', tr), file.rel_path, `${PATH_PREFIX_FILE}${file.digest}`);
-    }
-};
-{% endblock %}
-
-
-{% block body %}
-<a href="#playing">jump to playing</a>
-<button onclick="player_command('rebuild')">rebuild</button>
-<button onclick="player_command('empty')">empty</button>
-<hr />
-</div>
-<table id="playlist_rows">
-</table>
-{% endblock %}
diff --git a/src/templates/tags.html b/src/templates/tags.html
new file mode 100644 (file)
index 0000000..94d581e
--- /dev/null
@@ -0,0 +1,25 @@
+{% extends '_base.tmpl' %}
+{% import '_macros.tmpl' as macros %}
+
+
+{% block css %}
+{{ macros.css_sortable_table() }}
+#sortable_table { width: auto; }
+{% endblock %}
+
+
+{% block body %}
+</div>
+
+<table id="sortable_table">
+<tr>
+<th>
+<button id="clickable:name"   class="sorter">name</button>
+</th>
+<th>
+<button id="clickable:number" class="sorter">usage number</button>
+</th>
+</tr>
+</table>
+
+{% endblock %}
diff --git a/src/templates/tags.js b/src/templates/tags.js
new file mode 100644 (file)
index 0000000..b230ede
--- /dev/null
@@ -0,0 +1,47 @@
+/*
+eslint
+"capitalized-comments": [
+    "error",
+    "never"
+],
+"padded-blocks": [
+    "error",
+    "never"
+],
+ */
+import {
+    PARAM_TAG_NEEDED,
+    PATH_FILES,
+    addATdTo,
+    addTdTo,
+    sortableTableSetup,
+    subscribeEvents
+} from "./_base.js";
+
+const
+    COLNAME_NAME = "name",
+    COLNAME_NUMBER = "number";
+
+sortableTableSetup.itemsForSortableTable = {{tags|tojson|safe}};
+sortableTableSetup.sortCols = [
+    COLNAME_NAME,
+    COLNAME_NUMBER
+];
+sortableTableSetup.sortKey = COLNAME_NAME;
+sortableTableSetup.populateListItemRow = (
+    tr,
+    tag
+) => {
+    addATdTo(
+        tr,
+        tag.name,
+        `${PATH_FILES}?${PARAM_TAG_NEEDED}=${encodeURIComponent(tag.name)}`
+    );
+    addTdTo(
+        tr,
+        {"textContent": tag.number}
+    );
+};
+
+subscribeEvents();
+
diff --git a/src/templates/tags.tmpl b/src/templates/tags.tmpl
deleted file mode 100644 (file)
index aae3eab..0000000
+++ /dev/null
@@ -1,32 +0,0 @@
-{% extends '_base.tmpl' %}
-
-
-{% block script %}
-{{ macros.js_manage_sortable_table("name") }}
-var items_for_sortable_table = {{tags|tojson|safe}};
-function populate_list_item_row(tr, tag) {
-    add_a_to(add_child_to('td', tr), tag.name,
-             `${PATH_FILES}?needed_tag=${encodeURIComponent(tag.name)}`);
-    add_child_to('td', tr, {textContent: tag.number});
-}
-window.addEventListener('load', draw_sortable_table);
-{% endblock %}
-
-
-{% block css %}
-{{ macros.css_sortable_table() }}
-#sortable_table { width: auto; }
-{% endblock %}
-
-
-{% block body %}
-</div>
-
-<table id="sortable_table">
-<tr>
-<th><button class="sorter" onclick="sort_by(this, 'name'); ">name</button></th>
-<th><button class="sorter" onclick="sort_by(this, 'number'); ">usage number</button></th>
-</tr>
-</table>
-{% endblock %}
-
diff --git a/src/templates/yt_queries.html b/src/templates/yt_queries.html
new file mode 100644 (file)
index 0000000..bdb7140
--- /dev/null
@@ -0,0 +1,22 @@
+{% extends '_base.tmpl' %}
+
+
+{% block body %}
+<p>quota: {{quota_count}}/100000</p>
+<form action="/yt_queries" method="POST" />
+<input name="query" />
+</form>
+</div>
+<table>
+<tr>
+<th>retrieved at</th>
+<th>query</th>
+</tr>
+{% for query in queries %}
+<tr>
+<td>{{query.retrieved_at[:19]}}</td>
+<td><a href="/yt_query/{{query.id_}}">{{query.text}}</a></td>
+</tr>
+{% endfor %}
+</table>
+{% endblock %}
diff --git a/src/templates/yt_queries.js b/src/templates/yt_queries.js
new file mode 100644 (file)
index 0000000..6a2a2ab
--- /dev/null
@@ -0,0 +1,6 @@
+import {
+    subscribeEvents
+} from "./_base.js";
+
+subscribeEvents();
+
diff --git a/src/templates/yt_queries.tmpl b/src/templates/yt_queries.tmpl
deleted file mode 100644 (file)
index 1bbc0af..0000000
+++ /dev/null
@@ -1,23 +0,0 @@
-{% extends '_base.tmpl' %}
-
-
-{% block body %}
-<p>quota: {{quota_count}}/100000</p>
-<form action="/{{page_names.yt_queries}}" method="POST" />
-<input name="query" />
-</form>
-</div>
-
-<table>
-<tr>
-<th>retrieved at</th>
-<th>query</th>
-</tr>
-{% for query in queries %}
-<tr>
-<td>{{query.retrieved_at[:19]}}</td>
-<td><a href="/{{page_names.yt_query}}/{{query.id_}}">{{query.text}}</a></td>
-</tr>
-{% endfor %}
-</table>
-{% endblock %}
diff --git a/src/templates/yt_query.html b/src/templates/yt_query.html
new file mode 100644 (file)
index 0000000..4f2eea9
--- /dev/null
@@ -0,0 +1,24 @@
+{% extends '_base.tmpl' %}
+
+
+{% block body %}
+<p>query: {{query}}</p>
+<hr />
+</div>
+<table>
+{% for video in videos %}
+<tr>
+<td>
+<a href="/yt_result/{{video.id_}}"><img src="/thumbnails/{{video.id_}}.jpg" /></a>
+</td>
+<td>
+{{video.definition}}<br />
+{{video.duration}}
+</td>
+<td>
+<b><a href="/yt_result/{{video.id_}}">{{video.title}}</a></b> · {{video.description}}
+</td>
+</tr>
+{% endfor %}
+</table>
+{% endblock %}
diff --git a/src/templates/yt_query.js b/src/templates/yt_query.js
new file mode 100644 (file)
index 0000000..6a2a2ab
--- /dev/null
@@ -0,0 +1,6 @@
+import {
+    subscribeEvents
+} from "./_base.js";
+
+subscribeEvents();
+
diff --git a/src/templates/yt_result.html b/src/templates/yt_result.html
new file mode 100644 (file)
index 0000000..e0e7e5f
--- /dev/null
@@ -0,0 +1,22 @@
+{% extends '_base.tmpl' %}
+
+
+{% block body %}
+<table id="video_table" dat>
+<tr><th>title:</th><td>{{video_data.title}}</td></tr>
+<tr><th>thumbnail:</th><td><img src="/thumbnails/{{video_data.id_}}.jpg" /></td></tr>
+<tr><th>description:</th><td>{{video_data.description}}</td></tr>
+<tr><th>duration:</th><td>{{video_data.duration}}</td></tr>
+<tr><th>definition:</th><td>{{video_data.definition}}</td></tr>
+<tr><th>YouTube ID:</th><td>{% if youtube_prefix|length > 0 %}<a href="{{youtube_prefix}}{{video_data.id_}}">{{video_data.id_}}{% else %}{{video_data.id_}}{% endif %}</td></tr>
+<tr><th>download:</th><td id="status"></td></tr>
+<tr>
+<th>linked queries:</th>
+<td>
+<ul>
+{% for query in queries %}<li><a href="/yt_query/{{query.id_}}">{{query.text}}</a>{% endfor %}
+</ul>
+</td>
+</tr>
+</table>
+{% endblock %}
diff --git a/src/templates/yt_result.js b/src/templates/yt_result.js
new file mode 100644 (file)
index 0000000..981aafd
--- /dev/null
@@ -0,0 +1,58 @@
+/* global
+document,
+window
+*/
+/*
+eslint
+"function-paren-newline": "off",
+"newline-after-var": "off",
+"padded-blocks": [
+    "error",
+    "never"
+],
+*/
+import {
+    IDX_PATH_ID,
+    PATH_PREFIX_DOWNLOAD,
+    PATH_PREFIX_FILE,
+    addATo,
+    addTextTo,
+    eventHandlers,
+    subscribeEvents
+} from "./_base.js";
+
+const
+    ID_TD_STATUS = "status",
+    PARAM_DOWNLOAD = "download",
+    TOK_ABSENT = "absent",
+    TOK_PRESENT = "present",
+    TOK_Q_DOWNLOAD = "download?",
+    ytId = window.location.pathname.split("/")[IDX_PATH_ID];
+
+eventHandlers.download = (
+    data
+) => {
+    const td = document.getElementById(ID_TD_STATUS);
+    td.innerHTML = "";
+    if (data.status === TOK_ABSENT) {
+        addATo(
+            td,
+            TOK_Q_DOWNLOAD,
+            `${PATH_PREFIX_DOWNLOAD}${ytId}`
+        );
+    } else if (data.status === TOK_PRESENT) {
+        addATo(
+            td,
+            data.path,
+            `${PATH_PREFIX_FILE}${data.digest}`
+        );
+    } else {
+        addTextTo(
+            td,
+            `${data.status}`
+        );
+    }
+};
+
+subscribeEvents(`${PARAM_DOWNLOAD}=${ytId}`);
+
diff --git a/src/templates/yt_result.tmpl b/src/templates/yt_result.tmpl
deleted file mode 100644 (file)
index fe68a43..0000000
+++ /dev/null
@@ -1,45 +0,0 @@
-{% extends '_base.tmpl' %}
-
-
-{% block script %}
-events_params += 'download={{video_data.id_}}';
-event_handlers.download = function(data) {
-    const td = document.getElementById("status");
-    td.innerHTML = "";
-    if ("absent" == data.status) {
-        add_a_to(
-            parent=td,
-            text="download?",
-            href="/{{page_names.download}}/{{video_data.id_}}");
-    } else if ("present" == data.status) {
-        add_a_to(
-            parent=td,
-            text=data.path,
-            href="/{{page_names.file}}/" + data.digest);
-    } else {
-        add_text_to(
-            parent=td,
-            text=`${data.status}`); }
-}
-{% endblock %}
-
-
-{% block body %}
-<table>
-<tr><th>title:</th><td>{{video_data.title}}</td></tr>
-<tr><th>thumbnail:</th><td><img src="/{{page_names.thumbnails}}/{{video_data.id_}}.jpg" /></td></tr>
-<tr><th>description:</th><td>{{video_data.description}}</td></tr>
-<tr><th>duration:</th><td>{{video_data.duration}}</td></tr>
-<tr><th>definition:</th><td>{{video_data.definition}}</td></tr>
-<tr><th>YouTube ID:</th><td>{% if youtube_prefix|length > 0 %}<a href="{{youtube_prefix}}{{video_data.id_}}">{{video_data.id_}}{% else %}{{video_data.id_}}{% endif %}</td></tr>
-<tr><th>download:</th><td id="status"></td></tr>
-<tr>
-<th>linked queries:</th>
-<td>
-<ul>
-{% for query in queries %}<li><a href="/{{page_names.yt_query}}/{{query.id_}}">{{query.text}}</a>{% endfor %}
-</ul>
-</td>
-</tr>
-</table>
-{% endblock %}
diff --git a/src/templates/yt_results.tmpl b/src/templates/yt_results.tmpl
deleted file mode 100644 (file)
index 2115c2b..0000000
+++ /dev/null
@@ -1,24 +0,0 @@
-{% extends '_base.tmpl' %}
-
-
-{% block body %}
-<p>query: {{query}}</p>
-<hr />
-</div>
-<table>
-{% for video in videos %}
-<tr>
-<td>
-<a href="/{{page_names.yt_result}}/{{video.id_}}"><img src="/{{page_names.thumbnails}}/{{video.id_}}.jpg" /></a>
-</td>
-<td>
-{{video.definition}}<br />
-{{video.duration}}
-</td>
-<td>
-<b><a href="/{{page_names.yt_result}}/{{video.id_}}">{{video.title}}</a></b> · {{video.description}}
-</td>
-</tr>
-{% endfor %}
-</table>
-{% endblock %}
index 2eb793059fb81741cf5cf0b53446e537762d037e..f4c3ad95dfb6608dd72e039d2d1ef4d3b93b0615 100644 (file)
@@ -13,7 +13,8 @@ from plomlib.web import (
         PlomHttpHandler, PlomHttpServer, PlomQueryMap, MIME_APP_JSON)
 from ytplom.db import Hash, DbConn
 from ytplom.misc import (
-    FILE_FLAGS, PATH_THUMBNAILS, YOUTUBE_URL_PREFIX, ensure_expected_dirs,
+    CMD_DOWN, CMD_RM, CMD_UP, FILE_FLAGS, PATH_THUMBNAILS, YOUTUBE_URL_PREFIX,
+    ensure_expected_dirs,
     Config, DownloadsManager, FilterStr, FlagName, Player, QueryId, QueryText,
     QuotaLog, TagSet, VideoFile, YoutubeId, YoutubeQuery, YoutubeVideo
 )
@@ -24,40 +25,8 @@ from ytplom.primitives import NotFoundException, PATH_APP_DATA
 _THUMBNAIL_URL_PREFIX = 'https://i.ytimg.com/vi/'
 _THUMBNAIL_URL_SUFFIX = '/default.jpg'
 
-# template paths
-_PATH_TEMPLATES = PATH_APP_DATA.joinpath('templates')
-_NAME_TEMPLATE_DOWNLOADS = Path('downloads.tmpl')
-_NAME_TEMPLATE_FILE_DATA = Path('file_data.tmpl')
-_NAME_TEMPLATE_FILES = Path('files.tmpl')
-_NAME_TEMPLATE_PLAYLIST = Path('playlist.tmpl')
-_NAME_TEMPLATE_TAGS = Path('tags.tmpl')
-_NAME_TEMPLATE_YT_QUERIES = Path('yt_queries.tmpl')
-_NAME_TEMPLATE_YT_RESULT = Path('yt_result.tmpl')
-_NAME_TEMPLATE_YT_RESULTS = Path('yt_results.tmpl')
-
-# page names
-PAGE_NAMES: dict[str, str] = {
-    'download': 'dl',
-    'downloads': 'downloads',
-    'downloads_json': 'downloads.json',
-    'events': 'events',
-    'file': 'file',
-    'file_kill': 'file_kill',
-    'file_json': 'file.json',
-    'files': 'files',
-    'files_json': 'files.json',
-    'missing': 'missing',
-    'player': 'player',
-    'playlist': 'playlist',
-    'purge': 'purge',
-    'tags': 'tags',
-    'thumbnails': 'thumbnails',
-    'yt_queries': 'yt_queries',
-    'yt_query': 'yt_query',
-    'yt_result': 'yt_result'
-}
-
 # misc
+_PATH_TEMPLATES = PATH_APP_DATA.joinpath('templates')
 _PING_INTERVAL_S = 1
 _EVENTS_UPDATE_INTERVAL_S = 0.1
 _HEADER_CONTENT_TYPE = 'Content-Type'
@@ -111,17 +80,17 @@ class _TaskHandler(PlomHttpHandler):
 
     def do_POST(self) -> None:  # pylint:disable=invalid-name
         """Map POST requests to handlers for various paths."""
-        if self.pagename == PAGE_NAMES['downloads_json']:
+        if self.pagename == 'downloads.json':
             self._receive_downloads()
-        elif self.pagename == PAGE_NAMES['file_json']:
+        elif self.pagename == 'file.json':
             self._update_file()
-        elif self.pagename == PAGE_NAMES['file_kill']:
+        elif self.pagename == 'file_kill':
             self._kill_file()
-        elif self.pagename == PAGE_NAMES['player']:
+        elif self.pagename == 'player':
             self._receive_player_command()
-        elif self.pagename == PAGE_NAMES['purge']:
+        elif self.pagename == 'purge':
             self._purge_deleted_files()
-        elif self.pagename == PAGE_NAMES['yt_queries']:
+        elif self.pagename == 'yt_queries':
             self._receive_yt_query()
 
     def _receive_downloads(self) -> None:
@@ -168,11 +137,11 @@ class _TaskHandler(PlomHttpHandler):
             self.server.player.load_files_and_mpv(empty=True)
         elif command.startswith('jump_'):
             self.server.player.jump_to(int(command.split('_')[1]))
-        elif command.startswith('up_'):
+        elif command.startswith(CMD_UP + '_'):
             self.server.player.move_entry(int(command.split('_')[1]))
-        elif command.startswith('down_'):
+        elif command.startswith(CMD_DOWN + '_'):
             self.server.player.move_entry(int(command.split('_')[1]), False)
-        elif command.startswith('rm_'):
+        elif command.startswith(CMD_RM + '_'):
             self.server.player.remove_by_idx(int(command.split('_')[1]))
         elif (command.startswith('inject_')
               or command.startswith('injectplay_')):
@@ -197,44 +166,52 @@ class _TaskHandler(PlomHttpHandler):
             query_data = YoutubeQuery.new_by_request_saved(
                     conn, self.server.config, query_txt)
             conn.commit()
-        self._redirect(Path('/')
-                       .joinpath(PAGE_NAMES['yt_query'])
-                       .joinpath(query_data.id_))
+        self._redirect(Path('/', 'yt_query', query_data.id_))
 
     def do_GET(self) -> None:  # pylint:disable=invalid-name
         """Map GET requests to handlers for various paths."""
         try:
-            if self.pagename == PAGE_NAMES['download']:
+            if self.pagename == 'download':
                 self._send_or_download_video()
-            elif self.pagename == PAGE_NAMES['downloads']:
-                self._send_downloads()
-            elif self.pagename == PAGE_NAMES['events']:
+            elif self.pagename == 'events':
                 self._send_events()
-            elif self.pagename == PAGE_NAMES['file']:
-                self._send_file_data()
-            elif self.pagename == PAGE_NAMES['file_json']:
+            elif self.pagename == 'file.json':
                 self._send_file_json()
-            elif self.pagename == PAGE_NAMES['files']:
-                self._send_files_index()
-            elif self.pagename == PAGE_NAMES['files_json']:
+            elif self.pagename == 'files.json':
                 self._send_files_json()
-            elif self.pagename == PAGE_NAMES['missing']:
+            elif self.pagename == 'missing':
                 self._send_missing_json()
-            elif self.pagename == PAGE_NAMES['tags']:
-                self._send_tags_index()
-            elif self.pagename == PAGE_NAMES['thumbnails']:
+            elif self.pagename == 'thumbnails':
                 self._send_thumbnail()
-            elif self.pagename == PAGE_NAMES['yt_result']:
-                self._send_yt_result()
-            elif self.pagename == PAGE_NAMES['yt_queries']:
-                self._send_yt_queries_index_and_search()
-            elif self.pagename == PAGE_NAMES['yt_query']:
-                self._send_yt_query_page()
-            else:  # e.g. for /
-                self._send_playlist()
+            elif self.pagename.endswith('.js'):
+                self._send_js()
+            else:
+                self._send_html()
         except NotFoundException as e:
             self.send_http(bytes(str(e), encoding='utf8'), code=404)
 
+    def _build_ctx(self, target_name: str) -> dict[str, Any]:
+        handler_name = '_send_' + target_name.replace('.', '_')
+        return (getattr(self, handler_name)() if hasattr(self, handler_name)
+                else {})
+
+    def _send_html(self) -> None:
+        tmpl_name = f'{self.pagename}.html'
+        tmpl_path = _PATH_TEMPLATES.joinpath(tmpl_name)
+        if not tmpl_path.exists():
+            self.pagename = 'playlist'
+            tmpl_name = f'{self.pagename}.html'
+        ctx = {'pagename': self.pagename,
+               'background_color': self.server.config.background_color}
+        self.send_rendered(Path(tmpl_name), ctx | self._build_ctx(tmpl_name))
+
+    def _send_js(self) -> None:
+        ctx = self._build_ctx(self.pagename)
+        self.send_http(
+            bytes(self.server.jinja.get_template(self.pagename).render(**ctx),
+                  encoding='utf8'),
+            [(_HEADER_CONTENT_TYPE, 'text/javascript')])
+
     def _send_json(self, body: dict | list) -> None:
         self.send_http(bytes(json_dumps(body), encoding='utf8'),
                        headers=[(_HEADER_CONTENT_TYPE, MIME_APP_JSON)])
@@ -245,7 +222,6 @@ class _TaskHandler(PlomHttpHandler):
                                 ) -> None:
         tmpl_ctx['selected'] = tmpl_ctx.get('selected', '')
         tmpl_ctx['background_color'] = self.server.config.background_color
-        tmpl_ctx['page_names'] = PAGE_NAMES
         self.send_rendered(tmpl_name, tmpl_ctx)
 
     def _send_or_download_video(self) -> None:
@@ -255,17 +231,10 @@ class _TaskHandler(PlomHttpHandler):
                 file_data = VideoFile.get_by_yt_id(conn, video_id)
                 if not file_data.present:
                     raise NotFoundException
-                self._redirect(Path('/')
-                               .joinpath(PAGE_NAMES['file'])
-                               .joinpath(file_data.digest.b64))
+                self._redirect(Path('/', 'file', file_data.digest.b64))
         except NotFoundException:
             self.server.downloads.q.put(f'queue_{video_id}')
-            self._redirect(Path('/')
-                           .joinpath(PAGE_NAMES['yt_result'])
-                           .joinpath(video_id))
-
-    def _send_downloads(self) -> None:
-        self._send_rendered_template(_NAME_TEMPLATE_DOWNLOADS, {})
+            self._redirect(Path('/', 'yt_result', video_id))
 
     def _send_events(self) -> None:
         self.send_http(headers=[(_HEADER_CONTENT_TYPE, 'text/event-stream'),
@@ -333,35 +302,37 @@ class _TaskHandler(PlomHttpHandler):
                         last_updates['download'] = update['time']
                         payload['download'] = {k: update[k] for k in update
                                                if k != 'time'}
-            if 'downloads' in subscriptions:
-                if last_updates['downloads'] < self.server.downloads.timestamp:
-                    last_updates['downloads'] = self.server.downloads.timestamp
-                    with DbConn() as conn:
-                        payload['downloads']\
-                            = self.server.downloads.overview(conn)
+            if 'downloads' in subscriptions\
+                    and (last_updates['downloads']
+                         < self.server.downloads.timestamp):
+                last_updates['downloads'] = self.server.downloads.timestamp
+                with DbConn() as conn:
+                    payload['downloads']\
+                        = self.server.downloads.overview(conn)
             if not payload:
                 sleep(_EVENTS_UPDATE_INTERVAL_S)
 
-    def _send_file_data(self) -> None:
+    def _send_file_html(self) -> dict[str, Any]:
         with DbConn() as conn:
             file = VideoFile.get_one(conn, Hash.from_b64(self.path_toks[2]))
-        self._send_rendered_template(
-            _NAME_TEMPLATE_FILE_DATA,
-            {'allow_edit': self.server.config.allow_file_edit, 'file': file})
+            return {
+                'allow_edit': self.server.config.allow_file_edit,
+                'file': file}
 
     def _send_file_json(self) -> None:
         with DbConn() as conn:
             file = VideoFile.get_one(conn, Hash.from_b64(self.path_toks[2]))
             unused_tags = file.unused_tags(conn).as_str_list
-        self._send_json(file.as_dict | {'unused_tags': unused_tags})
+        self._send_json(file.as_dict | {'unused_tags': unused_tags,
+                                        'yt_id': file.yt_id})
 
-    def _send_files_index(self) -> None:
+    def _send_files_js(self) -> dict[str, Any]:
         with DbConn() as conn:
-            showable_tags = sorted(list(VideoFile.all_tags_showable(conn)))
-        self._send_rendered_template(_NAME_TEMPLATE_FILES, {
-            'allow_show_absent':  self.server.config.allow_file_edit,
-            'showable_tags': showable_tags,
-            'needed_tags': self.params.all_for('needed_tag')})
+            showable_tags = VideoFile.all_tags_showable(conn)
+            return {'showable_tags': sorted(list(showable_tags))}
+
+    def _send_files_html(self) -> dict[str, Any]:
+        return {'allow_show_absent':  self.server.config.allow_file_edit}
 
     def _send_files_json(self) -> None:
         with DbConn() as conn:
@@ -380,13 +351,11 @@ class _TaskHandler(PlomHttpHandler):
             self._send_json([f.digest.b64 for f in VideoFile.get_all(conn)
                              if not f.present])
 
-    def _send_tags_index(self) -> None:
+    def _send_tags_js(self) -> dict[str, Any]:
         with DbConn() as conn:
-            tags_to_numbers = [
-                    {'name': k, 'number': v} for k, v
-                    in VideoFile.showable_tags_to_numbers(conn).items()]
-        self._send_rendered_template(_NAME_TEMPLATE_TAGS,
-                                     {'tags': tags_to_numbers})
+            return {
+                'tags': [{'name': k, 'number': v} for k, v
+                         in VideoFile.showable_tags_to_numbers(conn).items()]}
 
     def _send_thumbnail(self) -> None:
         filename = Path(self.path_toks[2])
@@ -404,7 +373,7 @@ class _TaskHandler(PlomHttpHandler):
         with path_thumbnail.open('rb') as f:
             self.send_http(f.read(), [(_HEADER_CONTENT_TYPE, 'image/jpg')])
 
-    def _send_yt_result(self) -> None:
+    def _send_yt_result_html(self) -> dict[str, Any]:
         video_id = YoutubeId(self.path_toks[2])
         with DbConn() as conn:
             linked_queries = [
@@ -414,37 +383,29 @@ class _TaskHandler(PlomHttpHandler):
                 video_data = YoutubeVideo.get_one(conn, video_id)
             except NotFoundException:
                 video_data = YoutubeVideo(video_id)
-        self._send_rendered_template(
-                _NAME_TEMPLATE_YT_RESULT,
-                {'video_data': video_data,
-                 'queries': linked_queries,
-                 'youtube_prefix': (
-                     YOUTUBE_URL_PREFIX if self.server.config.link_originals
-                     else '')})
-
-    def _send_yt_queries_index_and_search(self) -> None:
+            return {
+                'video_data': video_data,
+                'queries': linked_queries,
+                'youtube_prefix': (
+                    YOUTUBE_URL_PREFIX if self.server.config.link_originals
+                    else '')}
+
+    def _send_yt_queries_html(self) -> dict[str, Any]:
         with DbConn() as conn:
             quota_count = QuotaLog.current(conn)
             queries_data = [
                     q for q in YoutubeQuery.get_all(conn)
                     if q.retrieved_at > self.server.config.queries_cutoff]
         queries_data.sort(key=lambda q: q.retrieved_at, reverse=True)
-        self._send_rendered_template(_NAME_TEMPLATE_YT_QUERIES,
-                                     {'queries': queries_data,
-                                      'quota_count': quota_count,
-                                      'selected': 'yt_queries'})
+        return {'queries': queries_data,
+                'quota_count': quota_count,
+                'selected': 'yt_queries'}
 
-    def _send_yt_query_page(self) -> None:
+    def _send_yt_query_html(self) -> dict[str, Any]:
         query_id = QueryId(self.path_toks[2])
         with DbConn() as conn:
-            query = YoutubeQuery.get_one(conn, str(query_id))
-            results = YoutubeVideo.get_all_for_query(conn, query_id)
-        self._send_rendered_template(_NAME_TEMPLATE_YT_RESULTS,
-                                     {'query': query.text, 'videos': results})
-
-    def _send_playlist(self) -> None:
-        self._send_rendered_template(_NAME_TEMPLATE_PLAYLIST,
-                                     {'selected': 'playlist'})
+            return {'query': YoutubeQuery.get_one(conn, str(query_id)).text,
+                    'videos': YoutubeVideo.get_all_for_query(conn, query_id)}
 
 
 def serve():
index 33a4a9b60827b2c4eb8ffff539b6f0de4e1cd6a1..8e3592020aef72a3f12fb9f50a8310332c241e3d 100644 (file)
@@ -85,6 +85,10 @@ TOK_LOADED = 'downloaded_bytes'
 MILLION = 1000 * 1000
 MEGA = 1024 * 1024
 
+CMD_UP = 'up'
+CMD_DOWN = 'down'
+CMD_RM = 'rm'
+
 
 def ensure_expected_dirs(expected_dirs: list[Path]) -> None:
     """Ensure existance of expected_dirs _as_ directories."""
@@ -953,7 +957,7 @@ class DownloadsManager:
         for attr_name in ('_downloaded', '_inherited'):
             coll = getattr(self, attr_name)
             if yt_id in coll:
-                # NB: for some strange remove .remove on target
+                # NB: for some strange .remove on target
                 # collection will not satisfy assert below …
                 setattr(self, attr_name, [id_ for id_ in coll if id_ != yt_id])
         assert yt_id not in self._downloaded + self._inherited
@@ -1054,18 +1058,18 @@ class DownloadsManager:
                 if command == 'download_next':
                     Thread(target=self._download_next, daemon=False).start()
                     continue
-                if command == 'abort':
+                if command == CMD_RM:
                     yt_id = self._abort_downloading()
                 else:
                     command, arg = command.split('_', maxsplit=1)
                     if command == 'savefile':
                         yt_id = self._savefile(arg)
-                    elif command in {'unqueue', 'up', 'down'}:
+                    elif command in {CMD_RM, CMD_UP, CMD_DOWN}:
                         idx = int(arg)
-                        if command == 'unqueue':
+                        if command == CMD_RM:
                             yt_id = self._unqueue_download(idx)
                         else:
-                            self._move_in_queue(idx, upwards=command == 'up')
+                            self._move_in_queue(idx, upwards=command == CMD_UP)
                             continue
                     else:
                         yt_id = arg
index d7bc0c5460dcdfb7e3e444b573f677511fac655a..252c7a1e1ca794afbdc8190ceddbf8ed1de1e655 100644 (file)
@@ -11,7 +11,6 @@ from scp import SCPClient  # type: ignore
 from ytplom.db import DbConn, DbFile, Hash, PATH_DB
 from ytplom.misc import (PATH_TEMP, Config, FileDeletionRequest, FlagName,
                          QuotaLog, VideoFile, YoutubeQuery, YoutubeVideo)
-from ytplom.http import PAGE_NAMES
 
 
 _PATH_DB_REMOTE = PATH_TEMP.joinpath('remote_db.sql')
@@ -89,7 +88,7 @@ def _sync_dbs(scp: SCPClient) -> None:
 
 
 def _urls_here_and_there(config: Config, page_name: str) -> tuple[str, ...]:
-    return tuple(f'http://{host}:{port}/{PAGE_NAMES[page_name]}'
+    return tuple(f'http://{host}:{port}/{page_name}'
                  for host, port in ((config.remote, config.port_remote),
                                     (config.host, config.port)))