home · contact · privacy
Improve interface and live-ness of file data editing. master
authorChristian Heller <c.heller@plomlompom.de>
Thu, 20 Feb 2025 13:48:04 +0000 (14:48 +0100)
committerChristian Heller <c.heller@plomlompom.de>
Thu, 20 Feb 2025 13:48:04 +0000 (14:48 +0100)
src/templates/_base.tmpl
src/templates/file_data.tmpl
src/ytplom/http.py
src/ytplom/misc.py

index 4d2f21e1680db226fe05240c2e3b828cf7eb272f..c75d1dfa803d451a8f9e3097a5ba5b5693c36ad1 100644 (file)
@@ -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) {
index fa1117e02e3c0cefd9aefc4a14dd788dea494ca4..0ea51668ebf12be62b5436fe6bd795220859cf19 100644 (file)
@@ -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 %}
-<form action="/{{page_names.file}}/{{file.digest.b64}}" method="POST" />
-<input type="hidden" name="redir_target" value="{{redir_target}}" />
-{% endif %}
 <table>
 
 <tr>
@@ -23,13 +83,7 @@ input[type=submit].dangerous { color: red; }
 
 <tr>
 <th>present:</th>
-<td>
-{% if file.present %}
-<a href="/{{page_names.download}}/{{file.yt_id}}">yes</a>
-<input type="button" onclick="player_command('inject_{{file.digest.b64}}');" value="inject" />
-{% else %}
-no
-{% endif %}
+<td id="presence">
 </td>
 </tr>
 
@@ -46,22 +100,13 @@ no
 <tr>
 <th>tags</th>
 <td>
-<table>
-{% for tag in file.tags_showable %}
-<tr>
-<td class="tag_checkboxes"><input type="checkbox" name="tags" value="{{tag}}" checked{% if not allow_edit %} disabled{% endif %}/></td>
-<td><a href="/{{page_names.files}}?needed_tag={{tag|urlencode}}">{{tag}}</a></td>
-</tr>
-{% endfor %}
+<table id="tags_table">
 {% if allow_edit %}
 <tr>
-<td class="tag_checkboxes">add:</td>
+<td class="tag_checkboxes"><button id="add_tag_button" onclick="send_update(this)" >add:</button></td>
 <td>
-<input name="tags" list="unused_tags" autocomplete="off" />
+<input id="added_tag" list="unused_tags" autocomplete="off" />
 <datalist id="unused_tags" />
-{% for tag in unused_tags %}
-<option value="{{tag}}">{{tag}}</option>
-{% endfor %}
 </datalist>
 </td>
 </tr>
@@ -71,20 +116,26 @@ no
 </tr>
 
 <tr>
-<th>flags:</th>
+<th>options</th>
+<td>
+<table>
+<tr>
+<td>
+<input id="sync_checkbox" type="checkbox" {% if not allow_edit %}disabled{% endif %} onclick="send_update()" /> do sync<br />
+</td>
+{% if allow_edit %}
 <td class="dangerous">
-{% for flag_name in flag_names %}
-{{ flag_name }}: <input type="checkbox" name="flags" value="{{flag_name}}" {% if file.is_flag_set(flag_name) %}checked{% endif %}{% if not allow_edit %} disabled{% endif %}/><br />
-{% endfor %}
+<button id="unlink_button" onclick="send_update(this)" />delete locally</button>
+<form action="/{{page_names.file_kill}}/{{file.digest.b64}}" method="POST" />
+<input type="hidden" name="redir_target" value="{{redir_target}}" />
+<input type="submit" value="KILL" />
+</form>
+</td>
+{% endif %}
+</tr>
+</table>
 </td>
 </tr>
 
 </table>
-{% if allow_edit %}
-<input type="submit" name="update" value="update" />
-<div class="dangerous">
-<input class="dangerous" type="submit" name="unlink" value="unlink (locally)" />
-</div>
-</form>
-{% endif %}
 {% endblock %}
index 59da51cd5b20f39c4c2b79424565105d7c26a7fa..b173a229782490de0ce26c6ea135a5d7e4a2d014 100644 (file)
@@ -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])
index 768227c0f5d70ae0af4d85b81c95efd920bd63ac..404884506e506c89deb4058dd91849dc17c947da 100644 (file)
@@ -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)