Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f9ba6fec14 | |||
| 5f55163147 | |||
| a6ef34623e | |||
| 7cb48d20f6 | |||
| 445103a878 | |||
| 579da8c44e | |||
| daabd2f37f | |||
| 039c58cf89 | |||
| 410c033f12 | |||
| ce302e4d45 | |||
| 19589c9463 | |||
| 3d9506b14e | |||
| 23b87278b2 | |||
| 0b8e027c30 | |||
| 1bd9f0d942 | |||
| fa7890cb21 | |||
| 957c32e3a7 | |||
| 8d069df9d1 | |||
| 96d1d7ac6b | |||
| 009b2ba243 | |||
| 4f051ae250 | |||
| 7e9fbb1bf6 |
68
.gitea/workflows/build.yml
Normal file
68
.gitea/workflows/build.yml
Normal file
@ -0,0 +1,68 @@
|
||||
name: build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
VROBBLER_DATABASE_URL: sqlite:///test.db
|
||||
VROBBLER_USDA_API_KEY: ${{ vars.VROBBLER_USDA_API_KEY }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Cache pip/poetry
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cache/pip
|
||||
~/.cache/pypoetry
|
||||
key: ${{ runner.os }}-py311-${{ hashFiles('**/poetry.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-py311-
|
||||
|
||||
- name: Install Poetry
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install poetry
|
||||
|
||||
- name: Install deps
|
||||
run: |
|
||||
cp vrobbler.conf.test vrobbler.conf
|
||||
poetry install --with test
|
||||
|
||||
- name: Pytest with coverage
|
||||
run: |
|
||||
poetry run pytest -n 5 --cov-report term:skip-covered --cov=vrobbler tests
|
||||
|
||||
- name: Notify success (ntfy)
|
||||
if: success()
|
||||
run: |
|
||||
curl -fsS \
|
||||
-H "Title: vrobbler CI success" \
|
||||
-H "Priority: low" \
|
||||
-H "Tags: success,vrobbler" \
|
||||
-H "Actions: view, Changes, ${{ gitea.server_url }}/${{ gitea.repository }}/commit/${{ gitea.sha }}; view, Build, ${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_id }}" \
|
||||
-d "✅ Build succeeded: ${{ gitea.repository }} @ ${{ gitea.sha }}" \
|
||||
https://ntfy.unbl.ink/drone
|
||||
|
||||
- name: Notify failure (ntfy)
|
||||
if: failure()
|
||||
run: |
|
||||
curl -fsS \
|
||||
-H "Title: vrobbler CI failure" \
|
||||
-H "Priority: high" \
|
||||
-H "Tags: failure,vrobbler" \
|
||||
-H "Actions: view, Changes, ${{ gitea.server_url }}/${{ gitea.repository }}/commit/${{ gitea.sha }}; view, Build, ${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_id }}" \
|
||||
-d "❌ Build failed: ${{ gitea.repository }} @ ${{ gitea.sha }}" \
|
||||
https://ntfy.unbl.ink/drone
|
||||
@ -1,10 +1,8 @@
|
||||
name: build & deploy
|
||||
name: deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
tags: ["*"]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
@ -22,7 +20,6 @@ jobs:
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
# Cache pip + Poetry caches (rough equivalent to your mounted pip_cache)
|
||||
- name: Cache pip/poetry
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
@ -47,7 +44,6 @@ jobs:
|
||||
run: |
|
||||
poetry run pytest -n 5 --cov-report term:skip-covered --cov=vrobbler tests
|
||||
|
||||
# Notifications (success/failure) for the test job
|
||||
- name: Notify success (ntfy)
|
||||
if: success()
|
||||
run: |
|
||||
@ -71,8 +67,6 @@ jobs:
|
||||
https://ntfy.unbl.ink/drone
|
||||
|
||||
build-and-deploy:
|
||||
# Only deploy on tags (equivalent to Drone when: ref: refs/tags/*)
|
||||
# if: startsWith(gitea.ref, 'refs/tags/')
|
||||
needs: [test]
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@ -97,12 +91,19 @@ jobs:
|
||||
|
||||
- name: Build package with commit info
|
||||
run: |
|
||||
# Write commit to _commit.py before build
|
||||
echo "commit = '$(echo ${{ gitea.sha }} | cut -c1-8)'" > vrobbler/_commit.py
|
||||
poetry build
|
||||
# Restore original _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
|
||||
uses: appleboy/scp-action@v1.0.0
|
||||
with:
|
||||
@ -120,10 +121,13 @@ jobs:
|
||||
key: ${{ secrets.JAIL_KEY }}
|
||||
command_timeout: 2m
|
||||
script: |
|
||||
set -e
|
||||
mkdir -p /var/lib/vrobbler
|
||||
echo "${{ gitea.sha }}" | cut -c1-8 > /var/lib/vrobbler/commit.txt
|
||||
pip uninstall -y vrobbler
|
||||
pip install /var/lib/vrobbler/dist/*.whl
|
||||
rm -f /var/lib/vrobbler/dist/*.whl
|
||||
python -c "import vrobbler; print(f'vrobbler {vrobbler.__version__} installed OK')"
|
||||
vrobbler migrate
|
||||
vrobbler collectstatic --noinput
|
||||
immortalctl restart vrobbler-celery && immortalctl restart vrobbler-celerybeat && immortalctl restart vrobbler
|
||||
235
PROJECT.org
235
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/16] :vrobbler:project:personal:
|
||||
* Backlog [0/15] :vrobbler:project:personal:
|
||||
** TODO [#C] Add sentiment parsing for Scrobbles with notes :vrobbler:project:scrobbles:sentiment:
|
||||
:PROPERTIES:
|
||||
: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
|
||||
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:
|
||||
:ID: 5a4fb0f8-0555-40ec-b06f-93c26bd686f4
|
||||
:END:
|
||||
@ -437,12 +437,21 @@ AllTrails is the best source, though having TrailForks is nice to.
|
||||
Would be nice to have some loose connection to the actual event in my Garmin
|
||||
profile.
|
||||
|
||||
** TODO [#B] Explore a way to add metadata editing to scrobbles after saving :vrobbler:spike:scrobbling:personal:project:
|
||||
** TODO [#B] Fix how we show notes and descriptions from scrobbles to users :metadata:notes:tasks:
|
||||
:PROPERTIES:
|
||||
:ID: adf4c513-a417-4ec9-8831-f01ffcf63276
|
||||
:END:
|
||||
*** Description
|
||||
|
||||
Currently the display of notes leaves something to be desired. The biggest issue
|
||||
is that they don't look good on mobile and are probably trying to be too cute.
|
||||
Rather than post-it note style, we should just put notes in a list under the
|
||||
description, above the Edit Log toggle, with timestamps for when they were
|
||||
added.
|
||||
|
||||
They should also probably support markdown formatting and that should be
|
||||
displayed in the template.
|
||||
|
||||
Could be as simple as a JSON form on the scrobble detail page (do I have have
|
||||
one of those yet?).
|
||||
** TODO [#B] Explore a good way to show notes and descriptions from scrobbles to users :personal:project:scrobbling:vrobbler:spike:
|
||||
** TODO [#B] Add webdav syncing to retroarch imports :vrobbler:videogames:webdav:feature:project:personal:
|
||||
** TODO [#B] Add CSV endpoint for book scrobbles that LibraryThing can ingest :personal:project:books:feature:export:
|
||||
https://app.todoist.com/app/task/add-a-csv-endpoint-for-users-book-reads-that-library-thing-can-ingest-6X7QPMRp265xMXqg#comment-6X7QrXq6gJjMP4hg
|
||||
** TODO [#B] Scrape ComicBookRoundUp ratings for comic book metadata :vrobbler:books:feature:comicbook:personal:project:
|
||||
@ -450,11 +459,11 @@ https://app.todoist.com/app/task/add-a-csv-endpoint-for-users-book-reads-that-li
|
||||
- Note taken on [2025-09-25 Thu 10:51]
|
||||
|
||||
As an example https://comicbookroundup.com/comic-books/reviews/humanoids-publishing/the-history-of-science-fiction
|
||||
** TODO [#A] Find page numbers for comic books from ComicVine :vrobbler:feature:books:personal:project:
|
||||
** TODO [#B] Find page numbers for comic books from ComicVine :vrobbler:feature:books:personal:project:
|
||||
:PROPERTIES:
|
||||
:ID: 79f867c3-1288-4143-b6bf-2a452983ee9f
|
||||
:END:
|
||||
** TODO [#A] Fix koreader scrobble imports to use DST properly :vrobbler:personal:bug:books:imports:
|
||||
** TODO [#B] Fix koreader scrobble imports to use DST properly :vrobbler:personal:bug:books:imports:
|
||||
:PROPERTIES:
|
||||
:ID: 79758cba-a440-48b6-a637-efb88827acf2
|
||||
:END:
|
||||
@ -480,14 +489,218 @@ 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 [#A] When creating org-mode tasks, don't copy comments :vrobbler:bug:scrobbles:tasks:
|
||||
** TODO [#A] Orgmode tasks are not updated if in progress :tasks:orgmode:bug:
|
||||
:PROPERTIES:
|
||||
:ID: 7dcebb2c-7c4c-4ac5-bee6-c2e36c3811f9
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
Currently if you POST to the orgmode webhook with a task that's already in progress, the request just stops there.
|
||||
|
||||
We should add logic where if the task is in-progress, instead of doing nothing,
|
||||
it checks the webhook payload against the in-progress tasks and updates the
|
||||
description of the scrobble.log with the incoming task description if it's
|
||||
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 [#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]
|
||||
** DONE [#A] Fix error in org-mode task sync :emacs:orgmode:tasks:bug:
|
||||
:PROPERTIES:
|
||||
:ID: 03256d2a-48aa-4be7-aeb3-fa1cfddc86bf
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
#+begin_src python
|
||||
File "/usr/local/lib/python3.11/site-packages/vrobbler/apps/tasks/webhooks.py", line 236, in post
|
||||
emacs_scrobble_update_task(
|
||||
File "/usr/local/lib/python3.11/site-packages/vrobbler/apps/scrobbles/scrobblers.py", line 844, in emacs_scrobble_update_task
|
||||
for note in emacs_notes:
|
||||
TypeError: 'NoneType' object is not iterable
|
||||
#+end_src
|
||||
** DONE [#B] Adjust how similar artists are shown :feature:templates:artists:music:
|
||||
:PROPERTIES:
|
||||
:ID: 2a081620-a0a2-4851-a7cf-4043f9c2ee31
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
Currently we show the top 10 similar artists on the Artist detail page linked to
|
||||
the artist detail page on Vrobbler.
|
||||
|
||||
First off, this is very slow. We should look into speeding up the rendering of
|
||||
the similar artists widget.
|
||||
|
||||
Second, the artist name in the similar artist list should be a link to the
|
||||
Vrobbler artist detail page, but there should also be a [musicbrainz] link next
|
||||
to it, that links out to the musicbrainz page whether we have the artist in the
|
||||
Vrobbler database or not.
|
||||
|
||||
|
||||
|
||||
* Version 39.3 [2/2]
|
||||
** DONE [#A] Issue found when doing a release :bug:tooling:release:cicd:
|
||||
:PROPERTIES:
|
||||
:ID: a8fc3ec9-74ec-4190-8ac2-62cd8a33e828
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
#+begin_src sh
|
||||
err: ERROR: Cannot install vrobbler 0.16.1 (from /var/lib/vrobbler/dist/vrobbler-0.16.1-py3-none-any.whl) and vrobbler 38.0 (from /var/lib/vrobbler/dist/vrobbler-38.0-py3-none-any.whl) because these package versions have conflicting dependencies.
|
||||
out: The conflict is caused by:
|
||||
out: The user requested vrobbler 0.16.1 (from /var/lib/vrobbler/dist/vrobbler-0.16.1-py3-none-any.whl)
|
||||
out: The user requested vrobbler 38.0 (from /var/lib/vrobbler/dist/vrobbler-38.0-py3-none-any.whl)
|
||||
out: To fix this you could try to:
|
||||
out: 1. loosen the range of package versions you've specified
|
||||
out: 2. remove package versions to allow pip attempt to solve the dependency conflict
|
||||
err: ERROR: ResolutionImpossible: for help visit https://pip.pypa.io/en/latest/topics/dependency-resolution/#dealing-with-dependency-conflicts
|
||||
2026/06/01 14:15:00 Process exited with status 1
|
||||
failed to remove container: Error response from daemon: removal of container 3fe0eaf032c5518aca4ab71734b52bda7c54ed406136b82136ab7155bf5ff3c1 is already in progress
|
||||
#+end_src
|
||||
|
||||
** DONE [#A] Fix deploy actions running twice :bug:tooling:cicd:
|
||||
:PROPERTIES:
|
||||
:ID: aa56f2a6-2b61-4ddf-9e27-9eadcddf8412
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
Turns out we're now running the build-deploy action twice, once on branch push
|
||||
and once on tag push.
|
||||
|
||||
We should do builds on each push and build and deploys only when a new tag is
|
||||
detected.
|
||||
|
||||
|
||||
* Version 39.2 [2/2]
|
||||
** DONE [#B] Releases do not pin commit to the repo for display :bug:tooling:releases:
|
||||
:PROPERTIES:
|
||||
:ID: 2a9f2ff5-2642-47ab-ba1d-e41825411713
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
Somewhere in implementing the justfile release flow, we lost the capture of the
|
||||
latest commit in the relesae flow so the footer now always says: vXX.x (unknown)
|
||||
|
||||
It should have the first bit of the commit in the parens at the end.
|
||||
|
||||
** 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:
|
||||
:ID: 68a011b2-bb6f-3ba8-2312-5947c41db9ac
|
||||
:END:
|
||||
|
||||
* Version 39.0 [3/3]
|
||||
** 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
|
||||
:END:
|
||||
** DONE [#A] Try to fix deploy failing with bad release plan :release:tooling:pyproject:
|
||||
:PROPERTIES:
|
||||
:ID: 63dc633c-4382-e6a5-e663-b01871ce86ce
|
||||
:END:
|
||||
|
||||
* Version 38.0 [38/38]
|
||||
** DONE [#A] Fix release flow to be easier to trigger :pyproject:release:tooling:
|
||||
|
||||
4
justfile
4
justfile
@ -17,3 +17,7 @@ celery-beat:
|
||||
|
||||
release kind="minor":
|
||||
poetry run python scripts/release.py {{kind}}
|
||||
|
||||
push:
|
||||
git push && git push gitea
|
||||
git push --tags && git push --tags gitea
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "vrobbler"
|
||||
version = "38.0"
|
||||
version = "40.2"
|
||||
description = ""
|
||||
authors = ["Colin Powell <colin@unbl.ink>"]
|
||||
|
||||
|
||||
@ -607,7 +607,8 @@ def test_scrobble_detail_view_post_updates_log(client):
|
||||
|
||||
scrobble.refresh_from_db()
|
||||
assert scrobble.log["description"] == "Updated description"
|
||||
assert scrobble.log["notes"] == ["Updated note"]
|
||||
assert isinstance(scrobble.log["notes"], dict)
|
||||
assert list(scrobble.log["notes"].values()) == ["Updated note"]
|
||||
|
||||
|
||||
@pytest.mark.skip("Need to refactor")
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -19,11 +19,7 @@ class ArtistListView(generic.ListView):
|
||||
paginate_by = 100
|
||||
|
||||
def get_queryset(self):
|
||||
qs = (
|
||||
super()
|
||||
.get_queryset()
|
||||
.annotate(scrobble_count=Count("track__scrobble"))
|
||||
)
|
||||
qs = super().get_queryset().annotate(scrobble_count=Count("track__scrobble"))
|
||||
genre = self.request.GET.get("genre")
|
||||
if genre:
|
||||
qs = qs.filter(theaudiodb_genre=genre)
|
||||
@ -82,26 +78,30 @@ class ArtistDetailView(generic.DetailView):
|
||||
|
||||
similar = artist.similar_artists or []
|
||||
if similar:
|
||||
mbids = [sa["artist_mbid"] for sa in similar if sa.get("artist_mbid")]
|
||||
top = similar[:10]
|
||||
mbids = [sa["artist_mbid"] for sa in top if sa.get("artist_mbid")]
|
||||
local_artists = {
|
||||
a.musicbrainz_id: a
|
||||
for a in Artist.objects.filter(musicbrainz_id__in=mbids)
|
||||
}
|
||||
for sa in similar:
|
||||
for sa in top:
|
||||
local = local_artists.get(sa.get("artist_mbid"))
|
||||
sa["local_url"] = local.get_absolute_url() if local else None
|
||||
context_data["similar_artists"] = similar
|
||||
sa["musicbrainz_url"] = (
|
||||
f"https://musicbrainz.org/artist/{sa['artist_mbid']}"
|
||||
if sa.get("artist_mbid")
|
||||
else None
|
||||
)
|
||||
context_data["similar_artists"] = top
|
||||
|
||||
if artist.theaudiodb_genre:
|
||||
context_data["genre_count"] = (
|
||||
Artist.objects.filter(theaudiodb_genre=artist.theaudiodb_genre)
|
||||
.count()
|
||||
)
|
||||
context_data["genre_count"] = Artist.objects.filter(
|
||||
theaudiodb_genre=artist.theaudiodb_genre
|
||||
).count()
|
||||
if artist.theaudiodb_mood:
|
||||
context_data["mood_count"] = (
|
||||
Artist.objects.filter(theaudiodb_mood=artist.theaudiodb_mood)
|
||||
.count()
|
||||
)
|
||||
context_data["mood_count"] = Artist.objects.filter(
|
||||
theaudiodb_mood=artist.theaudiodb_mood
|
||||
).count()
|
||||
|
||||
return context_data
|
||||
|
||||
|
||||
@ -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'<div class="sticky-note">{ts}: {note_text}</div>'
|
||||
)
|
||||
elif isinstance(self.notes, str):
|
||||
for line in self.notes.split("\n"):
|
||||
if line.strip():
|
||||
escaped_line = html.escape(line)
|
||||
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))
|
||||
|
||||
|
||||
|
||||
@ -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,54 @@ 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")
|
||||
if timestamps:
|
||||
contents = data.getlist(f"{name}_contents")
|
||||
result = {}
|
||||
for i, ts in enumerate(timestamps):
|
||||
if i < len(contents) and ts:
|
||||
result[ts] = contents[i]
|
||||
return result if result else ""
|
||||
return data.get(name, "")
|
||||
|
||||
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 {}
|
||||
|
||||
if isinstance(value, str):
|
||||
if value.strip():
|
||||
from scrobbles.utils import make_note_timestamp
|
||||
return {make_note_timestamp(): value.strip()}
|
||||
return {}
|
||||
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
|
||||
return {}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
)
|
||||
@ -450,11 +451,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 +470,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 +528,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"] = {make_note_timestamp(stop_timestamp): comments}
|
||||
|
||||
logger.info(f"Creating scrobble for {base_game} at {timestamp}")
|
||||
log_data["raw_data"] = bgstat_data
|
||||
scrobble_dict = {
|
||||
@ -685,9 +689,10 @@ def todoist_scrobble_update_task(
|
||||
)
|
||||
return
|
||||
|
||||
timestamp = todoist_note.get("posted_at") or make_note_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",
|
||||
@ -782,9 +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: dict, user_id: int
|
||||
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,
|
||||
@ -803,27 +836,35 @@ 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 not content:
|
||||
continue
|
||||
content = _truncate_at_org_header(content)
|
||||
if not content:
|
||||
continue
|
||||
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 +906,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,11 +925,9 @@ 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["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")
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
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",
|
||||
}
|
||||
|
||||
@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]
|
||||
|
||||
|
||||
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>
|
||||
@ -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}
|
||||
@ -83,18 +85,54 @@ 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_old_boardgame_log_to_new(commit=False):
|
||||
scrobbles = Scrobble.objects.filter(board_game__isnull=False, log__has_key="notes")
|
||||
for scrobble in scrobbles:
|
||||
if isinstance(scrobble.log.get("notes"), str):
|
||||
scrobble.log["notes"] = [scrobble.log.pop("notes")]
|
||||
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 = make_note_timestamp(scrobble.timestamp + timedelta(seconds=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):
|
||||
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:
|
||||
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"] = {make_note_timestamp(key_ts): "\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")
|
||||
|
||||
@ -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") or [],
|
||||
user_id,
|
||||
description=post_data.get("body"),
|
||||
)
|
||||
|
||||
self.logger.info(
|
||||
|
||||
@ -14,11 +14,12 @@ def version_info(request):
|
||||
if not commit:
|
||||
# Try to import from _commit.py module first
|
||||
try:
|
||||
from vrobbler._commit import commit
|
||||
from vrobbler._commit import commit as _commit
|
||||
except ImportError:
|
||||
pass
|
||||
else:
|
||||
return {"app_version": app_version, "git_commit": commit}
|
||||
if _commit and _commit != "unknown":
|
||||
return {"app_version": app_version, "git_commit": _commit}
|
||||
|
||||
# Try to read from commit file (written during deploy)
|
||||
commit_file = Path("/var/lib/vrobbler/commit.txt")
|
||||
|
||||
@ -258,7 +258,7 @@
|
||||
{% for chart in charts.trail|slice:":20" %}
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<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>
|
||||
</li>
|
||||
{% endfor %}
|
||||
|
||||
@ -53,13 +53,15 @@
|
||||
<tr>
|
||||
<th scope="col">Artist</th>
|
||||
<th scope="col">Score</th>
|
||||
<th scope="col"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for sa in similar_artists|slice:":10" %}
|
||||
{% for sa in similar_artists %}
|
||||
<tr>
|
||||
<td>{% if sa.local_url %}<a href="{{sa.local_url}}">{{sa.name}}</a>{% else %}{{sa.name}}{% endif %}</td>
|
||||
<td>{{sa.score}}</td>
|
||||
<td>{% if sa.musicbrainz_url %}<a href="{{sa.musicbrainz_url}}">musicbrainz</a>{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
||||
Reference in New Issue
Block a user