[scrobbles] Big update for notes in tasks and boardgames
This commit is contained in:
41
PROJECT.org
41
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 [2/18] :vrobbler:project:personal:
|
* Backlog [3/18] :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
|
||||||
@ -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
|
profile's historic timezone, how many hours to adjust the KoReader time to get
|
||||||
to GMT to save it in the database.
|
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:
|
:PROPERTIES:
|
||||||
:ID: 0c762d09-fc69-4e75-be40-7eaaf04f178e
|
:ID: 0c762d09-fc69-4e75-be40-7eaaf04f178e
|
||||||
:END:
|
:END:
|
||||||
|
|
||||||
*** Description
|
*** 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:
|
** DONE [#A] Actually push branches up and add a just command to do it :release:justfile:tooling:
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
:ID: 50aa5daa-a802-6aa9-38a3-218b7a9d4b34
|
:ID: 50aa5daa-a802-6aa9-38a3-218b7a9d4b34
|
||||||
|
|||||||
@ -90,6 +90,26 @@ class BoardGameLogData(BaseLogData, LongPlayLogData):
|
|||||||
"variant",
|
"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
|
@cached_property
|
||||||
def location(self):
|
def location(self):
|
||||||
if not self.location_id:
|
if not self.location_id:
|
||||||
@ -133,23 +153,6 @@ class BoardGameLogData(BaseLogData, LongPlayLogData):
|
|||||||
|
|
||||||
return "".join(html_parts)
|
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):
|
class BoardGamePublisher(TimeStampedModel):
|
||||||
name = models.CharField(max_length=255)
|
name = models.CharField(max_length=255)
|
||||||
|
|||||||
@ -57,14 +57,28 @@ class BaseLogData(JSONDataclass):
|
|||||||
if not self.notes:
|
if not self.notes:
|
||||||
return ""
|
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):
|
if isinstance(self.notes, str):
|
||||||
return html.escape(re.sub(r"\s*\[start:\d+\|end:\d+\]$", "", self.notes))
|
return html.escape(re.sub(r"\s*\[start:\d+\|end:\d+\]$", "", self.notes))
|
||||||
|
|
||||||
if isinstance(self.notes, list):
|
if isinstance(self.notes, list):
|
||||||
cleaned_notes = []
|
cleaned_notes = []
|
||||||
for note in self.notes:
|
for note in self.notes:
|
||||||
cleaned = html.escape(re.sub(r"\s*\[start:\d+\|end:\d+\]$", "", note))
|
if isinstance(note, dict):
|
||||||
cleaned_notes.append(cleaned)
|
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 separator.join(cleaned_notes)
|
||||||
|
|
||||||
return ""
|
return ""
|
||||||
@ -76,18 +90,35 @@ class BaseLogData(JSONDataclass):
|
|||||||
if not self.notes:
|
if not self.notes:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
notes_list = []
|
|
||||||
if isinstance(self.notes, str):
|
|
||||||
notes_list = [self.notes]
|
|
||||||
elif isinstance(self.notes, list):
|
|
||||||
notes_list = self.notes
|
|
||||||
|
|
||||||
html_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'<div class="sticky-note">{ts}: {note_text}</div>'
|
||||||
|
)
|
||||||
|
elif isinstance(self.notes, str):
|
||||||
|
for line in self.notes.split("\n"):
|
||||||
if line.strip():
|
if line.strip():
|
||||||
escaped_line = html.escape(line)
|
escaped_line = html.escape(line)
|
||||||
html_notes.append(f'<div class="sticky-note">{escaped_line}</div>')
|
html_notes.append(f'<div class="sticky-note">{escaped_line}</div>')
|
||||||
|
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'<div class="sticky-note">{timestamp}: {note_text}</div>'
|
||||||
|
)
|
||||||
|
elif isinstance(note, str):
|
||||||
|
for line in note.split("\n"):
|
||||||
|
if line.strip():
|
||||||
|
escaped_line = html.escape(line)
|
||||||
|
html_notes.append(
|
||||||
|
f'<div class="sticky-note">{escaped_line}</div>'
|
||||||
|
)
|
||||||
|
|
||||||
return mark_safe("".join(html_notes))
|
return mark_safe("".join(html_notes))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import json
|
||||||
from dataclasses import fields
|
from dataclasses import fields
|
||||||
from typing import Union, get_args, get_origin
|
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)
|
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(
|
form_cls.base_fields["notes"] = forms.CharField(
|
||||||
required=False,
|
required=False,
|
||||||
widget=forms.Textarea(attrs={"rows": 4}),
|
widget=forms.Textarea(attrs={"rows": 4}),
|
||||||
@ -101,3 +102,47 @@ def form_from_dataclass(dataclass):
|
|||||||
|
|
||||||
form_cls.clean_notes = clean_notes
|
form_cls.clean_notes = clean_notes
|
||||||
return form_cls
|
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 {}
|
||||||
|
|||||||
@ -1,9 +1,7 @@
|
|||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
from vrobbler.apps.tasks.utils import (
|
from vrobbler.apps.tasks.utils import (
|
||||||
convert_notes_to_dict,
|
|
||||||
convert_old_boardgame_log_to_new,
|
convert_old_boardgame_log_to_new,
|
||||||
convert_old_orgmode_log_to_new,
|
convert_tasks_notes_list_to_dict,
|
||||||
convert_old_todoist_log_to_new,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -21,7 +19,5 @@ class Command(BaseCommand):
|
|||||||
commit = True
|
commit = True
|
||||||
else:
|
else:
|
||||||
print("No changes will be saved, use --commit to save")
|
print("No changes will be saved, use --commit to save")
|
||||||
convert_old_orgmode_log_to_new(commit)
|
convert_tasks_notes_list_to_dict(commit)
|
||||||
convert_old_todoist_log_to_new(commit)
|
|
||||||
convert_notes_to_dict(commit)
|
|
||||||
convert_old_boardgame_log_to_new(commit)
|
convert_old_boardgame_log_to_new(commit)
|
||||||
|
|||||||
@ -450,11 +450,11 @@ def email_scrobble_board_game(
|
|||||||
locations[location_dict.get("id")] = location
|
locations[location_dict.get("id")] = location
|
||||||
|
|
||||||
scrobbles_created = []
|
scrobbles_created = []
|
||||||
second = 0
|
|
||||||
for play_dict in bgstat_data.get("plays", []):
|
for play_dict in bgstat_data.get("plays", []):
|
||||||
hour = None
|
hour = None
|
||||||
minute = None
|
minute = None
|
||||||
second = None
|
second = None
|
||||||
|
comments = None
|
||||||
if "comments" in play_dict.keys():
|
if "comments" in play_dict.keys():
|
||||||
for line in play_dict.get("comments", "").split("\n"):
|
for line in play_dict.get("comments", "").split("\n"):
|
||||||
if "Learning to play" in line:
|
if "Learning to play" in line:
|
||||||
@ -469,7 +469,7 @@ def email_scrobble_board_game(
|
|||||||
except IndexError:
|
except IndexError:
|
||||||
second = 0
|
second = 0
|
||||||
|
|
||||||
log_data["notes"] = [play_dict.get("comments")]
|
comments = play_dict.get("comments")
|
||||||
log_data["expansion_ids"] = []
|
log_data["expansion_ids"] = []
|
||||||
try:
|
try:
|
||||||
base_game = base_games[play_dict.get("gameRefId")]
|
base_game = base_games[play_dict.get("gameRefId")]
|
||||||
@ -527,6 +527,9 @@ def email_scrobble_board_game(
|
|||||||
duration_seconds = base_game.run_time_seconds
|
duration_seconds = base_game.run_time_seconds
|
||||||
stop_timestamp = timestamp + timedelta(seconds=duration_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}")
|
logger.info(f"Creating scrobble for {base_game} at {timestamp}")
|
||||||
log_data["raw_data"] = bgstat_data
|
log_data["raw_data"] = bgstat_data
|
||||||
scrobble_dict = {
|
scrobble_dict = {
|
||||||
@ -685,9 +688,10 @@ def todoist_scrobble_update_task(
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
timestamp = todoist_note.get("posted_at") or str(int(timezone.now().timestamp()))
|
||||||
if not scrobble.log.get("notes"):
|
if not scrobble.log.get("notes"):
|
||||||
scrobble.log["notes"] = []
|
scrobble.log["notes"] = {}
|
||||||
scrobble.log["notes"].append(todoist_note.get("notes"))
|
scrobble.log["notes"][timestamp] = todoist_note.get("notes")
|
||||||
scrobble.save(update_fields=["log"])
|
scrobble.save(update_fields=["log"])
|
||||||
logger.info(
|
logger.info(
|
||||||
"[todoist_scrobble_update_task] todoist note added",
|
"[todoist_scrobble_update_task] todoist note added",
|
||||||
@ -783,7 +787,10 @@ def todoist_scrobble_task(
|
|||||||
|
|
||||||
|
|
||||||
def emacs_scrobble_update_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]:
|
) -> Optional[Scrobble]:
|
||||||
scrobble = Scrobble.objects.filter(
|
scrobble = Scrobble.objects.filter(
|
||||||
in_progress=True,
|
in_progress=True,
|
||||||
@ -803,27 +810,30 @@ def emacs_scrobble_update_task(
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
notes_updated = False
|
log_updated = False
|
||||||
|
|
||||||
|
if not scrobble.log.get("notes"):
|
||||||
|
scrobble.log["notes"] = {}
|
||||||
|
|
||||||
for note in emacs_notes:
|
for note in emacs_notes:
|
||||||
try:
|
timestamp = note.get("timestamp")
|
||||||
existing_note_ts = [
|
content = note.get("content")
|
||||||
n.get("timestamp") for n in scrobble.log.get("notes", [])
|
if timestamp:
|
||||||
]
|
existing = scrobble.log["notes"].get(timestamp)
|
||||||
except AttributeError:
|
if existing != content:
|
||||||
existing_note_ts = []
|
scrobble.log["notes"][timestamp] = content
|
||||||
if not scrobble.log.get('notes"'):
|
log_updated = True
|
||||||
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
|
|
||||||
|
|
||||||
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"])
|
scrobble.save(update_fields=["log"])
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"[emacs_scrobble_update_task] emacs note added",
|
"[emacs_scrobble_update_task] emacs scrobble updated",
|
||||||
extra={
|
extra={
|
||||||
"emacs_note": emacs_notes,
|
"emacs_id": emacs_id,
|
||||||
"user_id": user_id,
|
"user_id": user_id,
|
||||||
"media_type": Scrobble.MediaType.TASK,
|
"media_type": Scrobble.MediaType.TASK,
|
||||||
},
|
},
|
||||||
@ -865,7 +875,7 @@ def emacs_scrobble_task(
|
|||||||
logger.info(
|
logger.info(
|
||||||
"[emacs_scrobble_task] cannot start already started task",
|
"[emacs_scrobble_task] cannot start already started task",
|
||||||
extra={
|
extra={
|
||||||
"ormode_id": orgmode_id,
|
"orgmode_id": orgmode_id,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
return in_progress_scrobble
|
return in_progress_scrobble
|
||||||
@ -884,9 +894,7 @@ def emacs_scrobble_task(
|
|||||||
if in_progress_scrobble:
|
if in_progress_scrobble:
|
||||||
return in_progress_scrobble
|
return in_progress_scrobble
|
||||||
|
|
||||||
notes = task_data.pop("notes")
|
task_data.pop("notes", None)
|
||||||
if notes:
|
|
||||||
task_data["notes"] = [note.get("content") for note in notes]
|
|
||||||
task_data["title"] = task_data.pop("description")
|
task_data["title"] = task_data.pop("description")
|
||||||
task_data["description"] = task_data.pop("body")
|
task_data["description"] = task_data.pop("body")
|
||||||
task_data["labels"] = task_data.pop("labels")
|
task_data["labels"] = task_data.pop("labels")
|
||||||
|
|||||||
@ -1024,7 +1024,11 @@ class ScrobbleDetailView(DetailView):
|
|||||||
FormClass = self.get_form_class()
|
FormClass = self.get_form_class()
|
||||||
|
|
||||||
log = self.object.log or {}
|
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)
|
return FormClass(initial=log)
|
||||||
|
|
||||||
|
|||||||
47
vrobbler/apps/tasks/forms.py
Normal file
47
vrobbler/apps/tasks/forms.py
Normal file
@ -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 {}
|
||||||
@ -38,6 +38,18 @@ class TaskLogData(BaseLogData):
|
|||||||
"todoist_project_id",
|
"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:
|
def notes_as_str(self, separator: str = " | ") -> str:
|
||||||
"""Return formatted notes with line breaks and no keys"""
|
"""Return formatted notes with line breaks and no keys"""
|
||||||
labels_str = ""
|
labels_str = ""
|
||||||
@ -47,13 +59,14 @@ class TaskLogData(BaseLogData):
|
|||||||
lines = []
|
lines = []
|
||||||
if self.notes:
|
if self.notes:
|
||||||
notes = self.notes
|
notes = self.notes
|
||||||
|
if isinstance(notes, dict):
|
||||||
|
notes = [{k: v} for k, v in notes.items()]
|
||||||
if isinstance(notes, str):
|
if isinstance(notes, str):
|
||||||
notes = [notes]
|
notes = [notes]
|
||||||
|
|
||||||
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()))
|
||||||
# Flatten newlines and clean whitespace
|
|
||||||
note_text = " ".join(note_text.strip().split())
|
note_text = " ".join(note_text.strip().split())
|
||||||
lines.append(f"{timestamp}: {note_text}")
|
lines.append(f"{timestamp}: {note_text}")
|
||||||
if isinstance(note, list):
|
if isinstance(note, list):
|
||||||
@ -67,6 +80,8 @@ class TaskLogData(BaseLogData):
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
notes = self.notes
|
notes = self.notes
|
||||||
|
if isinstance(notes, dict):
|
||||||
|
notes = [{k: v} for k, v in notes.items()]
|
||||||
if isinstance(notes, str):
|
if isinstance(notes, str):
|
||||||
notes = [notes]
|
notes = [notes]
|
||||||
|
|
||||||
|
|||||||
36
vrobbler/apps/tasks/static/tasks/task_notes.js
Normal file
36
vrobbler/apps/tasks/static/tasks/task_notes.js
Normal file
@ -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 =
|
||||||
|
'<div class="col-md-3">' +
|
||||||
|
'<input type="hidden" name="' + widgetName + '_timestamps" value="' + timestamp + '">' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="col-md-7">' +
|
||||||
|
'<textarea name="' + widgetName + '_contents" class="form-control" rows="2">' + (content || '') + '</textarea>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="col-md-2">' +
|
||||||
|
'<button type="button" class="btn btn-sm btn-outline-danger remove-note">×</button>' +
|
||||||
|
'</div>';
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
17
vrobbler/apps/tasks/templates/tasks/task_notes_widget.html
Normal file
17
vrobbler/apps/tasks/templates/tasks/task_notes_widget.html
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
{% load static %}
|
||||||
|
<div id="task-notes-container" class="task-notes-widget">
|
||||||
|
{% for timestamp, content in widget.notes.items %}
|
||||||
|
<div class="task-note-row row mb-2 align-items-start">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<input type="hidden" name="{{widget.name}}_timestamps" value="{{timestamp}}">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-7">
|
||||||
|
<textarea name="{{widget.name}}_contents" class="form-control" rows="2">{{content}}</textarea>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-danger remove-note">×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-primary add-note" data-widget-name="{{widget.name}}">+ Add Note</button>
|
||||||
@ -83,18 +83,50 @@ def convert_notes_to_dict(commit=False):
|
|||||||
scrobble.log["notes"] = [
|
scrobble.log["notes"] = [
|
||||||
value for d in note_list for value in d.values()
|
value for d in note_list for value in d.values()
|
||||||
]
|
]
|
||||||
|
else:
|
||||||
|
scrobble.log["notes"] = note_list
|
||||||
count += 1
|
count += 1
|
||||||
if commit:
|
if commit:
|
||||||
scrobble.save(update_fields=["log"])
|
scrobble.save(update_fields=["log"])
|
||||||
print(f"Updated {count} todoist tasks scrobbles")
|
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):
|
def convert_old_boardgame_log_to_new(commit=False):
|
||||||
scrobbles = Scrobble.objects.filter(board_game__isnull=False, log__has_key="notes")
|
scrobbles = Scrobble.objects.filter(board_game__isnull=False, log__has_key="notes")
|
||||||
|
count = 0
|
||||||
for scrobble in scrobbles:
|
for scrobble in scrobbles:
|
||||||
if isinstance(scrobble.log.get("notes"), str):
|
notes = scrobble.log.get("notes")
|
||||||
scrobble.log["notes"] = [scrobble.log.pop("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:
|
if commit:
|
||||||
scrobble.save(update_fields=["log"])
|
scrobble.save(update_fields=["log"])
|
||||||
print(f"Updated {scrobbles.count()} board game scrobbles")
|
print(f"Updated {count} board game scrobbles")
|
||||||
|
|||||||
@ -136,6 +136,7 @@ class TodoistWebhookView(APIView):
|
|||||||
"todoist_type": todoist_type,
|
"todoist_type": todoist_type,
|
||||||
"todoist_event": todoist_event,
|
"todoist_event": todoist_event,
|
||||||
"updated_at": task_data.get("updated_at"),
|
"updated_at": task_data.get("updated_at"),
|
||||||
|
"posted_at": event_data.get("posted_at"),
|
||||||
"details": task_data.get("description"),
|
"details": task_data.get("description"),
|
||||||
"notes": event_data.get("content"),
|
"notes": event_data.get("content"),
|
||||||
"is_deleted": (
|
"is_deleted": (
|
||||||
@ -231,11 +232,12 @@ class EmacsWebhookView(APIView):
|
|||||||
status=status.HTTP_304_NOT_MODIFIED,
|
status=status.HTTP_304_NOT_MODIFIED,
|
||||||
)
|
)
|
||||||
|
|
||||||
if task_in_progress and post_data.get("notes"):
|
if task_in_progress:
|
||||||
emacs_scrobble_update_task(
|
emacs_scrobble_update_task(
|
||||||
post_data.get("source_id"),
|
post_data.get("source_id"),
|
||||||
post_data.get("notes"),
|
post_data.get("notes", []),
|
||||||
user_id,
|
user_id,
|
||||||
|
description=post_data.get("body"),
|
||||||
)
|
)
|
||||||
|
|
||||||
self.logger.info(
|
self.logger.info(
|
||||||
|
|||||||
Reference in New Issue
Block a user