Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a70343d6f3 | |||
| 3e72042c24 | |||
| 087c7775ae | |||
| 3f71065ad6 | |||
| 801672124f | |||
| 811e9c1ce9 | |||
| 415b32bdc7 | |||
| 22319c807a | |||
| f9ba6fec14 | |||
| 5f55163147 | |||
| a6ef34623e | |||
| 7cb48d20f6 | |||
| 445103a878 |
@ -95,6 +95,15 @@ jobs:
|
|||||||
poetry build
|
poetry build
|
||||||
git checkout vrobbler/_commit.py
|
git checkout vrobbler/_commit.py
|
||||||
|
|
||||||
|
- name: Clean old wheels from server
|
||||||
|
uses: appleboy/ssh-action@v1.0.3
|
||||||
|
with:
|
||||||
|
host: vrobbler.service
|
||||||
|
username: root
|
||||||
|
key: ${{ secrets.JAIL_KEY }}
|
||||||
|
script: |
|
||||||
|
rm -f /var/lib/vrobbler/dist/*.whl
|
||||||
|
|
||||||
- name: Copy wheel to server and deploy
|
- name: Copy wheel to server and deploy
|
||||||
uses: appleboy/scp-action@v1.0.0
|
uses: appleboy/scp-action@v1.0.0
|
||||||
with:
|
with:
|
||||||
@ -116,9 +125,9 @@ jobs:
|
|||||||
mkdir -p /var/lib/vrobbler
|
mkdir -p /var/lib/vrobbler
|
||||||
echo "${{ gitea.sha }}" | cut -c1-8 > /var/lib/vrobbler/commit.txt
|
echo "${{ gitea.sha }}" | cut -c1-8 > /var/lib/vrobbler/commit.txt
|
||||||
pip uninstall -y vrobbler
|
pip uninstall -y vrobbler
|
||||||
rm -f /var/lib/vrobbler/*.whl
|
pip install /var/lib/vrobbler/dist/*.whl
|
||||||
pip install /var/lib/vrobbler/*.whl
|
rm -f /var/lib/vrobbler/dist/*.whl
|
||||||
python -c "import vrobbler; print(f'vrobbler {vrobbler.__version__} installed OK')"
|
python3 -c "import vrobbler; print(f'vrobbler {vrobbler.__version__} installed OK')"
|
||||||
vrobbler migrate
|
vrobbler migrate
|
||||||
vrobbler collectstatic --noinput
|
vrobbler collectstatic --noinput
|
||||||
immortalctl restart vrobbler-celery && immortalctl restart vrobbler-celerybeat && immortalctl restart vrobbler
|
immortalctl restart vrobbler-celery && immortalctl restart vrobbler-celerybeat && immortalctl restart vrobbler
|
||||||
|
|||||||
98
PROJECT.org
98
PROJECT.org
@ -93,7 +93,7 @@ fetching and simple saving.
|
|||||||
:LOGBOOK:
|
:LOGBOOK:
|
||||||
CLOCK: [2025-07-09 Wed 09:55]--[2025-07-09 Wed 10:15] => 0:20
|
CLOCK: [2025-07-09 Wed 09:55]--[2025-07-09 Wed 10:15] => 0:20
|
||||||
:END:
|
:END:
|
||||||
* Backlog [0/13] :vrobbler:project:personal:
|
* Backlog [0/14] :vrobbler:project:personal:
|
||||||
** TODO [#C] Add sentiment parsing for Scrobbles with notes :vrobbler:project:scrobbles:sentiment:
|
** TODO [#C] Add sentiment parsing for Scrobbles with notes :vrobbler:project:scrobbles:sentiment:
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
:ID: 37781d6a-f3b0-48b2-bf98-33c2c791cf85
|
:ID: 37781d6a-f3b0-48b2-bf98-33c2c791cf85
|
||||||
@ -428,7 +428,7 @@ Pretty clear, I would love to make trails more useful. Historically I wasn't
|
|||||||
hiking a lot, which made the source for this a bit silly. But it's clear that
|
hiking a lot, which made the source for this a bit silly. But it's clear that
|
||||||
AllTrails is the best source, though having TrailForks is nice to.
|
AllTrails is the best source, though having TrailForks is nice to.
|
||||||
|
|
||||||
** TODO [#B] Add `garmin_activity_id` to the TrailMetadataLog class :vrobbler:trails:feature:personal:project:
|
** TODO [#B] Add `garmin_activity_id` to the TrailLogData class :trails:feature:personal:project:
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
:ID: 5a4fb0f8-0555-40ec-b06f-93c26bd686f4
|
:ID: 5a4fb0f8-0555-40ec-b06f-93c26bd686f4
|
||||||
:END:
|
:END:
|
||||||
@ -505,6 +505,100 @@ 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
|
different in the webhook than what's in the scrobble.log, update the comment in
|
||||||
the scrobble.log
|
the scrobble.log
|
||||||
|
|
||||||
|
* Version 41.0 [5/5]
|
||||||
|
** DONE [#B] For any scrobble detail page with notes display them better :templates:notes:scrobbles:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: c0dcf9da-227f-4a22-bcd9-9d46053607d9
|
||||||
|
:END:
|
||||||
|
|
||||||
|
*** Description
|
||||||
|
|
||||||
|
Currently notes are displayed as little post-it notes. This is cute, but not terribly useful.
|
||||||
|
|
||||||
|
We should update note rendering to be a simple newest to oldest display in a
|
||||||
|
single column with the timestamp has a small header, and the content rendered as
|
||||||
|
markdown with a small bar or horizontal divider marking them from the next note.
|
||||||
|
|
||||||
|
** DONE [#A] Imports should send notifications :feature:notifications:imports:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 6f78f8d5-ecaa-4d8a-a666-ae4e27653191
|
||||||
|
:END:
|
||||||
|
|
||||||
|
*** Description
|
||||||
|
|
||||||
|
Currently importing board games sends out a ntfy message when a scrobble is
|
||||||
|
created.
|
||||||
|
|
||||||
|
We should do the same thing for other import types; namely: gpx, ebird, and scale.
|
||||||
|
|
||||||
|
** DONE [#A] Board game imports send duplicate ntfy message :bug:notifications:boardgames:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 8f067432-0399-4b79-9e93-727edcccedbd
|
||||||
|
:END:
|
||||||
|
|
||||||
|
*** Description
|
||||||
|
|
||||||
|
When a board game scrobble is created via a bgstats import, ntfy messages are
|
||||||
|
sent.
|
||||||
|
|
||||||
|
But right now they are duplicated (two are sent at the same time). Can we review
|
||||||
|
the code to see why this is happening and fix it?
|
||||||
|
|
||||||
|
** DONE [#A] Too many geolocation notifications go out :bug:notifications:geolocations:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 6357ad7a-fe4e-49dd-a063-55d87e459c17
|
||||||
|
:END:
|
||||||
|
|
||||||
|
*** Description
|
||||||
|
|
||||||
|
Currently ntfy gets overwhelemed when there's more than a hundred or so messages left in a queue on a client.
|
||||||
|
|
||||||
|
It would be nice if we could not spam ntfy, and this is especially true with Geolocations, where we really
|
||||||
|
don't need to alert folks unless they have a named Geolocation (has a title). Can we adjust the ntfy
|
||||||
|
sending for Geolocations to only send if the scrobbled location has a title?
|
||||||
|
|
||||||
|
|
||||||
|
** DONE [#C] Fix bug where Weigh-in imports do not set title :bug:tasks:scale:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 622e354a-8e66-4ecd-9e1c-a53f0a2ec362
|
||||||
|
:END:
|
||||||
|
|
||||||
|
*** Description
|
||||||
|
|
||||||
|
Currently when we import a scale CSV row and create data, the title is left
|
||||||
|
blank which makes it look funny in a list view. Let's save the weight as the
|
||||||
|
title of the Weigh-in task.
|
||||||
|
|
||||||
|
|
||||||
|
* Version 40.2 [1/1]
|
||||||
|
** DONE [#A] Try fixing deploy bugs again :tooling:releases:bug:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 15894943-be1d-200f-8400-a136770ad9d2
|
||||||
|
:END:
|
||||||
|
|
||||||
|
* Version 40.1 [2/2]
|
||||||
|
** DONE [#A] Releases are still broken :bug:releases:tooling:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: bca37a18-afa2-4ddd-a11b-ef0555f38bc9
|
||||||
|
:END:
|
||||||
|
|
||||||
|
*** Description
|
||||||
|
|
||||||
|
Deploys are still broken, even with them being pulled apart and run separately.
|
||||||
|
|
||||||
|
We need to address the way the commit ends up stashed in the codebase.
|
||||||
|
|
||||||
|
** DONE [#C] Fix bug on chart pages where trail titles missing :bug:trails:charts:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 21075430-8a93-4e59-9a02-479315960ae6
|
||||||
|
:END:
|
||||||
|
|
||||||
|
*** Description
|
||||||
|
|
||||||
|
When trails are rendered on the chart views, there are no titles, which means we don't see
|
||||||
|
anything except the ranking number.
|
||||||
|
|
||||||
|
|
||||||
* Version 40.0 [2/2]
|
* Version 40.0 [2/2]
|
||||||
** DONE [#A] Fix error in org-mode task sync :emacs:orgmode:tasks:bug:
|
** DONE [#A] Fix error in org-mode task sync :emacs:orgmode:tasks:bug:
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
|
|||||||
1
justfile
1
justfile
@ -19,5 +19,4 @@ release kind="minor":
|
|||||||
poetry run python scripts/release.py {{kind}}
|
poetry run python scripts/release.py {{kind}}
|
||||||
|
|
||||||
push:
|
push:
|
||||||
git push && git push gitea
|
|
||||||
git push --tags && git push --tags gitea
|
git push --tags && git push --tags gitea
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "vrobbler"
|
name = "vrobbler"
|
||||||
version = "40.0"
|
version = "41.0"
|
||||||
description = ""
|
description = ""
|
||||||
authors = ["Colin Powell <colin@unbl.ink>"]
|
authors = ["Colin Powell <colin@unbl.ink>"]
|
||||||
|
|
||||||
|
|||||||
@ -8,6 +8,7 @@ from django.contrib.auth import get_user_model
|
|||||||
|
|
||||||
from birds.models import Bird, BirdSightingEntry, BirdSightingLogData, BirdingLocation
|
from birds.models import Bird, BirdSightingEntry, BirdSightingLogData, BirdingLocation
|
||||||
from scrobbles.models import Scrobble
|
from scrobbles.models import Scrobble
|
||||||
|
from scrobbles.notifications import ScrobbleNtfyNotification
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
@ -183,4 +184,6 @@ def import_birding_csv(file_path, user_id):
|
|||||||
|
|
||||||
created = Scrobble.objects.bulk_create(new_scrobbles)
|
created = Scrobble.objects.bulk_create(new_scrobbles)
|
||||||
logger.info(f"Created {len(created)} birding scrobbles")
|
logger.info(f"Created {len(created)} birding scrobbles")
|
||||||
|
for scrobble in created:
|
||||||
|
ScrobbleNtfyNotification(scrobble).send()
|
||||||
return created
|
return created
|
||||||
|
|||||||
@ -83,43 +83,110 @@ class BaseLogData(JSONDataclass):
|
|||||||
|
|
||||||
return ""
|
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:
|
def notes_as_html(self) -> str:
|
||||||
import html
|
import bleach
|
||||||
|
import markdown
|
||||||
from django.utils.safestring import mark_safe
|
from django.utils.safestring import mark_safe
|
||||||
|
|
||||||
if not self.notes:
|
if not self.notes:
|
||||||
return ""
|
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):
|
if isinstance(self.notes, dict):
|
||||||
for ts, text in self.notes.items():
|
for ts, text in sorted(self.notes.items(), reverse=True):
|
||||||
note_text = " ".join(text.strip().split())
|
note_items.append((ts, text.strip()))
|
||||||
html_notes.append(
|
|
||||||
f'<div class="sticky-note">{ts}: {note_text}</div>'
|
|
||||||
)
|
|
||||||
elif isinstance(self.notes, str):
|
elif isinstance(self.notes, str):
|
||||||
for line in self.notes.split("\n"):
|
for line in self.notes.split("\n"):
|
||||||
if line.strip():
|
if line.strip():
|
||||||
escaped_line = html.escape(line)
|
note_items.append((None, line.strip()))
|
||||||
html_notes.append(f'<div class="sticky-note">{escaped_line}</div>')
|
|
||||||
elif isinstance(self.notes, list):
|
elif isinstance(self.notes, list):
|
||||||
for note in self.notes:
|
for note in self.notes:
|
||||||
if isinstance(note, dict):
|
if isinstance(note, dict):
|
||||||
timestamp, note_text = next(iter(note.items()))
|
timestamp, note_text = next(iter(note.items()))
|
||||||
note_text = " ".join(note_text.strip().split())
|
note_items.append((timestamp, note_text.strip()))
|
||||||
html_notes.append(
|
|
||||||
f'<div class="sticky-note">{timestamp}: {note_text}</div>'
|
|
||||||
)
|
|
||||||
elif isinstance(note, str):
|
elif isinstance(note, str):
|
||||||
for line in note.split("\n"):
|
for line in note.split("\n"):
|
||||||
if line.strip():
|
if line.strip():
|
||||||
escaped_line = html.escape(line)
|
note_items.append((None, line.strip()))
|
||||||
html_notes.append(
|
|
||||||
f'<div class="sticky-note">{escaped_line}</div>'
|
|
||||||
)
|
|
||||||
|
|
||||||
return mark_safe("".join(html_notes))
|
html_parts = []
|
||||||
|
for i, (ts, text) in enumerate(note_items):
|
||||||
|
if i > 0:
|
||||||
|
html_parts.append('<hr class="note-divider">')
|
||||||
|
|
||||||
|
ts_html = ""
|
||||||
|
if ts:
|
||||||
|
ts_html = f'<h5 class="note-timestamp">{self._format_timestamp(ts)}</h5>'
|
||||||
|
|
||||||
|
content_html = bleach.clean(
|
||||||
|
md.convert(text),
|
||||||
|
tags=allowed_tags,
|
||||||
|
strip=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
html_parts.append(
|
||||||
|
f'<div class="note-item">{ts_html}<div class="note-content">{content_html}</div></div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
return mark_safe("".join(html_parts))
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|||||||
@ -5,6 +5,7 @@ from datetime import datetime, timedelta
|
|||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
from scrobbles.models import Scrobble
|
from scrobbles.models import Scrobble
|
||||||
|
from scrobbles.notifications import ScrobbleNtfyNotification
|
||||||
from tasks.models import Task
|
from tasks.models import Task
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -85,6 +86,11 @@ def import_scale_csv(file_path, user_id):
|
|||||||
|
|
||||||
log_dict["unit_type"] = user.profile.weigh_in_units
|
log_dict["unit_type"] = user.profile.weigh_in_units
|
||||||
|
|
||||||
|
weight_val = log_dict.get("weight")
|
||||||
|
if weight_val is not None:
|
||||||
|
unit = "kg" if user.profile.weigh_in_units == "metric" else "lbs"
|
||||||
|
log_dict["title"] = f"{weight_val} {unit}"
|
||||||
|
|
||||||
existing = Scrobble.objects.filter(
|
existing = Scrobble.objects.filter(
|
||||||
timestamp=start,
|
timestamp=start,
|
||||||
task=weigh_in,
|
task=weigh_in,
|
||||||
@ -110,4 +116,6 @@ def import_scale_csv(file_path, user_id):
|
|||||||
|
|
||||||
created = Scrobble.objects.bulk_create(new_scrobbles)
|
created = Scrobble.objects.bulk_create(new_scrobbles)
|
||||||
logger.info(f"Created {len(created)} weigh-in scrobbles")
|
logger.info(f"Created {len(created)} weigh-in scrobbles")
|
||||||
|
for scrobble in created:
|
||||||
|
ScrobbleNtfyNotification(scrobble).send()
|
||||||
return created
|
return created
|
||||||
|
|||||||
@ -12,6 +12,7 @@ from django.core.files import File
|
|||||||
|
|
||||||
from locations.models import GeoLocation
|
from locations.models import GeoLocation
|
||||||
from scrobbles.models import Scrobble
|
from scrobbles.models import Scrobble
|
||||||
|
from scrobbles.notifications import ScrobbleNtfyNotification
|
||||||
from trails.models import Trail, TrailLogData
|
from trails.models import Trail, TrailLogData
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -328,4 +329,6 @@ def import_trail_gpx(file_path, user_id, original_filename=None):
|
|||||||
|
|
||||||
created = Scrobble.objects.bulk_create(new_scrobbles)
|
created = Scrobble.objects.bulk_create(new_scrobbles)
|
||||||
logger.info(f"Created {len(created)} trail scrobbles")
|
logger.info(f"Created {len(created)} trail scrobbles")
|
||||||
|
for scrobble in created:
|
||||||
|
ScrobbleNtfyNotification(scrobble).send()
|
||||||
return created
|
return created
|
||||||
|
|||||||
@ -1529,7 +1529,12 @@ class Scrobble(TimeStampedModel):
|
|||||||
scrobble_data: dict,
|
scrobble_data: dict,
|
||||||
) -> "Scrobble":
|
) -> "Scrobble":
|
||||||
scrobble = cls.objects.create(**scrobble_data)
|
scrobble = cls.objects.create(**scrobble_data)
|
||||||
ScrobbleNtfyNotification(scrobble).send()
|
if not (
|
||||||
|
scrobble.media_type == cls.MediaType.GEO_LOCATION
|
||||||
|
and scrobble.media_obj
|
||||||
|
and not scrobble.media_obj.title
|
||||||
|
):
|
||||||
|
ScrobbleNtfyNotification(scrobble).send()
|
||||||
return scrobble
|
return scrobble
|
||||||
|
|
||||||
def stop(self, timestamp=None, force_finish=False) -> None:
|
def stop(self, timestamp=None, force_finish=False) -> None:
|
||||||
|
|||||||
@ -562,7 +562,6 @@ def email_scrobble_board_game(
|
|||||||
scrobble.played_to_completion = True
|
scrobble.played_to_completion = True
|
||||||
scrobble.save()
|
scrobble.save()
|
||||||
scrobbles_created.append(scrobble)
|
scrobbles_created.append(scrobble)
|
||||||
ScrobbleNtfyNotification(scrobble).send()
|
|
||||||
|
|
||||||
return scrobbles_created
|
return scrobbles_created
|
||||||
|
|
||||||
|
|||||||
@ -76,33 +76,62 @@ class TaskLogData(BaseLogData):
|
|||||||
return separator.join(lines).encode("utf-8").decode("unicode_escape")
|
return separator.join(lines).encode("utf-8").decode("unicode_escape")
|
||||||
|
|
||||||
def notes_as_html(self) -> str:
|
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:
|
if not self.notes:
|
||||||
return ""
|
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
|
notes = self.notes
|
||||||
if isinstance(notes, dict):
|
if isinstance(notes, dict):
|
||||||
notes = [{k: v} for k, v in notes.items()]
|
notes = [{k: v} for k, v in notes.items()]
|
||||||
if isinstance(notes, str):
|
if isinstance(notes, str):
|
||||||
notes = [notes]
|
notes = [notes]
|
||||||
|
|
||||||
html_notes = []
|
note_items = []
|
||||||
for note in notes:
|
for note in notes:
|
||||||
if isinstance(note, dict):
|
if isinstance(note, dict):
|
||||||
timestamp, note_text = next(iter(note.items()))
|
timestamp, note_text = next(iter(note.items()))
|
||||||
note_text = " ".join(note_text.strip().split())
|
note_items.append((timestamp, note_text.strip()))
|
||||||
html_notes.append(
|
|
||||||
f'<div class="sticky-note">{timestamp}: {note_text}</div>'
|
|
||||||
)
|
|
||||||
elif isinstance(note, str):
|
elif isinstance(note, str):
|
||||||
escaped = note.encode("utf-8").decode("unicode_escape")
|
escaped = note.encode("utf-8").decode("unicode_escape")
|
||||||
for line in escaped.split("\n"):
|
for line in escaped.split("\n"):
|
||||||
if line.strip():
|
if line.strip():
|
||||||
html_notes.append(f'<div class="sticky-note">{line}</div>')
|
note_items.append((None, line.strip()))
|
||||||
elif isinstance(note, list):
|
elif isinstance(note, list):
|
||||||
for item in note:
|
for item in note:
|
||||||
if isinstance(item, str):
|
if isinstance(item, str):
|
||||||
html_notes.append(f'<div class="sticky-note">{item}</div>')
|
note_items.append((None, item.strip()))
|
||||||
return "".join(html_notes)
|
|
||||||
|
html_parts = []
|
||||||
|
for i, (ts, text) in enumerate(note_items):
|
||||||
|
if i > 0:
|
||||||
|
html_parts.append('<hr class="note-divider">')
|
||||||
|
|
||||||
|
ts_html = ""
|
||||||
|
if ts:
|
||||||
|
ts_html = f'<h5 class="note-timestamp">{BaseLogData._format_timestamp(ts)}</h5>'
|
||||||
|
|
||||||
|
content_html = bleach.clean(
|
||||||
|
md.convert(text),
|
||||||
|
tags=allowed_tags,
|
||||||
|
strip=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
html_parts.append(
|
||||||
|
f'<div class="note-item">{ts_html}<div class="note-content">{content_html}</div></div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
return mark_safe("".join(html_parts))
|
||||||
|
|
||||||
|
|
||||||
class Task(LongPlayScrobblableMixin):
|
class Task(LongPlayScrobblableMixin):
|
||||||
|
|||||||
@ -168,6 +168,35 @@
|
|||||||
}
|
}
|
||||||
#scrobble-form { width: 100% }
|
#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 {
|
.sticky-notes-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@ -218,6 +247,9 @@
|
|||||||
a:not(.nav-link):not(.btn):not(.page-link):hover {
|
a:not(.nav-link):not(.btn):not(.page-link):hover {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
a.badge {
|
||||||
|
color: #fff !important;
|
||||||
|
}
|
||||||
.table-striped > tbody > tr:nth-of-type(odd) {
|
.table-striped > tbody > tr:nth-of-type(odd) {
|
||||||
background-color: color-mix(in srgb, var(--accent) 6%, #fff);
|
background-color: color-mix(in srgb, var(--accent) 6%, #fff);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -258,7 +258,7 @@
|
|||||||
{% for chart in charts.trail|slice:":20" %}
|
{% for chart in charts.trail|slice:":20" %}
|
||||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||||
<span class="me-2"><strong>#{{chart.rank}}</strong></span>
|
<span class="me-2"><strong>#{{chart.rank}}</strong></span>
|
||||||
<a href="{{chart.trail.get_absolute_url}}">{{chart.trail.name}}</a>
|
<a href="{{chart.trail.get_absolute_url}}">{{chart.trail.title}}</a>
|
||||||
<span class="badge bg-primary rounded-pill">{{chart.count}}</span>
|
<span class="badge bg-primary rounded-pill">{{chart.count}}</span>
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@ -115,8 +115,8 @@
|
|||||||
{% with notes_html=object.logdata.notes_as_html %}
|
{% with notes_html=object.logdata.notes_as_html %}
|
||||||
{% if notes_html %}
|
{% if notes_html %}
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<h5>Notes</h5>
|
<h4>Notes</h4>
|
||||||
<div class="sticky-notes-container">
|
<div class="notes-list">
|
||||||
{{ notes_html|safe }}
|
{{ notes_html|safe }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user