diff --git a/PROJECT.org b/PROJECT.org index ab11487..a76a42b 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 [0/15] :vrobbler:project:personal: +* Backlog [1/16] :vrobbler:project:personal: ** TODO [#C] Add sentiment parsing for Scrobbles with notes :vrobbler:project:scrobbles:sentiment: :PROPERTIES: :ID: 37781d6a-f3b0-48b2-bf98-33c2c791cf85 @@ -480,6 +480,21 @@ 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. +** DONE [#B] Fix the way timestamps are stored for notes on tasks :bug:scrobbles:tasks: +:PROPERTIES: +:ID: 32973bb3-079b-8cdf-6495-82f8ae907299 +:END: + +*** Description +:PROPERTIES: +:ID: d833a3df-6eb2-36bb-1863-e438e5d36151 +:END: + +Turns out Todoist uses a human-readable timestamp format for comments. We should +adapt that for use from org-mode as well. + +Format should be: "%Y-%m-%dT%H:%M:%S.%fZ" + * Version 39.1 [1/1] ** DONE [#A] Fix bug in tests for notes saving :bug:scrobbles:forms: :PROPERTIES: diff --git a/vrobbler/apps/scrobbles/forms.py b/vrobbler/apps/scrobbles/forms.py index e9ffc4e..aa0e6ab 100644 --- a/vrobbler/apps/scrobbles/forms.py +++ b/vrobbler/apps/scrobbles/forms.py @@ -145,8 +145,8 @@ class NotesDictField(forms.Field): if isinstance(value, str): if value.strip(): - from datetime import datetime - return {datetime.now().isoformat(): value.strip()} + from scrobbles.utils import make_note_timestamp + return {make_note_timestamp(): value.strip()} return {} if isinstance(value, dict): diff --git a/vrobbler/apps/scrobbles/scrobblers.py b/vrobbler/apps/scrobbles/scrobblers.py index 591ed2d..115def9 100644 --- a/vrobbler/apps/scrobbles/scrobblers.py +++ b/vrobbler/apps/scrobbles/scrobblers.py @@ -35,6 +35,7 @@ from scrobbles.notifications import ScrobbleNtfyNotification from scrobbles.utils import ( convert_to_seconds, extract_domain, + make_note_timestamp, next_url_if_exists, remove_last_part, ) @@ -528,7 +529,7 @@ def email_scrobble_board_game( stop_timestamp = timestamp + timedelta(seconds=duration_seconds) if comments: - log_data["notes"] = {str(int(stop_timestamp.timestamp())): comments} + log_data["notes"] = {make_note_timestamp(stop_timestamp): comments} logger.info(f"Creating scrobble for {base_game} at {timestamp}") log_data["raw_data"] = bgstat_data @@ -688,7 +689,7 @@ def todoist_scrobble_update_task( ) return - timestamp = todoist_note.get("posted_at") or str(int(timezone.now().timestamp())) + timestamp = todoist_note.get("posted_at") or make_note_timestamp() if not scrobble.log.get("notes"): scrobble.log["notes"] = {} scrobble.log["notes"][timestamp] = todoist_note.get("notes") @@ -786,12 +787,37 @@ def todoist_scrobble_task( return scrobble +ORG_HEADING_RE = re.compile(r"^(\*+\s+.*)$", re.MULTILINE) +NOTE_CONTENT_SPLIT = re.compile(r"^\*{3,}\s", re.MULTILINE) + + +def _truncate_at_org_header(text: str) -> str: + """Truncate text at the first org-mode heading (*** or more).""" + parts = NOTE_CONTENT_SPLIT.split(text, maxsplit=1) + return parts[0].strip() + + +def _extract_org_section(body: str | None, heading: str) -> str | None: + """Extract content under a specific org-mode sub-heading (e.g. '*** Description').""" + if not body: + return None + sections = ORG_HEADING_RE.split(body) + # sections alternates: [prefix, heading1, content1, heading2, content2, ...] + for i, section in enumerate(sections): + if section.strip().startswith(heading): + if i + 1 < len(sections): + return sections[i + 1].strip() + return "" + return None + + def emacs_scrobble_update_task( emacs_id: str, emacs_notes: list, user_id: int, description: Optional[str] = None, ) -> Optional[Scrobble]: + description = _extract_org_section(description, "*** Description") scrobble = Scrobble.objects.filter( in_progress=True, user_id=user_id, @@ -818,6 +844,11 @@ def emacs_scrobble_update_task( for note in emacs_notes: timestamp = note.get("timestamp") content = note.get("content") + if not content: + continue + content = _truncate_at_org_header(content) + if not content: + continue if timestamp: existing = scrobble.log["notes"].get(timestamp) if existing != content: @@ -896,7 +927,7 @@ def emacs_scrobble_task( task_data.pop("notes", None) task_data["title"] = task_data.pop("description") - task_data["description"] = task_data.pop("body") + task_data["description"] = _extract_org_section(task_data.pop("body"), "*** Description") task_data["labels"] = task_data.pop("labels") task_data["orgmode_id"] = task_data.pop("source_id") diff --git a/vrobbler/apps/scrobbles/utils.py b/vrobbler/apps/scrobbles/utils.py index 47ca0ac..049b155 100644 --- a/vrobbler/apps/scrobbles/utils.py +++ b/vrobbler/apps/scrobbles/utils.py @@ -32,6 +32,16 @@ from webdav.client import get_webdav_client if TYPE_CHECKING: from scrobbles.models import Scrobble + +NOTE_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%S.%fZ" + + +def make_note_timestamp(dt: datetime | None = None) -> str: + if dt is None: + dt = timezone.now() + return dt.strftime(NOTE_TIMESTAMP_FORMAT) + + logger = logging.getLogger(__name__) User = get_user_model() diff --git a/vrobbler/apps/tasks/utils.py b/vrobbler/apps/tasks/utils.py index 48bc002..e4cb861 100644 --- a/vrobbler/apps/tasks/utils.py +++ b/vrobbler/apps/tasks/utils.py @@ -64,6 +64,8 @@ def convert_old_todoist_log_to_new(commit=False): def convert_notes_to_dict(commit=False): + from scrobbles.utils import make_note_timestamp + scrobbles = Scrobble.objects.filter(log__notes__isnull=False) count = 0 for scrobble in scrobbles: @@ -71,7 +73,7 @@ def convert_notes_to_dict(commit=False): print(f"Converting {scrobble} string note to dict") if scrobble.log.get("notes") == "": scrobble.log.pop("notes") - key = str(int(scrobble.timestamp.timestamp())) + key = make_note_timestamp(scrobble.timestamp) notes = scrobble.log.pop("notes") scrobble.log = {} scrobble.log["notes"] = {key: notes} @@ -92,12 +94,14 @@ def convert_notes_to_dict(commit=False): def convert_tasks_notes_list_to_dict(commit=False): + from scrobbles.utils import make_note_timestamp + 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) + key = make_note_timestamp(scrobble.timestamp + timedelta(seconds=10)) parts = [] for note in notes: if isinstance(note, dict): @@ -114,6 +118,8 @@ def convert_tasks_notes_list_to_dict(commit=False): def convert_old_boardgame_log_to_new(commit=False): + from scrobbles.utils import make_note_timestamp + scrobbles = Scrobble.objects.filter(board_game__isnull=False, log__has_key="notes") count = 0 for scrobble in scrobbles: @@ -123,7 +129,7 @@ def convert_old_boardgame_log_to_new(commit=False): notes = [notes] if isinstance(notes, list): key_ts = scrobble.stop_timestamp or scrobble.timestamp - scrobble.log["notes"] = {str(int(key_ts.timestamp())): "\n".join( + scrobble.log["notes"] = {make_note_timestamp(key_ts): "\n".join( str(n) for n in notes )} count += 1