From 087c7775aec16a46bf9dfab2c75fccd8fe15ecb9 Mon Sep 17 00:00:00 2001 From: Colin Powell Date: Thu, 4 Jun 2026 11:15:58 -0400 Subject: [PATCH] [templates] Fix note parsing --- PROJECT.org | 4 +- vrobbler/apps/scrobbles/dataclasses.py | 103 +++++++++++++++--- vrobbler/apps/tasks/models.py | 45 ++++++-- vrobbler/templates/base.html | 32 ++++++ .../templates/scrobbles/scrobble_detail.html | 4 +- 5 files changed, 158 insertions(+), 30 deletions(-) diff --git a/PROJECT.org b/PROJECT.org index db172b5..16dee8f 100644 --- a/PROJECT.org +++ b/PROJECT.org @@ -93,7 +93,7 @@ fetching and simple saving. :LOGBOOK: CLOCK: [2025-07-09 Wed 09:55]--[2025-07-09 Wed 10:15] => 0:20 :END: -* Backlog [4/19] :vrobbler:project:personal: +* Backlog [5/19] :vrobbler:project:personal: ** TODO [#C] Add sentiment parsing for Scrobbles with notes :vrobbler:project:scrobbles:sentiment: :PROPERTIES: :ID: 37781d6a-f3b0-48b2-bf98-33c2c791cf85 @@ -505,7 +505,7 @@ different. And the same for comments. If a comment (by timestamp key) is different in the webhook than what's in the scrobble.log, update the comment in the scrobble.log -** TODO [#B] For any scrobble detail page with notes display them better :templates:notes:scrobbles: +** DONE [#B] For any scrobble detail page with notes display them better :templates:notes:scrobbles: :PROPERTIES: :ID: c0dcf9da-227f-4a22-bcd9-9d46053607d9 :END: diff --git a/vrobbler/apps/scrobbles/dataclasses.py b/vrobbler/apps/scrobbles/dataclasses.py index 4f326a5..1e0abdf 100644 --- a/vrobbler/apps/scrobbles/dataclasses.py +++ b/vrobbler/apps/scrobbles/dataclasses.py @@ -83,43 +83,110 @@ class BaseLogData(JSONDataclass): return "" + @staticmethod + def _format_timestamp(ts: str) -> str: + from datetime import datetime, timezone + import re + + cleaned = ts.strip().strip("[]") + dt = None + + if cleaned.isdigit(): + try: + seconds = int(cleaned) + dt = datetime.fromtimestamp(seconds, tz=timezone.utc) + except (ValueError, OSError): + pass + + if dt is None: + for fmt in [ + "%Y-%m-%dT%H:%M:%S.%fZ", + "%Y-%m-%dT%H:%M:%SZ", + "%Y-%m-%dT%H:%M:%S.%f", + "%Y-%m-%dT%H:%M:%S", + ]: + try: + dt = datetime.strptime(cleaned, fmt) + break + except ValueError: + continue + + if dt is None: + m = re.match( + r"(\d{4}-\d{2}-\d{2})\s+\w{3}\s+(\d{2}:\d{2})", cleaned + ) + if m: + try: + dt = datetime.strptime( + f"{m.group(1)} {m.group(2)}", "%Y-%m-%d %H:%M" + ) + except ValueError: + pass + + if dt is None: + try: + dt = datetime.fromisoformat(cleaned) + except ValueError: + pass + + if dt: + return dt.strftime("%b %-d, %Y %-I:%M %p") + return ts + def notes_as_html(self) -> str: - import html + import bleach + import markdown from django.utils.safestring import mark_safe if not self.notes: return "" - html_notes = [] + md = markdown.Markdown(extensions=["extra"]) + allowed_tags = [ + "p", "br", "strong", "em", "a", "ul", "ol", "li", + "code", "pre", "blockquote", "h1", "h2", "h3", "h4", "h5", "h6", + "hr", "img", + ] + + note_items = [] if isinstance(self.notes, dict): - for ts, text in self.notes.items(): - note_text = " ".join(text.strip().split()) - html_notes.append( - f'
{ts}: {note_text}
' - ) + for ts, text in sorted(self.notes.items(), reverse=True): + note_items.append((ts, text.strip())) elif isinstance(self.notes, str): for line in self.notes.split("\n"): if line.strip(): - escaped_line = html.escape(line) - html_notes.append(f'
{escaped_line}
') + note_items.append((None, line.strip())) elif isinstance(self.notes, list): for note in self.notes: if isinstance(note, dict): timestamp, note_text = next(iter(note.items())) - note_text = " ".join(note_text.strip().split()) - html_notes.append( - f'
{timestamp}: {note_text}
' - ) + note_items.append((timestamp, note_text.strip())) elif isinstance(note, str): for line in note.split("\n"): if line.strip(): - escaped_line = html.escape(line) - html_notes.append( - f'
{escaped_line}
' - ) + note_items.append((None, line.strip())) - return mark_safe("".join(html_notes)) + html_parts = [] + for i, (ts, text) in enumerate(note_items): + if i > 0: + html_parts.append('
') + + ts_html = "" + if ts: + ts_html = f'
{self._format_timestamp(ts)}
' + + content_html = bleach.clean( + md.convert(text), + tags=allowed_tags, + strip=True, + ) + + html_parts.append( + f'
{ts_html}
{content_html}
' + ) + + return mark_safe("".join(html_parts)) @dataclass diff --git a/vrobbler/apps/tasks/models.py b/vrobbler/apps/tasks/models.py index 51512c1..a0027b7 100644 --- a/vrobbler/apps/tasks/models.py +++ b/vrobbler/apps/tasks/models.py @@ -76,33 +76,62 @@ class TaskLogData(BaseLogData): return separator.join(lines).encode("utf-8").decode("unicode_escape") def notes_as_html(self) -> str: + import bleach + import markdown + from django.utils.safestring import mark_safe + from scrobbles.dataclasses import BaseLogData + if not self.notes: return "" + md = markdown.Markdown(extensions=["extra"]) + allowed_tags = [ + "p", "br", "strong", "em", "a", "ul", "ol", "li", + "code", "pre", "blockquote", "h1", "h2", "h3", "h4", "h5", "h6", + "hr", "img", + ] + notes = self.notes if isinstance(notes, dict): notes = [{k: v} for k, v in notes.items()] if isinstance(notes, str): notes = [notes] - html_notes = [] + note_items = [] for note in notes: if isinstance(note, dict): timestamp, note_text = next(iter(note.items())) - note_text = " ".join(note_text.strip().split()) - html_notes.append( - f'
{timestamp}: {note_text}
' - ) + note_items.append((timestamp, note_text.strip())) elif isinstance(note, str): escaped = note.encode("utf-8").decode("unicode_escape") for line in escaped.split("\n"): if line.strip(): - html_notes.append(f'
{line}
') + note_items.append((None, line.strip())) elif isinstance(note, list): for item in note: if isinstance(item, str): - html_notes.append(f'
{item}
') - return "".join(html_notes) + note_items.append((None, item.strip())) + + html_parts = [] + for i, (ts, text) in enumerate(note_items): + if i > 0: + html_parts.append('
') + + ts_html = "" + if ts: + ts_html = f'
{BaseLogData._format_timestamp(ts)}
' + + content_html = bleach.clean( + md.convert(text), + tags=allowed_tags, + strip=True, + ) + + html_parts.append( + f'
{ts_html}
{content_html}
' + ) + + return mark_safe("".join(html_parts)) class Task(LongPlayScrobblableMixin): diff --git a/vrobbler/templates/base.html b/vrobbler/templates/base.html index aec1c09..46628ad 100644 --- a/vrobbler/templates/base.html +++ b/vrobbler/templates/base.html @@ -168,6 +168,35 @@ } #scrobble-form { width: 100% } + .notes-list { + max-width: 600px; + } + + .note-item { + padding: 4px 0; + } + + .note-timestamp { + font-size: 0.8em; + color: #666; + margin: 0 0 4px; + } + + .note-content { + font-size: 0.95em; + line-height: 1.5; + } + + .note-content p:last-child { + margin-bottom: 0; + } + + .note-divider { + margin: 8px 0; + border: 0; + border-top: 1px solid #ddd; + } + .sticky-notes-container { display: flex; flex-wrap: wrap; @@ -218,6 +247,9 @@ a:not(.nav-link):not(.btn):not(.page-link):hover { color: var(--accent); } + a.badge { + color: #fff !important; + } .table-striped > tbody > tr:nth-of-type(odd) { background-color: color-mix(in srgb, var(--accent) 6%, #fff); } diff --git a/vrobbler/templates/scrobbles/scrobble_detail.html b/vrobbler/templates/scrobbles/scrobble_detail.html index d3cf99b..0328bd5 100644 --- a/vrobbler/templates/scrobbles/scrobble_detail.html +++ b/vrobbler/templates/scrobbles/scrobble_detail.html @@ -115,8 +115,8 @@ {% with notes_html=object.logdata.notes_as_html %} {% if notes_html %}
-
Notes
-
+

Notes

+
{{ notes_html|safe }}