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''
+ )
+
+ 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''
+ )
+
+ 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 }}