From: Christian Heller <c.heller@plomlompom.de> Date: Thu, 20 Feb 2025 22:33:38 +0000 (+0100) Subject: Add new /tags view, sortable by name and usage number. X-Git-Url: https://plomlompom.com/repos/%7B%7Bdb.prefix%7D%7D/static/%7B%7Bprefix%7D%7D/%7B%7Byoutube_prefix%7D%7D%7B%7Bvideo_id%7D%7D?a=commitdiff_plain;p=ytplom Add new /tags view, sortable by name and usage number. --- diff --git a/src/templates/_base.tmpl b/src/templates/_base.tmpl index 007bce9..74a2cd0 100644 --- a/src/templates/_base.tmpl +++ b/src/templates/_base.tmpl @@ -117,6 +117,7 @@ td, th { vertical-align: top; text-align: left; margin: 0; padding: 0; } <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") }} <hr /> <button class="btn_if_can_play" onclick="player_command('prev')">prev</button> diff --git a/src/templates/_macros.tmpl b/src/templates/_macros.tmpl index 9c70226..9cf18c9 100644 --- a/src/templates/_macros.tmpl +++ b/src/templates/_macros.tmpl @@ -3,3 +3,46 @@ {% 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 = new_child_to('tr', table); + tr.classList.add("sortable_row"); + populate_list_item_row(tr, item); + }); +} + +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(); +} +{% endmacro %} diff --git a/src/templates/files.tmpl b/src/templates/files.tmpl index 81ae35d..4c4fa0c 100644 --- a/src/templates/files.tmpl +++ b/src/templates/files.tmpl @@ -6,8 +6,6 @@ const PATH_FILES_JSON = "/{{page_names.files_json}}"; const all_tags = {{showable_tags|tojson|safe}}; var needed_tags = {{needed_tags|tojson|safe}}; -var filtered_files = []; -var sort_key = "rel_path"; function select_tag() { if (tags_select.selectedIndex < 1) { @@ -46,38 +44,6 @@ function update_filter_inputs() { update_files_list(); } -function draw_files_table() { - filtered_files.sort((a, b) => { - let inverter = 1; - let _sort_key = sort_key; - if (sort_key[0] == '-') { - inverter = -1; - _sort_key = sort_key.substring(1); - } - const cmp = "tags_count" == _sort_key - ? (a.tags_showable.length > b.tags_showable.length) - : (a[_sort_key] > b[_sort_key]); - return inverter * (cmp ? 1 : -1); - }); - const table = document.getElementById("files_table"); - Array.from(document.getElementsByClassName("file_row")).forEach((row) => row.remove()); - filtered_files.forEach((file) => { - const tr = new_child_to('tr', table); - tr.classList.add("file_row"); - new_child_to('td', tr, file.size); - new_child_to('td', tr, file.duration); - const td_inject = new_child_to('td', tr); - const btn_inject = new_child_to('button', td_inject); - btn_inject.onclick = function() { player_command(`inject_${file.digest}`) }; - btn_inject.disabled = !file.present; - btn_inject.textContent = 'inject'; - father_tag_links(new_child_to('td', tr), file.tags_showable); - const td_link = new_child_to('td', tr); - const a = new_child_to('a', td_link, file.rel_path); - a.href = `${PATH_PREFIX_FILE}${file.digest}`; - }); -} - async function update_files_list() { const filter_path = encodeURIComponent(document.getElementById("input_filter_path").value); let target = `${PATH_FILES_JSON}?filter_path=${filter_path}`; @@ -85,37 +51,36 @@ async function update_files_list() { if (document.getElementById("input_show_absent").checked) { target = `${target}&show_absent=1`; } {% endif %} needed_tags.forEach((tag) => target = `${target}&needed_tag=${encodeURIComponent(tag)}`); - filtered_files = await wrapped_fetch(target).then((response) => response.json()); - document.getElementById("files_count").textContent = `${filtered_files.length}`; - draw_files_table(); + 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() { - filtered_files.forEach((file) => { player_command(`inject_${file.digest}`) }); + items_for_sortable_table.forEach((file) => { player_command(`inject_${file.digest}`) }); } -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_files_table(); +{{ macros.js_manage_sortable_table("rel_path") }} +var items_for_sortable_table = []; +function populate_list_item_row(tr, file) { + new_child_to('td', tr, file.size); + new_child_to('td', tr, file.duration); + const td_inject = new_child_to('td', tr); + const btn_inject = new_child_to('button', td_inject); + btn_inject.onclick = function() { player_command(`inject_${file.digest}`) }; + btn_inject.disabled = !file.present; + btn_inject.textContent = 'inject'; + father_tag_links(new_child_to('td', tr), file.tags_showable); + const td_link = new_child_to('td', tr); + const a = new_child_to('a', td_link, file.rel_path); + a.href = `${PATH_PREFIX_FILE}${file.digest}`; } - window.addEventListener('load', update_filter_inputs); {% endblock %} {% block css %} -button.sorter { background-color: transparent; } -button.sorting { background-color: white; color: black; } -button.reverse { background-color: black; color: white; } +{{ macros.css_sortable_table() }} {% endblock %} @@ -131,12 +96,12 @@ needed tags: <select id="tags_select" onchange="select_tag()"></select><br /> known files (shown: <span id="files_count">?</span>): <button onclick="inject_all();">inject all</button> </p> -<table id="files_table"> +<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, 'tags_count'); ">count</button></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> </tr> </table> diff --git a/src/templates/tags.tmpl b/src/templates/tags.tmpl new file mode 100644 index 0000000..a6f9f08 --- /dev/null +++ b/src/templates/tags.tmpl @@ -0,0 +1,30 @@ +{% 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) { + a_name = new_child_to('a', new_child_to('td', tr), tag.name); + a_name.href = `${PATH_FILES}?needed_tag=${encodeURIComponent(tag.name)}`; + new_child_to('td', tr, tag.number); +} +window.addEventListener('load', draw_sortable_table); +{% endblock %} + + +{% block css %} +{{ macros.css_sortable_table() }} +#sortable_table { width: auto; } +{% endblock %} + + +{% block body %} +<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/ytplom/http.py b/src/ytplom/http.py index 817220a..280b88c 100644 --- a/src/ytplom/http.py +++ b/src/ytplom/http.py @@ -31,6 +31,7 @@ _PATH_TEMPLATES = PATH_APP_DATA.joinpath('templates') _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') @@ -48,6 +49,7 @@ PAGE_NAMES: dict[str, str] = { 'player': 'player', 'playlist': 'playlist', 'purge': 'purge', + 'tags': 'tags', 'thumbnails': 'thumbnails', 'yt_queries': 'yt_queries', 'yt_query': 'yt_query', @@ -205,6 +207,8 @@ class _TaskHandler(PlomHttpHandler): self._send_files_json() elif self.pagename == PAGE_NAMES['missing']: self._send_missing_json() + elif self.pagename == PAGE_NAMES['tags']: + self._send_tags_index() elif self.pagename == PAGE_NAMES['thumbnails']: self._send_thumbnail() elif self.pagename == PAGE_NAMES['yt_result']: @@ -326,7 +330,9 @@ class _TaskHandler(PlomHttpHandler): TagSet(set(self.params.all_for('needed_tag'))), bool(self.params.first_for('show_absent'))) files.sort(key=lambda t: t.rel_path) - self._send_json([f.as_dict for f in files]) + self._send_json([ + f.as_dict | {'n_tags': len(f.tags_showable.as_str_list)} + for f in files]) def _send_missing_json(self) -> None: with DbConn() as conn: @@ -334,6 +340,14 @@ class _TaskHandler(PlomHttpHandler): if f.missing] self._send_json(missing) + def _send_tags_index(self) -> None: + 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}) + def _send_thumbnail(self) -> None: filename = Path(self.path_toks[2]) ensure_expected_dirs([PATH_THUMBNAILS]) diff --git a/src/ytplom/misc.py b/src/ytplom/misc.py index 4048845..2fd993e 100644 --- a/src/ytplom/misc.py +++ b/src/ytplom/misc.py @@ -426,8 +426,8 @@ class VideoFile(DbData): @classmethod def get_filtered(cls, conn: DbConn, - filter_path: FilterStr, - needed_tags_seen: TagSet, + filter_path: FilterStr = FilterStr(''), + needed_tags_seen: TagSet = TagSet(set()), show_absent: bool = False ) -> list[Self]: """Return cls.get_all matching provided filter criteria.""" @@ -440,6 +440,15 @@ class VideoFile(DbData): and needed_tags_seen.whitelisted(cls.tags_display_whitelist ).are_all_in(f.tags)] + @classmethod + def showable_tags_to_numbers(cls, conn) -> dict[str, int]: + """Return showable tags with how often used in showable files.""" + tags_to_numbers: dict[str, int] = {} + for file in cls.get_filtered(conn): + for tag in file.tags.whitelisted(cls.tags_display_whitelist): + tags_to_numbers[tag] = tags_to_numbers.get(tag, 0) + 1 + return tags_to_numbers + @classmethod def all_tags_showable(cls, conn) -> TagSet: """Show all used tags passing .tags_display_whitelist."""