diff --git a/PROJECT.org b/PROJECT.org
index f031b5d..7a437e8 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 [2/18] :vrobbler:project:personal:
+* Backlog [3/18] :vrobbler:project:personal:
** TODO [#C] Add sentiment parsing for Scrobbles with notes :vrobbler:project:scrobbles:sentiment:
:PROPERTIES:
:ID: 37781d6a-f3b0-48b2-bf98-33c2c791cf85
@@ -480,15 +480,50 @@ whatever time KoReader reports, we need to know, given the date and the user
profile's historic timezone, how many hours to adjust the KoReader time to get
to GMT to save it in the database.
-** TODO [#B] When creating org-mode tasks, don't copy comments :vrobbler:bug:scrobbles:tasks:
+** DONE [#B] Clean up org-mode tasks metadata :bug:tasks:metadata:
:PROPERTIES:
:ID: 0c762d09-fc69-4e75-be40-7eaaf04f178e
:END:
*** Description
-It would be nice to not duplicate comments that exist on a task when it's first scrobbled.
+Org-mode tasks have a `Description` subheader, which should populate the
+"Description" of a task.
+The title should come from the actual TODO content, with tags coming from the
+tags in colons after the task. This behaviour should already work.
+
+Next, any comments should go under a `Comments` subheader. Under these should be a list tag like:
+
+"Note taken on [2026-05-31 Sun 20:03]"
+
+Which declares it's a note with a timestamp like "YYYY-MM-DD d HH:MM".
+
+When scrobbling a task from org-mode, the description should always be copied to
+the scrobble's log["description"].
+
+Any comments that exist on the task when the scrobble is first created, should
+be ignored.
+
+Any comments on a task that is updated (a re-POST'd while the task is in
+progress, should add a comment in the log["notes"], a dictionary, keyed off the
+timestamp the note string. If a note for that datetime already exists, the
+content should be replaced with the new comment.
+
+Additionally, when a task is re-POST'd while the task is in progress, the
+description should also be compared, and if different, the newest description
+should be used.
+
+Note, the biggest change for this flow is making sure comments that already
+exist are not added to any new tasks and that both comments and descriptions are
+added or updated if the new values are different than what is already in the
+currently in-progress task.
+
+If the task is completed, don't touch it.
+
+*** Comments
+- Note taken on [2026-05-31 Sun 20:03]
+
** DONE [#A] Actually push branches up and add a just command to do it :release:justfile:tooling:
:PROPERTIES:
:ID: 50aa5daa-a802-6aa9-38a3-218b7a9d4b34
diff --git a/vrobbler/apps/boardgames/models.py b/vrobbler/apps/boardgames/models.py
index 81c894d..89a984b 100644
--- a/vrobbler/apps/boardgames/models.py
+++ b/vrobbler/apps/boardgames/models.py
@@ -90,6 +90,26 @@ class BoardGameLogData(BaseLogData, LongPlayLogData):
"variant",
}
+ @classmethod
+ def override_fields(cls) -> dict:
+ from scrobbles.forms import NotesDictField
+
+ fields = {}
+ for base in cls.mro()[1:]:
+ if hasattr(base, "override_fields"):
+ base_fields = base.override_fields()
+ fields.update(base_fields)
+ custom_fields = {
+ "notes": NotesDictField(required=False),
+ "location_id": forms.ModelChoiceField(
+ queryset=BoardGameLocation.objects.all(),
+ required=False,
+ widget=forms.Select(),
+ ),
+ }
+ fields.update(custom_fields)
+ return fields
+
@cached_property
def location(self):
if not self.location_id:
@@ -133,23 +153,6 @@ class BoardGameLogData(BaseLogData, LongPlayLogData):
return "".join(html_parts)
- @classmethod
- def override_fields(cls) -> dict:
- fields = {}
- for base in cls.mro()[1:]:
- if hasattr(base, "override_fields"):
- base_fields = base.override_fields()
- fields.update(base_fields)
- custom_fields = {
- "location_id": forms.ModelChoiceField(
- queryset=BoardGameLocation.objects.all(),
- required=False,
- widget=forms.Select(),
- )
- }
- fields.update(custom_fields)
- return fields
-
class BoardGamePublisher(TimeStampedModel):
name = models.CharField(max_length=255)
diff --git a/vrobbler/apps/scrobbles/dataclasses.py b/vrobbler/apps/scrobbles/dataclasses.py
index f9f582a..4f326a5 100644
--- a/vrobbler/apps/scrobbles/dataclasses.py
+++ b/vrobbler/apps/scrobbles/dataclasses.py
@@ -57,14 +57,28 @@ class BaseLogData(JSONDataclass):
if not self.notes:
return ""
+ if isinstance(self.notes, dict):
+ lines = []
+ for ts, text in self.notes.items():
+ note_text = " ".join(text.strip().split())
+ lines.append(f"{ts}: {note_text}")
+ return separator.join(lines)
+
if isinstance(self.notes, str):
return html.escape(re.sub(r"\s*\[start:\d+\|end:\d+\]$", "", self.notes))
if isinstance(self.notes, list):
cleaned_notes = []
for note in self.notes:
- cleaned = html.escape(re.sub(r"\s*\[start:\d+\|end:\d+\]$", "", note))
- cleaned_notes.append(cleaned)
+ if isinstance(note, dict):
+ timestamp, note_text = next(iter(note.items()))
+ note_text = " ".join(note_text.strip().split())
+ cleaned_notes.append(f"{timestamp}: {note_text}")
+ elif isinstance(note, str):
+ cleaned = html.escape(
+ re.sub(r"\s*\[start:\d+\|end:\d+\]$", "", note)
+ )
+ cleaned_notes.append(cleaned)
return separator.join(cleaned_notes)
return ""
@@ -76,18 +90,35 @@ class BaseLogData(JSONDataclass):
if not self.notes:
return ""
- notes_list = []
- if isinstance(self.notes, str):
- notes_list = [self.notes]
- elif isinstance(self.notes, list):
- notes_list = self.notes
-
html_notes = []
- for note in notes_list:
- for line in note.split("\n"):
+
+ 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}
'
+ )
+ 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}
')
+ 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}
'
+ )
+ elif isinstance(note, str):
+ for line in note.split("\n"):
+ if line.strip():
+ escaped_line = html.escape(line)
+ html_notes.append(
+ f'
{escaped_line}
'
+ )
+
return mark_safe("".join(html_notes))
diff --git a/vrobbler/apps/scrobbles/forms.py b/vrobbler/apps/scrobbles/forms.py
index 6ea5e01..2d14df4 100644
--- a/vrobbler/apps/scrobbles/forms.py
+++ b/vrobbler/apps/scrobbles/forms.py
@@ -1,3 +1,4 @@
+import json
from dataclasses import fields
from typing import Union, get_args, get_origin
@@ -89,7 +90,7 @@ def form_from_dataclass(dataclass):
form_cls = type(f"{dataclass.__name__}Form", (forms.Form,), form_fields)
- if "notes" in form_cls.base_fields:
+ if "notes" in form_cls.base_fields and "notes" not in override_fields:
form_cls.base_fields["notes"] = forms.CharField(
required=False,
widget=forms.Textarea(attrs={"rows": 4}),
@@ -101,3 +102,47 @@ def form_from_dataclass(dataclass):
form_cls.clean_notes = clean_notes
return form_cls
+
+
+class NotesDictWidget(forms.Widget):
+ template_name = "tasks/task_notes_widget.html"
+
+ class Media:
+ js = ("tasks/task_notes.js",)
+
+ def value_from_datadict(self, data, files, name):
+ timestamps = data.getlist(f"{name}_timestamps")
+ contents = data.getlist(f"{name}_contents")
+ return {
+ "timestamps": timestamps,
+ "contents": contents,
+ }
+
+ def get_context(self, name, value, attrs):
+ context = super().get_context(name, value, attrs)
+ notes = {}
+ if value:
+ if isinstance(value, str):
+ try:
+ notes = json.loads(value)
+ except (json.JSONDecodeError, TypeError):
+ notes = {}
+ elif isinstance(value, dict):
+ notes = value
+ context["widget"]["notes"] = notes
+ return context
+
+
+class NotesDictField(forms.Field):
+ widget = NotesDictWidget
+
+ def clean(self, value):
+ if not value:
+ return {}
+ result = {}
+ timestamps = value.get("timestamps", [])
+ contents = value.get("contents", [])
+ for i, ts in enumerate(timestamps):
+ if i < len(contents) and ts and contents[i].strip():
+ result[ts] = contents[i].strip()
+ return result if result else {}
diff --git a/vrobbler/apps/scrobbles/management/commands/convert_task_log_data.py b/vrobbler/apps/scrobbles/management/commands/convert_task_log_data.py
index 6e1246f..43a0d70 100644
--- a/vrobbler/apps/scrobbles/management/commands/convert_task_log_data.py
+++ b/vrobbler/apps/scrobbles/management/commands/convert_task_log_data.py
@@ -1,9 +1,7 @@
from django.core.management.base import BaseCommand
from vrobbler.apps.tasks.utils import (
- convert_notes_to_dict,
convert_old_boardgame_log_to_new,
- convert_old_orgmode_log_to_new,
- convert_old_todoist_log_to_new,
+ convert_tasks_notes_list_to_dict,
)
@@ -21,7 +19,5 @@ class Command(BaseCommand):
commit = True
else:
print("No changes will be saved, use --commit to save")
- convert_old_orgmode_log_to_new(commit)
- convert_old_todoist_log_to_new(commit)
- convert_notes_to_dict(commit)
+ convert_tasks_notes_list_to_dict(commit)
convert_old_boardgame_log_to_new(commit)
diff --git a/vrobbler/apps/scrobbles/scrobblers.py b/vrobbler/apps/scrobbles/scrobblers.py
index 529ec13..591ed2d 100644
--- a/vrobbler/apps/scrobbles/scrobblers.py
+++ b/vrobbler/apps/scrobbles/scrobblers.py
@@ -450,11 +450,11 @@ def email_scrobble_board_game(
locations[location_dict.get("id")] = location
scrobbles_created = []
- second = 0
for play_dict in bgstat_data.get("plays", []):
hour = None
minute = None
second = None
+ comments = None
if "comments" in play_dict.keys():
for line in play_dict.get("comments", "").split("\n"):
if "Learning to play" in line:
@@ -469,7 +469,7 @@ def email_scrobble_board_game(
except IndexError:
second = 0
- log_data["notes"] = [play_dict.get("comments")]
+ comments = play_dict.get("comments")
log_data["expansion_ids"] = []
try:
base_game = base_games[play_dict.get("gameRefId")]
@@ -527,6 +527,9 @@ def email_scrobble_board_game(
duration_seconds = base_game.run_time_seconds
stop_timestamp = timestamp + timedelta(seconds=duration_seconds)
+ if comments:
+ log_data["notes"] = {str(int(stop_timestamp.timestamp())): comments}
+
logger.info(f"Creating scrobble for {base_game} at {timestamp}")
log_data["raw_data"] = bgstat_data
scrobble_dict = {
@@ -685,9 +688,10 @@ def todoist_scrobble_update_task(
)
return
+ timestamp = todoist_note.get("posted_at") or str(int(timezone.now().timestamp()))
if not scrobble.log.get("notes"):
- scrobble.log["notes"] = []
- scrobble.log["notes"].append(todoist_note.get("notes"))
+ scrobble.log["notes"] = {}
+ scrobble.log["notes"][timestamp] = todoist_note.get("notes")
scrobble.save(update_fields=["log"])
logger.info(
"[todoist_scrobble_update_task] todoist note added",
@@ -783,7 +787,10 @@ def todoist_scrobble_task(
def emacs_scrobble_update_task(
- emacs_id: str, emacs_notes: dict, user_id: int
+ emacs_id: str,
+ emacs_notes: list,
+ user_id: int,
+ description: Optional[str] = None,
) -> Optional[Scrobble]:
scrobble = Scrobble.objects.filter(
in_progress=True,
@@ -803,27 +810,30 @@ def emacs_scrobble_update_task(
)
return
- notes_updated = False
+ log_updated = False
+
+ if not scrobble.log.get("notes"):
+ scrobble.log["notes"] = {}
+
for note in emacs_notes:
- try:
- existing_note_ts = [
- n.get("timestamp") for n in scrobble.log.get("notes", [])
- ]
- except AttributeError:
- existing_note_ts = []
- if not scrobble.log.get('notes"'):
- scrobble.log["notes"] = []
- if note.get("timestamp") not in existing_note_ts:
- scrobble.log["notes"].append({note.get("timestamp"): note.get("content")})
- notes_updated = True
+ timestamp = note.get("timestamp")
+ content = note.get("content")
+ if timestamp:
+ existing = scrobble.log["notes"].get(timestamp)
+ if existing != content:
+ scrobble.log["notes"][timestamp] = content
+ log_updated = True
- if notes_updated:
+ if description is not None and scrobble.log.get("description") != description:
+ scrobble.log["description"] = description
+ log_updated = True
+
+ if log_updated:
scrobble.save(update_fields=["log"])
-
logger.info(
- "[emacs_scrobble_update_task] emacs note added",
+ "[emacs_scrobble_update_task] emacs scrobble updated",
extra={
- "emacs_note": emacs_notes,
+ "emacs_id": emacs_id,
"user_id": user_id,
"media_type": Scrobble.MediaType.TASK,
},
@@ -865,7 +875,7 @@ def emacs_scrobble_task(
logger.info(
"[emacs_scrobble_task] cannot start already started task",
extra={
- "ormode_id": orgmode_id,
+ "orgmode_id": orgmode_id,
},
)
return in_progress_scrobble
@@ -884,9 +894,7 @@ def emacs_scrobble_task(
if in_progress_scrobble:
return in_progress_scrobble
- notes = task_data.pop("notes")
- if notes:
- task_data["notes"] = [note.get("content") for note in notes]
+ task_data.pop("notes", None)
task_data["title"] = task_data.pop("description")
task_data["description"] = task_data.pop("body")
task_data["labels"] = task_data.pop("labels")
diff --git a/vrobbler/apps/scrobbles/views.py b/vrobbler/apps/scrobbles/views.py
index e2f539e..82b8bbc 100644
--- a/vrobbler/apps/scrobbles/views.py
+++ b/vrobbler/apps/scrobbles/views.py
@@ -1024,7 +1024,11 @@ class ScrobbleDetailView(DetailView):
FormClass = self.get_form_class()
log = self.object.log or {}
- log["notes"] = self.object.logdata.notes_as_str(separator="\n")
+ notes = log.get("notes")
+ if isinstance(notes, dict):
+ log["notes"] = notes
+ else:
+ log["notes"] = self.object.logdata.notes_as_str(separator="\n")
return FormClass(initial=log)
diff --git a/vrobbler/apps/tasks/forms.py b/vrobbler/apps/tasks/forms.py
new file mode 100644
index 0000000..8eae30f
--- /dev/null
+++ b/vrobbler/apps/tasks/forms.py
@@ -0,0 +1,47 @@
+import json
+
+from django import forms
+
+
+class TaskNotesWidget(forms.Widget):
+ template_name = "tasks/task_notes_widget.html"
+
+ class Media:
+ js = ("tasks/task_notes.js",)
+
+ def value_from_datadict(self, data, files, name):
+ timestamps = data.getlist(f"{name}_timestamps")
+ contents = data.getlist(f"{name}_contents")
+ return {
+ "timestamps": timestamps,
+ "contents": contents,
+ }
+
+ def get_context(self, name, value, attrs):
+ context = super().get_context(name, value, attrs)
+ notes = {}
+ if value:
+ if isinstance(value, str):
+ try:
+ notes = json.loads(value)
+ except (json.JSONDecodeError, TypeError):
+ notes = {}
+ elif isinstance(value, dict):
+ notes = value
+ context["widget"]["notes"] = notes
+ return context
+
+
+class TaskNotesField(forms.Field):
+ widget = TaskNotesWidget
+
+ def clean(self, value):
+ if not value:
+ return {}
+ result = {}
+ timestamps = value.get("timestamps", [])
+ contents = value.get("contents", [])
+ for i, ts in enumerate(timestamps):
+ if i < len(contents) and ts and contents[i].strip():
+ result[ts] = contents[i].strip()
+ return result if result else {}
diff --git a/vrobbler/apps/tasks/models.py b/vrobbler/apps/tasks/models.py
index ecc3fc0..51512c1 100644
--- a/vrobbler/apps/tasks/models.py
+++ b/vrobbler/apps/tasks/models.py
@@ -38,6 +38,18 @@ class TaskLogData(BaseLogData):
"todoist_project_id",
}
+ @classmethod
+ def override_fields(cls) -> dict:
+ from scrobbles.forms import NotesDictField
+
+ fields = {}
+ for base in cls.mro()[1:]:
+ if hasattr(base, "override_fields"):
+ base_fields = base.override_fields()
+ fields.update(base_fields)
+ fields["notes"] = NotesDictField(required=False)
+ return fields
+
def notes_as_str(self, separator: str = " | ") -> str:
"""Return formatted notes with line breaks and no keys"""
labels_str = ""
@@ -47,13 +59,14 @@ class TaskLogData(BaseLogData):
lines = []
if self.notes:
notes = self.notes
+ if isinstance(notes, dict):
+ notes = [{k: v} for k, v in notes.items()]
if isinstance(notes, str):
notes = [notes]
for note in notes:
if isinstance(note, dict):
timestamp, note_text = next(iter(note.items()))
- # Flatten newlines and clean whitespace
note_text = " ".join(note_text.strip().split())
lines.append(f"{timestamp}: {note_text}")
if isinstance(note, list):
@@ -67,6 +80,8 @@ class TaskLogData(BaseLogData):
return ""
notes = self.notes
+ if isinstance(notes, dict):
+ notes = [{k: v} for k, v in notes.items()]
if isinstance(notes, str):
notes = [notes]
diff --git a/vrobbler/apps/tasks/static/tasks/task_notes.js b/vrobbler/apps/tasks/static/tasks/task_notes.js
new file mode 100644
index 0000000..fd1ec25
--- /dev/null
+++ b/vrobbler/apps/tasks/static/tasks/task_notes.js
@@ -0,0 +1,36 @@
+(function() {
+ function addNoteRow(container, widgetName, timestamp, content) {
+ var row = document.createElement('div');
+ row.className = 'task-note-row row mb-2 align-items-start';
+ row.innerHTML =
+ '
' +
+ '' +
+ '
' +
+ '
' +
+ '' +
+ '
' +
+ '
' +
+ '' +
+ '
';
+ container.appendChild(row);
+ }
+
+ document.addEventListener('click', function(e) {
+ var addBtn = e.target.closest('.add-note');
+ if (addBtn) {
+ e.preventDefault();
+ var container = document.getElementById('task-notes-container');
+ var now = Math.floor(Date.now() / 1000);
+ var widgetName = addBtn.getAttribute('data-widget-name');
+ addNoteRow(container, widgetName, String(now), '');
+ }
+ });
+
+ document.addEventListener('click', function(e) {
+ var removeBtn = e.target.closest('.remove-note');
+ if (removeBtn) {
+ e.preventDefault();
+ removeBtn.closest('.task-note-row').remove();
+ }
+ });
+})();
diff --git a/vrobbler/apps/tasks/templates/tasks/task_notes_widget.html b/vrobbler/apps/tasks/templates/tasks/task_notes_widget.html
new file mode 100644
index 0000000..1c23328
--- /dev/null
+++ b/vrobbler/apps/tasks/templates/tasks/task_notes_widget.html
@@ -0,0 +1,17 @@
+{% load static %}
+
+ {% for timestamp, content in widget.notes.items %}
+
+
+
+
+
+
+
+
+
+
+
+ {% endfor %}
+
+
diff --git a/vrobbler/apps/tasks/utils.py b/vrobbler/apps/tasks/utils.py
index a5d9221..48bc002 100644
--- a/vrobbler/apps/tasks/utils.py
+++ b/vrobbler/apps/tasks/utils.py
@@ -83,18 +83,50 @@ def convert_notes_to_dict(commit=False):
scrobble.log["notes"] = [
value for d in note_list for value in d.values()
]
+ else:
+ scrobble.log["notes"] = note_list
count += 1
if commit:
scrobble.save(update_fields=["log"])
print(f"Updated {count} todoist tasks scrobbles")
+def convert_tasks_notes_list_to_dict(commit=False):
+ scrobbles = Scrobble.objects.filter(task__isnull=False, log__notes__isnull=False)
+ count = 0
+ for scrobble in scrobbles:
+ notes = scrobble.log.get("notes")
+ if isinstance(notes, list):
+ key = str(int(scrobble.timestamp.timestamp()) + 10)
+ parts = []
+ for note in notes:
+ if isinstance(note, dict):
+ parts.append(next(iter(note.values()), ""))
+ elif isinstance(note, list):
+ parts.extend(str(x) for x in note)
+ else:
+ parts.append(str(note))
+ scrobble.log["notes"] = {key: "\n".join(parts)}
+ count += 1
+ if commit:
+ scrobble.save(update_fields=["log"])
+ print(f"Updated {count} task scrobbles notes from list to dict")
+
+
def convert_old_boardgame_log_to_new(commit=False):
scrobbles = Scrobble.objects.filter(board_game__isnull=False, log__has_key="notes")
+ count = 0
for scrobble in scrobbles:
- if isinstance(scrobble.log.get("notes"), str):
- scrobble.log["notes"] = [scrobble.log.pop("notes")]
-
+ notes = scrobble.log.get("notes")
+ if isinstance(notes, str):
+ scrobble.log["notes"] = [notes]
+ notes = [notes]
+ if isinstance(notes, list):
+ key_ts = scrobble.stop_timestamp or scrobble.timestamp
+ scrobble.log["notes"] = {str(int(key_ts.timestamp())): "\n".join(
+ str(n) for n in notes
+ )}
+ count += 1
if commit:
scrobble.save(update_fields=["log"])
- print(f"Updated {scrobbles.count()} board game scrobbles")
+ print(f"Updated {count} board game scrobbles")
diff --git a/vrobbler/apps/tasks/webhooks.py b/vrobbler/apps/tasks/webhooks.py
index d8fd563..0c2b7eb 100644
--- a/vrobbler/apps/tasks/webhooks.py
+++ b/vrobbler/apps/tasks/webhooks.py
@@ -136,6 +136,7 @@ class TodoistWebhookView(APIView):
"todoist_type": todoist_type,
"todoist_event": todoist_event,
"updated_at": task_data.get("updated_at"),
+ "posted_at": event_data.get("posted_at"),
"details": task_data.get("description"),
"notes": event_data.get("content"),
"is_deleted": (
@@ -231,11 +232,12 @@ class EmacsWebhookView(APIView):
status=status.HTTP_304_NOT_MODIFIED,
)
- if task_in_progress and post_data.get("notes"):
+ if task_in_progress:
emacs_scrobble_update_task(
post_data.get("source_id"),
- post_data.get("notes"),
+ post_data.get("notes", []),
user_id,
+ description=post_data.get("body"),
)
self.logger.info(