From f20f5380dd34193366ffab2d2fca3d1f08a702f7 Mon Sep 17 00:00:00 2001 From: Christian Heller Date: Thu, 20 Feb 2025 14:48:04 +0100 Subject: [PATCH] Improve interface and live-ness of file data editing. --- src/templates/_base.tmpl | 14 ++-- src/templates/file_data.tmpl | 123 +++++++++++++++++++++++++---------- src/ytplom/http.py | 59 +++++++++++------ src/ytplom/misc.py | 38 ++++++++++- 4 files changed, 173 insertions(+), 61 deletions(-) diff --git a/src/templates/_base.tmpl b/src/templates/_base.tmpl index 4d2f21e..c75d1df 100644 --- a/src/templates/_base.tmpl +++ b/src/templates/_base.tmpl @@ -59,12 +59,18 @@ async function wrapped_fetch(target, fetch_kwargs=null, verbose=false) { } } -function player_command(command) { - wrapped_fetch(PATH_PLAYER, { +async function wrapped_post(target, body, verbose=false) { + const fetch_kwargs = { method: "POST", headers: {"Content-Type": "application/json"}, - body: JSON.stringify({command: [command]}) - }); + body: JSON.stringify(body) + }; + const response = await wrapped_fetch(target, fetch_kwargs, verbose); + return response; +} + +function player_command(command) { + wrapped_post(PATH_PLAYER, {command: [command]}) } function father_tag_links(parent_element, tags) { diff --git a/src/templates/file_data.tmpl b/src/templates/file_data.tmpl index fa1117e..0ea5166 100644 --- a/src/templates/file_data.tmpl +++ b/src/templates/file_data.tmpl @@ -1,19 +1,79 @@ {% 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()); + document.getElementById("sync_checkbox").checked = ! file_data.flags.includes("do not sync"); + Array.from(document.getElementsByClassName("listed_tags")).forEach((row) => row.remove()); + file_data.tags_showable.forEach((tag) => { + const tr = new_child_to("tr", document.getElementById("tags_table")); + tr.classList.add("listed_tags"); + td_checkbox = new_child_to("td", tr); + td_checkbox.classList.add("tag_checkboxes"); + const input = new_child_to("input", td_checkbox); + input.type = "checkbox"; + input.checked = true; + {% if not allow_edit %}input.disabled = true;{% endif %} + input.onclick = send_update; + const a = new_child_to("a", new_child_to("td", new_child_to("td", tr))); + a.href = "/{{page_names.files}}?needed_tag={{tag|urlencode}}"; + a.textContent = tag; + }); + document.getElementById("added_tag").value = ""; + const datalist = document.getElementById("unused_tags"); + datalist.innerHTML = ""; + file_data.unused_tags.forEach((tag) => { new_child_to("option", datalist, tag); }); + td_present = document.getElementById("presence"); + td_present.innerHTML = ""; + if (file_data.present) { + const a = new_child_to("a", td_present, "yes"); + a.href = "/{{page_names.download}}/{{file.yt_id}}"; + const button = new_child_to("button", td_present, "inject"); + button.onclick = function() { player_command("inject_{{file.digest.b64}}"); } + } 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, div.dangerous { text-align: right; } -input[type=submit].dangerous { color: red; } +td.dangerous { text-align: right} +td.dangerous > form > input[type=submit], td.dangerous > button { color: red; } {% endblock %} {% block body %} -{% if allow_edit %} -
- -{% endif %} @@ -23,13 +83,7 @@ input[type=submit].dangerous { color: red; } - @@ -46,22 +100,13 @@ no
present: -{% if file.present %} -yes - -{% else %} -no -{% endif %} +
tags - -{% for tag in file.tags_showable %} - - - - -{% endfor %} +
{{tag}}
{% if allow_edit %} - + @@ -71,20 +116,26 @@ no - + +
add: - + -{% for tag in unused_tags %} - -{% endfor %}
flags:options + + + +{% if allow_edit %} +{% endif %} + +
+ do sync
+
-{% for flag_name in flag_names %} -{{ flag_name }}:
-{% endfor %} + + + + + +
-{% if allow_edit %} - -
- -
- -{% endif %} {% endblock %} diff --git a/src/ytplom/http.py b/src/ytplom/http.py index 59da51c..b173a22 100644 --- a/src/ytplom/http.py +++ b/src/ytplom/http.py @@ -40,6 +40,8 @@ PAGE_NAMES: dict[str, str] = { 'download': 'dl', 'events': 'events', 'file': 'file', + 'file_kill': 'file_kill', + 'file_json': 'file.json', 'files': 'files', 'files_json': 'files.json', 'missing': 'missing', @@ -104,8 +106,10 @@ 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['file']: - self._receive_file_data() + if self.pagename == PAGE_NAMES['file_json']: + self._update_file() + elif self.pagename == PAGE_NAMES['file_kill']: + self._kill_file() elif self.pagename == PAGE_NAMES['player']: self._receive_player_command() elif self.pagename == PAGE_NAMES['purge']: @@ -113,22 +117,32 @@ class _TaskHandler(PlomHttpHandler): elif self.pagename == PAGE_NAMES['yt_queries']: self._receive_yt_query() - def _receive_file_data(self) -> None: + def _kill_file(self) -> None: + if not self.server.config.allow_file_edit: + self.send_http(b'no way', code=403) + with DbConn() as conn: + file = VideoFile.get_one(conn, Hash.from_b64(self.path_toks[2])) + file.set_flags({FILE_FLAGS[FlagName('delete')]}) + file.save(conn) + conn.commit() + file.ensure_absence_if_deleted() + self._redirect(Path(self.postvars.first_for('redir_target'))) + + def _update_file(self) -> None: if not (self.server.config.allow_file_edit # also if whitelist, … and self.server.config.tags_display_whitelist.empty): self.send_http(b'no way', code=403) # … cuz input form under … return # … this display filter might have suppressed set tags with DbConn() as conn: file = VideoFile.get_one(conn, Hash.from_b64(self.path_toks[2])) - if self.postvars.has_key('unlink'): - file.unlink_locally() - file.set_flags({FILE_FLAGS[FlagName(name)] - for name in self.postvars.all_for('flags')}) file.tags = TagSet.from_str_list(self.postvars.all_for('tags')) + file.set_flags({FILE_FLAGS[FlagName(name)] for name + in self.postvars.all_for('flags')}) + if self.postvars.as_dict['delete_locally']: + file.unlink_locally() file.save(conn) conn.commit() - file.ensure_absence_if_deleted() - self._redirect(Path(self.postvars.first_for('redir_target'))) + self.send_http(b'OK') def _receive_player_command(self) -> None: command = self.postvars.first_for('command') @@ -183,6 +197,8 @@ class _TaskHandler(PlomHttpHandler): self._send_events() elif self.pagename == PAGE_NAMES['file']: self._send_file_data() + elif self.pagename == PAGE_NAMES['file_json']: + self._send_file_json() elif self.pagename == PAGE_NAMES['files']: self._send_files_index() elif self.pagename == PAGE_NAMES['files_json']: @@ -202,6 +218,10 @@ class _TaskHandler(PlomHttpHandler): except NotFoundException as e: self.send_http(bytes(str(e), encoding='utf8'), code=404) + def _send_json(self, body: dict | list) -> None: + self.send_http(bytes(json_dumps(body), encoding='utf8'), + headers=[(_HEADER_CONTENT_TYPE, MIME_APP_JSON)]) + def _send_rendered_template(self, tmpl_name: Path, tmpl_ctx: dict[str, Any] @@ -276,17 +296,19 @@ class _TaskHandler(PlomHttpHandler): sleep(_EVENTS_UPDATE_INTERVAL_S) def _send_file_data(self) -> None: - digest = Hash.from_b64(self.path_toks[2]) with DbConn() as conn: - file = VideoFile.get_one(conn, digest) - unused_tags = file.unused_tags(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, - 'flag_names': list(FILE_FLAGS), - 'redir_target': self.path, - 'unused_tags': unused_tags}) + 'redir_target': self.path}) + + 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}) def _send_files_index(self) -> None: with DbConn() as conn: @@ -303,16 +325,13 @@ 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_http(bytes(json_dumps([f.as_dict for f in files]), - encoding='utf8'), - headers=[(_HEADER_CONTENT_TYPE, MIME_APP_JSON)]) + self._send_json([f.as_dict for f in files]) def _send_missing_json(self) -> None: with DbConn() as conn: missing = [f.digest.b64 for f in VideoFile.get_all(conn) if f.missing] - self.send_http(bytes(json_dumps(missing), encoding='utf8'), - headers=[(_HEADER_CONTENT_TYPE, MIME_APP_JSON)]) + self._send_json(missing) def _send_thumbnail(self) -> None: filename = Path(self.path_toks[2]) diff --git a/src/ytplom/misc.py b/src/ytplom/misc.py index 768227c..4048845 100644 --- a/src/ytplom/misc.py +++ b/src/ytplom/misc.py @@ -371,6 +371,7 @@ class VideoFile(DbData): return { 'digest': self.digest.b64, 'duration': self.duration(short=True), + 'flags': self.flags_as_str_list, 'present': self.present, 'rel_path': str(self.rel_path), 'size': f'{self.size:.2f}', @@ -383,6 +384,34 @@ class VideoFile(DbData): self._renew_last_update() return super().save(conn) + def test_deletion(self, do_raise: bool) -> bool: + """If 'delete' flag set, return True or raise NotFound, else False.""" + if self.is_flag_set(FlagName('delete')): + if do_raise: + raise NotFoundException('not showing entry marked as deleted') + return True + return False + + @classmethod + def get_one(cls, conn: DbConn, id_: str | Hash) -> Self: + """Extend super by .test_deletion.""" + file = super().get_one(conn, id_) + file.test_deletion(do_raise=True) # pylint: disable=no-member + # NB: mypy recognizes file as VideoFile without below assert and + # if-isinstance-else, yet less type-smart pylint only does due to the + # latter (also the reason for the disable=no-member above; but wouldn't + # suffice here, pylint would still identify function's return falsely); + # the assert isn't needed by mypy and not enough for pylint, but is + # included just so no future code change would trigger the else result. + assert isinstance(file, VideoFile) + return file if isinstance(file, VideoFile) else cls(None, Path('')) + + @classmethod + def get_all(cls, conn: DbConn) -> list[Self]: + """Extend super by excluding matches in .test_deletion.""" + files = super().get_all(conn) + return [f for f in files if not f.test_deletion(do_raise=False)] + @classmethod def get_by_yt_id(cls, conn: DbConn, yt_id: YoutubeId) -> Self: """Return VideoFile of .yt_id.""" @@ -390,7 +419,9 @@ class VideoFile(DbData): (yt_id,)).fetchone() if not row: raise NotFoundException(f'no entry for file to Youtube ID {yt_id}') - return cls._from_table_row(row) + file = cls._from_table_row(row) + file.test_deletion(do_raise=False) + return file @classmethod def get_filtered(cls, @@ -465,6 +496,11 @@ class VideoFile(DbData): """Return if file absent despite absence of 'delete' flag.""" return not (self.is_flag_set(FlagName('delete')) or self.present) + @property + def flags_as_str_list(self) -> list[str]: + """Return all set flags.""" + return [str(k) for k in FILE_FLAGS if self.is_flag_set(k)] + def set_flags(self, flags: set[FlagsInt]) -> None: """Set .flags to bitwise OR of FlagsInt in flags.""" self.flags = FlagsInt(0) -- 2.30.2