31 Commits
Author SHA1 Message Date
tris 005ff84e2f Bugfix 2026-09-01 21:54:27 +10:00
tris 6a7aa42237 Merge pull request 'layout-update' (#18) from layout-update into master
Reviewed-on: #18
2026-08-27 10:15:12 +10:00
tris 6869fef0e8 Fixed path strings: 2026-08-27 10:12:47 +10:00
tris 24cb447579 Improved project item management 2026-08-27 09:42:09 +10:00
tris 56827fa6b2 Merge 2026-08-05 08:24:50 +10:00
tris 6bb15ae8ad Fixed up library management 2026-08-05 08:21:30 +10:00
tris ab123ba7b1 Item list management 2026-08-05 08:10:25 +10:00
tris 471e465133 Music tag fixes 2026-08-05 08:10:16 +10:00
tris 965fc49501 Test fix 2026-08-05 08:04:45 +10:00
tris 980c1491aa Layout fixes 2026-08-05 07:58:57 +10:00
tris 521b3fd6c5 Fixed module paths 2026-08-05 07:53:05 +10:00
tris 29bdebf3ed Fixed up library management 2026-08-05 07:43:41 +10:00
tris 375d08488d Inline icon 2026-07-14 09:01:22 +10:00
tris f5349beaae Merge branch 'master' of gitea.tfconsulting.com.au:projects/polyphonic 2026-07-14 08:31:40 +10:00
tris 450eda5c26 Added sort columns 2026-07-14 08:31:32 +10:00
tris abb66f7b40 Updated dockerfiles 2026-07-13 08:50:57 +10:00
tris 4a22264d3f Rewrote tags code in annotate (with help from OpenCode) 2026-07-08 10:54:19 +10:00
tris fefa0a28b6 Switched to bulma 1.0 2026-07-08 10:52:45 +10:00
tris 0e69cdeca4 Tweeked deployment (again!) 2026-06-19 14:03:50 +10:00
tris e797876c16 Merge pull request 'Material Icons' (#17) from material into master
Reviewed-on: #17
2026-06-19 13:58:45 +10:00
tris 1301d19c08 Switched to material icons 2026-06-16 09:18:21 +10:00
tris ca3effcad1 Fixed my music page 2026-05-29 22:47:33 +10:00
tris 5468f6d3e7 Added handling of gdrive resource_keys - fixes #16 2026-05-27 22:46:08 +10:00
tris e46d8145a7 Install from git 2026-05-27 09:35:15 +10:00
tris 3444cdbc59 Merge pull request 'gdrive' (#15) from gdrive into master
Reviewed-on: #15
2026-05-24 16:45:13 +10:00
tris 6e77474d15 Couple of fixes 2026-05-24 16:42:51 +10:00
tris 72bad08a61 Merge branch 'master' into gdrive 2026-05-24 14:24:34 +10:00
tris b217fbea5e Sorted out linting 2026-05-24 14:23:59 +10:00
tris cec64ecd2f Working with manual sync 2026-05-24 13:51:23 +10:00
tris d30005d5b6 Indexing bug fix 2026-05-24 11:48:42 +10:00
tris ef981e06e8 Shared gdrive access 2026-05-24 11:35:18 +10:00
42 changed files with 1230 additions and 531 deletions
+3
View File
@@ -5,7 +5,10 @@ __pycache__
credentials.json credentials.json
credentials credentials
local_settings.py local_settings.py
local.mk
.coverage .coverage
.lint
.deploy
Session.vim Session.vim
poetry.lock poetry.lock
/env /env
+5 -2
View File
@@ -1,7 +1,10 @@
FROM alpine:latest FROM alpine:latest
ARG VERSION=0.8.4
ENV TARGET=/opt/polyphonic ENV TARGET=/opt/polyphonic
ENV RELEASE=polyphonic-0.8.4-py3-none-any.whl #ENV RELEASE=polyphonic-${VERSION}-py3-none-any.whl
ENV RELEASE=git+https://gitea.tfconsulting.com.au/projects/polyphonic.git@${VERSION}
RUN apk add --no-cache python3 py3-pip git ghostscript sqlite RUN apk add --no-cache python3 py3-pip git ghostscript sqlite
@@ -10,7 +13,7 @@ WORKDIR /root
RUN python3 -m venv ${TARGET} RUN python3 -m venv ${TARGET}
ENV PATH="${TARGET}/bin:$PATH" ENV PATH="${TARGET}/bin:$PATH"
COPY dist/${RELEASE} . #COPY dist/${RELEASE} .
RUN pip3 install ${RELEASE} --no-cache-dir RUN pip3 install ${RELEASE} --no-cache-dir
RUN pip3 install gunicorn whitenoise RUN pip3 install gunicorn whitenoise
+35 -6
View File
@@ -1,18 +1,36 @@
PYTHON=env/bin/python PYTHON=env/bin/python
DROPZONE=5.7.0
test: LIBRARY=polyphonic/interface/static
DROPZONE=5.7.0
BULMA=1.0.4
ALPINE=3.15.12
VERSION=0.8.4
export DJANGO_SETTINGS_MODULE=polyphonic.config.settings.dev
-include local.mk
test: .coverage
check: .lint
pre-commit: check test
.coverage: polyphonic
poetry run coverage run --include "polyphonic/*" --omit "*/migrations/*" polyphonic/manage.py test polyphonic poetry run coverage run --include "polyphonic/*" --omit "*/migrations/*" polyphonic/manage.py test polyphonic
poetry run coverage html poetry run coverage html
poetry run coverage report poetry run coverage report
check: .lint: polyphonic
poetry run ruff check polyphonic poetry run ruff check polyphonic
poetry run ruff format --check polyphonic poetry run ruff format --check polyphonic
touch $@
pre-commit: check test
build: build: dist/polyphonic-${VERSION}-py3-none-any.whl
dist/polyphonic-${VERSION}-py3-none-any.whl: polyphonic
poetry build poetry build
dev-setup: dev-setup:
@@ -25,7 +43,18 @@ upgrade:
poetry run manage collectstatic poetry run manage collectstatic
${MAKE} libraries ${MAKE} libraries
libraries: static/dropzone static/fonts/Quicksand_Book.otf #libraries: static/dropzone static/fonts/Quicksand_Book.otf
libraries: ${LIBRARY}/css/bulma.min.css ${LIBRARY}/js/alpine.js ${LIBRARY}/css/material.css
${LIBRARY}/css/bulma.min.css:
curl -o $@ "https://cdn.jsdelivr.net/npm/bulma@${BULMA}/css/bulma.min.css"
${LIBRARY}/js/alpine.js:
curl -o $@ "https://cdn.jsdelivr.net/npm/alpinejs@${ALPINE}/dist/cdn.min.js"
${LIBRARY}/css/material.css:
curl -o $@ "https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined"
static/dropzone: static/dropzone:
wget -O dropzone-${DROPZONE}.zip https://github.com/enyo/dropzone/archive/v${DROPZONE}.zip wget -O dropzone-${DROPZONE}.zip https://github.com/enyo/dropzone/archive/v${DROPZONE}.zip
+2 -1
View File
@@ -1,7 +1,7 @@
services: services:
polyphonic: polyphonic:
image: "polyphonic:0.8.4" image: "polyphonic:0.8.4"
build: "." build: .
ports: ports:
- "8001:8000" - "8001:8000"
volumes: volumes:
@@ -12,3 +12,4 @@ services:
DJANGO_SETTINGS_MODULE: local_settings DJANGO_SETTINGS_MODULE: local_settings
PYTHONPATH: /opt/polyphonic PYTHONPATH: /opt/polyphonic
WORK_DIR: /var/polyphonic WORK_DIR: /var/polyphonic
VERSION: 0.8.4
+18
View File
@@ -1,4 +1,5 @@
from .base import * # noqa from .base import * # noqa
from os import environ
DEBUG = True DEBUG = True
@@ -8,3 +9,20 @@ SECRET_KEY = "DO NOT USE IN PRODUCTION"
INSTALLED_APPS.append("debug_toolbar") # noqa INSTALLED_APPS.append("debug_toolbar") # noqa
MIDDLEWARE.insert(1, "debug_toolbar.middleware.DebugToolbarMiddleware") # noqa MIDDLEWARE.insert(1, "debug_toolbar.middleware.DebugToolbarMiddleware") # noqa
INTERNAL_IPS = ["127.0.0.1"] INTERNAL_IPS = ["127.0.0.1"]
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"handlers": {
"console": {
"class": "logging.StreamHandler",
"level": environ.get("DEBUG_LEVEL", "WARNING"),
},
},
"loggers": {
"polyphonic": {
"handlers": ["console"],
"level": environ.get("DEBUG_LEVEL", "WARNING"),
}
},
}
+4 -4
View File
@@ -31,12 +31,12 @@ class ProjectForm(forms.ModelForm, BaseForm):
class Meta: class Meta:
model = models.Project model = models.Project
fields = ["name", "description", "modules", "event_date"] fields = ["name", "description", "modules", "event_date"]
# widgets = { widgets = {"event_date": forms.DateTimeInput()}
# 'event_date': forms.DateTimeInput(attrs={'type': 'date'})
# }
modules = forms.MultipleChoiceField( modules = forms.MultipleChoiceField(
choices=[(x, x.title()) for x in models.settings.POLYPHONIC_MODULES], choices=[
(x, x.replace(".", " ").title()) for x in models.settings.POLYPHONIC_MODULES
],
widget=forms.CheckboxSelectMultiple, widget=forms.CheckboxSelectMultiple,
required=False, required=False,
) )
+3 -2
View File
@@ -192,8 +192,9 @@ class Project(models.Model):
class Module(models.Model): class Module(models.Model):
"""Enable modules on a oriject""" """Enable modules on a oriject"""
name = models.SlugField( name = models.CharField(
max_length=20, choices=[(x, x.title()) for x in settings.POLYPHONIC_MODULES] max_length=100,
choices=[(x, x.replace(".", " ").title()) for x in settings.POLYPHONIC_MODULES],
) )
project = models.ForeignKey( project = models.ForeignKey(
Project, related_name="modules", on_delete=models.CASCADE Project, related_name="modules", on_delete=models.CASCADE
@@ -32,10 +32,11 @@
.menu-label { .menu-label {
color: var(--primary); color: var(--primary);
} }
/*
.button.is-primary, .button.is-primary:hover { .button.is-primary, .button.is-primary:hover {
background-color: var(--primary); background-color: var(--primary);
} }
*/
A.admin-link:after { A.admin-link:after {
content: "*"; content: "*";

Before

Width:  |  Height:  |  Size: 426 KiB

After

Width:  |  Height:  |  Size: 426 KiB

Before

Width:  |  Height:  |  Size: 258 B

After

Width:  |  Height:  |  Size: 258 B

+7 -9
View File
@@ -1,17 +1,15 @@
{% load static %} {% load static %}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en" data-theme="light">
<head> <head>
<!-- Required meta tags --> <!-- Required meta tags -->
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/png" href="{% static 'interface/icon.png' %}" /> <link rel="icon" type="image/png" href="{% static 'images/icon.png' %}" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.3/css/bulma.min.css"> <link rel="stylesheet" href="{% static 'css/bulma.min.css' %}" />
<link rel="stylesheet" href="{% static 'interface/css/polyphonic.css' %}"></link> <link rel="stylesheet" href="{% static 'css/polyphonic.css' %}" />
<script src="{% static 'interface/js/interface.js' %}"></script> <link rel="stylesheet" href="{% static 'css/material.css' %}" />
<script src="//unpkg.com/alpinejs" defer></script> <script src="{% static 'js/interface.js' %}"></script>
<script src="//cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js" defer></script>
<script src="//kit.fontawesome.com/c837098e5b.js" crossorigin="anonymous" defer></script>
<title>{% block title %}Polyphonic{% endblock %}</title> <title>{% block title %}Polyphonic{% endblock %}</title>
{% block media %}{% endblock %} {% block media %}{% endblock %}
<style>{% block style %}{% endblock %}</style> <style>{% block style %}{% endblock %}</style>
@@ -23,7 +21,7 @@
<nav class="navbar" role="navigation"> <nav class="navbar" role="navigation">
<div class="navbar-brand has-text-primary"> <div class="navbar-brand has-text-primary">
<a class="navbar-item" href="/"> <a class="navbar-item" href="/">
<span class="icon fancy is-size-2 is-size-4-mobile mx-4"><i class="fas fa-random"></i></span> <span class="icon fancy mx-4"><span class="material-symbols-outlined is-size-1 is-size-3-mobile">groups</span></span>
<span class="fancy is-size-2 is-size-4-mobile">Polyphonic</span> <span class="fancy is-size-2 is-size-4-mobile">Polyphonic</span>
</a> </a>
<span class="navbar-item is-hidden-mobile fancy is-size-5">Musical Ensemble Manager</span> <span class="navbar-item is-hidden-mobile fancy is-size-5">Musical Ensemble Manager</span>
@@ -6,7 +6,7 @@
{% crispy_field field 'class' 'file-input'%} {% crispy_field field 'class' 'file-input'%}
<span class="file-cta"> <span class="file-cta">
<span class="file-icon"> <span class="file-icon">
<i class="fas fa-upload"></i> <span class="material-symbols-outlined">file_upload</span>
</span> </span>
<span class="file-label"> <span class="file-label">
Choose a file… Choose a file…
@@ -1,19 +1,20 @@
{% extends "interface/project_base.html" %} {% extends "interface/project_base.html" %}
{% load md2 %} {% load md2 %}
{% load polyphonic %}
{% block admin %} {% block admin %}
<a href="{% url 'project_create' object.slug %}" class="button is-link"> <a href="{% url 'project_create' object.slug %}" class="button is-link">
<span class="icon"><i class="fas fa-plus-circle"></i></span> {{ "add_notes"|icon }}
<span>Add project</span> <span>Add project</span>
</a> </a>
{% if inactive %} {% if inactive %}
<a href="?" class="button is-link"> <a href="?" class="button is-link">
<span class="icon"><i class="fas fa-archive"></i></span> {{ "preview_off"|icon }}
<span>Hide old</span> <span>Hide old</span>
</a> </a>
{% else %} {% else %}
<a href="?inactive" class="button is-link"> <a href="?inactive" class="button is-link">
<span class="icon"><i class="fas fa-archive"></i></span> {{ "preview"|icon }}
<span>Show all</span> <span>Show all</span>
</a> </a>
{% endif %} {% endif %}
@@ -35,22 +36,16 @@ Contacts:
{% include 'interface/project_items.html' %} {% include 'interface/project_items.html' %}
{% if request.is_admin %} {% if request.is_admin %}
<div class=""> <div class="box">
<div class="card"> <h3 class="subtitle">Admin Details</h3>
<header class="card header">
<p class="card-header-title">Admin Details</p>
</header>
<div class="card-content">
<ul> <ul>
<li><a href="{{ ensemble_link }}">Ensemble Sharing Link</a></li> <li><a href="{{ ensemble_link }}">Ensemble Sharing Link</a></li>
<li><a href="{% url 'project_create' ensemble.pk %}">Add a new project</a></li> <li><a href="{% url 'project_create' ensemble.pk %}">Add a new project</a></li>
</ul> </ul>
</div>
</div>
</div> </div>
{% endif %} {% endif %}
<div> <div class="has-text-right is-size-6">
<a href="{% url 'forget_resource' 'ensemble' ensemble.slug %}">Forget this ensemble</a> <a href="{% url 'forget_resource' 'ensemble' ensemble.slug %}">Forget this ensemble</a>
</div> </div>
@@ -5,7 +5,7 @@
{% comment %} {% comment %}
<div class="admin-tools is-pulled-right"> <div class="admin-tools is-pulled-right">
<a class="button is-link" href="{% url 'register' %}"> <a class="button is-link" href="{% url 'register' %}">
<span class="icon"><i class="fas fa-plus-circle"></i></span> {% icon "add_file" %}
<span>Register another</span> <span>Register another</span>
</a> </a>
</div> </div>
@@ -25,10 +25,13 @@
<img src="https://www.gravatar.com/avatar/{{ ensemble.email }}?d=mp" alt="Placeholder image"> <img src="https://www.gravatar.com/avatar/{{ ensemble.email }}?d=mp" alt="Placeholder image">
</figure> </figure>
</div> </div>
<div class="media-content" style="min-height: 60px"> <div class="media-content" style="min-height: 100px">
<a href="{% url 'ensemble_detail' ensemble.slug %}"> <a href="{% url 'ensemble_detail' ensemble.slug %}">
<p class="title is-4">{{ ensemble.name }}</p> <p class="title is-4">{{ ensemble.name }}</p>
</a> </a>
<div class="mt-3">
{{ ensemble.details|markdown }}
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -40,8 +43,11 @@
</div> </div>
</div> </div>
{% empty %} {% empty %}
<div class="hero"> <div class="hero mx-auto my-4">
You don't currently have access to any ensembles - ask your administrator for a link. <div class="hero-body has-text-centered">
<p class="title">You don't currently have access to any ensembles</p>
<p class="subtitle">Ask your administrator for a link.</p>
</div>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
@@ -21,7 +21,7 @@
<p class="menu-label">This Project</p> <p class="menu-label">This Project</p>
<ul class="menu-list"> <ul class="menu-list">
<li><a role="tab" href="{% url 'project_detail' project=project.id %}">Project Info</a></li> <li><a role="tab" href="{% url 'project_detail' project=project.id %}">Project Info</a></li>
{% if 'library' in modules %} {% if 'polyphonic.library' in modules %}
<li><a class="nav-link" href="{% url 'item_list' project=project.pk %}">My Music</a></li> <li><a class="nav-link" href="{% url 'item_list' project=project.pk %}">My Music</a></li>
{% endif %} {% endif %}
{% for page in project.wiki_pages.all %} {% for page in project.wiki_pages.all %}
@@ -4,11 +4,11 @@
{% block admin %} {% block admin %}
<a href="{% url 'wiki_create' project=project.pk %}" class="button is-link"> <a href="{% url 'wiki_create' project=project.pk %}" class="button is-link">
<span class="icon"><i class="fas fa-file"></i></span> {{ "add_notes"|icon }}
<span>Add Page</span> <span>Add Page</span>
</a> </a>
<a href="{% url 'project_edit' project=project.pk %}" class="button is-link"> <a href="{% url 'project_edit' project=project.pk %}" class="button is-link">
<span class="icon"><i class="fas fa-edit"></i></span> {{ "edit"|icon }}
<span>Edit</span> <span>Edit</span>
</a> </a>
{% endblock %} {% endblock %}
@@ -29,7 +29,7 @@
</h3> </h3>
{% endif %} {% endif %}
<div class="block"> <div class="block content">
{% if project.description %} {% if project.description %}
<p class="content">{{ project.description|markdown }}</p> <p class="content">{{ project.description|markdown }}</p>
{% else %} {% else %}
@@ -40,40 +40,36 @@
{% if project.owner %} {% if project.owner %}
<div class="block"> <div class="block">
{% if project.owner.email %} {% if project.owner.email %}
The project owner is <a href="mailto:{{ project.owner.email }}">{{ project.owner }}</a> The project owner is <strong><a href="mailto:{{ project.owner.email }}">{{ project.owner }}</a></strong>.
{% else %} {% else %}
The project owner is {{ project.owner }}. The project owner is <strong>{{ project.owner }}</strong>.
{% endif %} {% endif %}
</div> </div>
{% endif %} {% endif %}
</div> </div>
</div> {% if request.is_admin %}
<div class="box">
{% if 'library' in modules %}
<div class="column is-one-third">
{% include 'library/project_detail.html' %}
</div>
{% endif %}
{% if 'submission' in modules %}
<div class="column">
{% include 'submissions/project_detail.html' %}
</div>
{% endif %}
</div>
{% if request.is_admin %}
<div class="box">
<h3 class="subtitle">Admin Actions</h3> <h3 class="subtitle">Admin Actions</h3>
<ul> <ul>
<li><a href="{{ project_link }}">Project Link</a></li> <li><a href="{{ project_link }}">Project Link</a></li>
{% if 'library' in modules %}
<li><a href="{% url 'item_list_manage' project=project.pk %}">Manage items</a></li>
{% endif %}
</ul> </ul>
</div>
{% endif %}
</div>
<div class="column is-one-third">
{% if 'polyphonic.library' in modules %}
{% include 'library/project_detail.html' %}
{% endif %}
{% if 'polyphonic.submission' in modules %}
{% include 'submissions/project_detail.html' %}
{% endif %}
</div>
</div> </div>
{% endif %}
<!-- <!--
<div> <div>
@@ -12,7 +12,8 @@
<p class="card-header-icon" style="color: black;">{{ project.rough_date }}</p> <p class="card-header-icon" style="color: black;">{{ project.rough_date }}</p>
</header> </header>
</a> </a>
<div class="card-content" style="height: 100px; overflow: hidden"> <div class="card-content" style="height: 150px; overflow: hidden">
<div><b>{{ project.event_date|date:"l jS F Y, g:i A" }}</b></div>
<div class="content"> <div class="content">
{{ project.description | markdown }} {{ project.description | markdown }}
</div> </div>
@@ -26,8 +27,8 @@
</div> </div>
</div> </div>
{% empty %} {% empty %}
<div class="hero"> <div class="hero mx-auto my-4">
<div class="hero-body"> <div class="hero-body has-text-centered">
<p class="title">No projects currently planned</p> <p class="title">No projects currently planned</p>
<p class="subtitle">Go put your feet up!</p> <p class="subtitle">Go put your feet up!</p>
</div> </div>
@@ -1,9 +1,10 @@
{% extends "interface/project_base.html" %} {% extends "interface/project_base.html" %}
{% load md2 %} {% load md2 %}
{% load polyphonic %}
{% block admin %} {% block admin %}
<a class="button is-link" href="{% url 'resource_create' project=project.pk %}"> <a class="button is-link" href="{% url 'resource_create' project=project.pk %}">
<span class="icon"><i class="fas fa-plus-circle"></i></span> {% icon "add_notes" %}
<span>Add new</span> <span>Add new</span>
</a> </a>
{% endblock %} {% endblock %}
@@ -27,11 +28,11 @@
<div class="card-header-icon"> <div class="card-header-icon">
{% if request.is_admin %} {% if request.is_admin %}
<a href="{% url 'resource_upload' project=project.pk pk=resource.pk %}" class="icon" title="Upload"> <a href="{% url 'resource_upload' project=project.pk pk=resource.pk %}" title="Upload">
<i class="fas fa-upload"></i> {% icon "upload_file" %}
</a> </a>
<a href="{% url 'resource_edit' project=project.pk pk=resource.pk %}" class="icon" title="Edit"> <a href="{% url 'resource_edit' project=project.pk pk=resource.pk %}" title="Edit">
<i class="fas fa-edit"></i> {% icon "edit" %}
</a> </a>
{% endif %} {% endif %}
</div> </div>
@@ -1,8 +1,9 @@
{% extends "interface/project_base.html" %} {% extends "interface/project_base.html" %}
{% load polyphonic %}
{% block admin %} {% block admin %}
<a href="{% url 'wiki_edit' project=project.pk pk=wikipage.pk %}" class="button is-link"> <a href="{% url 'wiki_edit' project=project.pk pk=wikipage.pk %}" class="button is-link">
<span class="icon"><i class="fas fa-edit"></i></span> {{ "edit"|icon }}
<span>Edit</span> <span>Edit</span>
</a> </a>
{% endblock %} {% endblock %}
@@ -5,7 +5,7 @@ register = template.Library()
def basename(value): def basename(value):
return os.path.basename(value) return os.path.basename(str(value))
register.filter("basename", basename) register.filter("basename", basename)
@@ -1,9 +1,27 @@
from django import template from django import template
from django.utils import timesince from django.utils import timesince
from django.utils.html import format_html
register = template.Library() register = template.Library()
@register.filter("icon", is_safe=True)
def material_icon(value):
return f'<span class="icon"><span class="material-symbols-outlined">{value}</span></span>'
@register.simple_tag
def icon(name, element="span", classes=[]):
classes = ["icon"] + classes
return format_html(
'<{} class="{}"><span class="material-symbols-outlined">{}</span></{}>',
element,
" ".join(classes),
name,
element,
)
def roughtimesince(value): def roughtimesince(value):
return timesince.timesince(value, depth=1) return timesince.timesince(value, depth=1)
+26 -11
View File
@@ -1,4 +1,5 @@
from polyphonic.library.models import Collection, Work, WorkMeta, Document from polyphonic.library.models import Collection, Work, WorkMeta, Document
from byostorage.models import UserStorage
import logging import logging
@@ -6,21 +7,26 @@ logger = logging.getLogger(__name__)
def sync_work(work: Work): def sync_work(work: Work):
logger.info("Syncing '%s'", work.name) try:
folder_id = work.meta_info.get(name="folderid").value folder_id = work.meta_info.get(name="folderid").value
except WorkMeta.DoesNotExist as err:
raise IndexError("Work not currently linked to a gdrive folder") from err
storage = work.collection.storage.instance() logger.info("Syncing '%s' from %r", work.name, folder_id)
prefix = work.collection.storage.name
_, files = storage.listdir(folder_id) storage = UserStorage.objects.get(name="gdrive").instance()
existing = set( existing = set(
[ [
storage.parse_id(x.partition(":")[2]) storage.parse_resource(x.partition(":")[2]).id
for x in work.docs.values_list("upload", flat=True) for x in work.docs.values_list("upload", flat=True)
] ]
) )
logger.debug("%d existing documents", len(existing)) logger.debug("%d existing documents", len(existing))
_, files = storage.listdir(folder_id)
logger.debug("Remote files: %r", files)
for file in files: for file in files:
if file.id in existing: if file.id in existing:
logger.debug("%30s: Skipping existing (%s)", file.name, file.id) logger.debug("%30s: Skipping existing (%s)", file.name, file.id)
@@ -32,21 +38,26 @@ def sync_work(work: Work):
continue continue
logger.info("%40s: Adding", file.name) logger.info("%40s: Adding", file.name)
doc = work.docs.create(upload=f"{prefix}:{file}", doctype=Document.DOCTYPE_PDF) doc = work.docs.create(upload=f"gdrive:{file}", doctype=Document.DOCTYPE_PDF)
doc.auto_tag() doc.auto_tag()
for uri in existing: for uri in existing:
logger.warning("Local entry not in folder: %s", uri) logger.warning("Local entry not in folder: %s", uri)
def sync_partial_collection(collection: Collection, sync_existing: bool = True):
works = Work.objects.filter(collection=collection, meta_info__name="folderid")
for work in works:
sync_work(work)
def sync_collection(collection: Collection, sync_existing: bool = False): def sync_collection(collection: Collection, sync_existing: bool = False):
logger.info("Syncing '%s'", collection) logger.info("Syncing '%s'", collection)
if not collection.storage.storage.endswith("GDriveLinkStorage"): if not collection.storage.storage.endswith("GDriveLinkStorage"):
raise RuntimeError("Not a gdrive storage") return sync_partial_collection(collection, sync_existing)
if not collection.prefix:
raise KeyError("Prefix must store folder id")
existing = dict( existing = dict(
WorkMeta.objects.filter( WorkMeta.objects.filter(
@@ -54,10 +65,14 @@ def sync_collection(collection: Collection, sync_existing: bool = False):
).values_list("value", "work_id") ).values_list("value", "work_id")
) )
folder = collection.prefix
storage = collection.storage.instance() storage = collection.storage.instance()
folders, _ = storage.listdir(collection.prefix) folders, _ = storage.listdir(folder)
for folder in folders: for folder in folders:
if folder[0] == "_":
continue
if folder.id in existing: if folder.id in existing:
if sync_existing: if sync_existing:
logger.info("%40s: Syncing (%s)", folder.name, folder.id[:12]) logger.info("%40s: Syncing (%s)", folder.name, folder.id[:12])
+59 -38
View File
@@ -8,19 +8,26 @@ import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
SHARED_FOLDER = re.compile(r"https://drive.google.com/drive/folders/(\w+)") SHARED_FOLDER = re.compile(
SHARED_FILE = re.compile(r"https://drive.google.com/file/d/([\w\-]+)") r"https://drive.google.com/drive[u0-9\/]+folders/([\w\-]+)(\?resourcekey=([\w\-]+))?"
)
SHARED_FILE = re.compile(
r"https://drive.google.com/file/d/([\w\-]+)(\?resourcekey=([\w\-]+))?"
)
FILES_API = "https://www.googleapis.com/drive/v3/files" FILES_API = "https://www.googleapis.com/drive/v3/files"
class DriveObject(namedtuple("DriveObject", ("id", "name"))): class DriveObject(namedtuple("DriveObject", ("id", "key", "name"))):
@classmethod @classmethod
def from_string(cls, s): def from_string(cls, s: str):
return cls(*s.split("/", 1)) resource, _, name = s.partition("/")
id, _, key = resource.partition("#")
return cls(id, key, name)
def __str__(self): def __str__(self):
return f"{self.id}/{self.name}" resource = f"{self.id}#{self.key}" if self.key else self.id
return f"{resource}/{self.name}"
class GDriveLinkStorage(Storage): class GDriveLinkStorage(Storage):
@@ -30,57 +37,68 @@ class GDriveLinkStorage(Storage):
self.api_key = api_key self.api_key = api_key
super().__init__() super().__init__()
def parse_id(self, name): def parse_resource(self, name) -> DriveObject:
parts = name.split("/") return DriveObject.from_string(name)
return parts[0]
def extract_id(self, url, *patterns): def extract_resource(self, url, *patterns) -> DriveObject:
logger.debug("EXTRACT_ID: %r", url) logger.debug("EXTRACT_RESOURCE: %r", url)
for pattern in patterns: for pattern in patterns:
match = pattern.match(url) match = pattern.match(url)
if match: if match:
return match.groups()[0] groups = match.groups()
logger.debug(groups)
if len(groups) == 3:
return DriveObject(groups[0], groups[2], "")
return DriveObject(groups[0], None, "")
raise FileNotFoundError(f"Not a valid url: {url}") raise FileNotFoundError(f"Not a valid url: {url}")
def get_json(self, url): def get_json(self, url, resource: DriveObject):
logger.debug("GET_JSON: %s", url) headers = {}
response = requests.get(url) if resource.key:
headers["X-Goog-Drive-Resource-Keys"] = f"{resource.id}/{resource.key}"
logger.debug("GET_JSON: %s %r", url, headers)
response = requests.get(url, headers=headers)
data = response.json() data = response.json()
logger.debug("Data: %r", data) logger.debug("Data: %r", data)
return data return data
def listdir(self, path) -> tuple[list[str], list[str]]: def listdir(self, path) -> tuple[list[str], list[str]]:
# used to test for valid connection parameters - should do something to validate API key here # used to test for valid connection parameters - should do something to validate API key here
logger.debug("listdir: %s", path)
if path == "": if path == "":
return [], [] return [], []
logger.debug("LISTDIR: %s", path) folder = self.parse_resource(path)
folder_id = self.parse_id(path) url = f"{FILES_API}?q='{folder.id}'+in+parents&key={self.api_key}"
url = f"{FILES_API}?q='{folder_id}'+in+parents&key={self.api_key}" data = self.get_json(url, folder)
data = self.get_json(url)
files = [] files = []
folders = [] folders = []
for x in data["files"]: for x in data["files"]:
if x["mimeType"] == "application/vnd.google-apps.folder": if x["mimeType"] == "application/vnd.google-apps.folder":
# folders.append(f"{x['id']}/{x['name']}") # folders.append(f"{x['id']}/{x['name']}")
folders.append(DriveObject(x["id"], x["name"])) folders.append(DriveObject(x["id"], x.get("resourceKey"), x["name"]))
else: else:
# files.append(f"{x['id']}/{x['name']}") # files.append(f"{x['id']}/{x['name']}")
files.append(DriveObject(x["id"], x["name"])) files.append(DriveObject(x["id"], x.get("resourceKey"), x["name"]))
return folders, files return folders, files
def get_meta(self, name): def get_meta(self, name):
file_id = self.parse_id(name) file_resource = self.parse_resource(name)
url = f"{FILES_API}/{file_id}?key={self.api_key}" url = f"{FILES_API}/{file_resource.id}?key={self.api_key}"
return self.get_json(url) return self.get_json(url, file_resource)
def open(self, name, mode="rb"): def open(self, name, mode="rb"):
file_id = self.parse_id(name) resource = self.parse_resource(name)
url = f"{FILES_API}/{file_id}?alt=media&key={self.api_key}" url = f"{FILES_API}/{resource.id}?alt=media&key={self.api_key}"
logger.info("URL: %s", url)
response = requests.get(url, stream=True) headers = {}
if resource.key:
headers["X-Goog-Drive-Resource-Keys"] = f"{resource.id}/{resource.key}"
logger.info("URL: %s [%r]", url, headers)
response = requests.get(url, headers=headers, stream=True)
return GzipFile(name, "rb", 9, response.raw) return GzipFile(name, "rb", 9, response.raw)
def size(self, name): def size(self, name):
@@ -91,28 +109,31 @@ class GDriveLinkStorage(Storage):
def url(self, name): def url(self, name):
logger.debug("URL: %r", name) logger.debug("URL: %r", name)
file_id = self.parse_id(name) resource = self.parse_resource(name)
return f"https://drive.usercontent.google.com/download?export=download&id={file_id}&confirm=yes" uri = f"https://drive.usercontent.google.com/download?export=download&id={resource.id}&confirm=yes"
if resource.key:
uri += f"&resourcekey=${resource.key}"
return uri
def get_folder_id(self, url): def get_folder_id(self, url):
try: try:
return self.extract_id(url, SHARED_FOLDER) return self.extract_resource(url, SHARED_FOLDER)
except FileNotFoundError: except FileNotFoundError:
return None return None
def get_file_id(self, url): def get_file_id(self, url):
try: try:
return self.extract_id(url, SHARED_FILE) return self.extract_resource(url, SHARED_FILE)
except FileNotFoundError: except FileNotFoundError:
return None return None
def import_link(self, url) -> str:
file_resource = self.extract_resource(url, SHARED_FILE)
meta = self.get_meta(file_resource)
return f"{file_resource}/{meta['name']}"
""" """
def import_link(self, url) -> str:
file_id = self.extract_id(url, SHARED_FILE)
meta = self.get_meta(file_id)
return f"{file_id}/{meta['name']}"
def folder_import(self, url) -> list[str]: def folder_import(self, url) -> list[str]:
folder_id = self.extract_id(url, SHARED_FOLDER) folder_id = self.extract_id(url, SHARED_FOLDER)
_, files = self.listdir(folder_id) _, files = self.listdir(folder_id)
+14 -12
View File
@@ -4,6 +4,9 @@ from django.views.generic.detail import SingleObjectMixin
from polyphonic.library.views import CollectionMixin from polyphonic.library.views import CollectionMixin
from polyphonic.library.models import Work, Document from polyphonic.library.models import Work, Document
from polyphonic.library import forms from polyphonic.library import forms
from byostorage.models import UserStorage
from . import sync_work
class WorkGDriveView(CollectionMixin, SingleObjectMixin, FormView): class WorkGDriveView(CollectionMixin, SingleObjectMixin, FormView):
@@ -27,31 +30,30 @@ class WorkGDriveView(CollectionMixin, SingleObjectMixin, FormView):
def form_valid(self, form): def form_valid(self, form):
link = form.cleaned_data["link"] link = form.cleaned_data["link"]
storage = self.collection.storage.instance() # storage = self.collection.storage.instance()
storage = UserStorage.objects.get(name="gdrive").instance()
self.object = self.get_object() self.object = self.get_object()
try:
folderid = storage.get_folder_id(link) folderid = storage.get_folder_id(link)
if folderid:
self.object.meta_info.update_or_create( self.object.meta_info.update_or_create(
name="folderid", defaults={"value": folderid} name="folderid", defaults={"value": folderid}
) )
return redirect("work_detail", self.collection.pk, self.kwargs["pk"]) sync_work(self.object)
except FileNotFoundError:
pass # not a folder id
try: return redirect("work_detail", self.collection.pk, self.kwargs["pk"])
link = self.collection.storage.instance().import_link(link)
except AttributeError: link = storage.import_link(link)
pass if link is None:
except FileNotFoundError as e: form.add_error("link", "Not a valid link")
form.add_error("link", str(e))
return self.form_invalid(form) return self.form_invalid(form)
work = self.collection.works.get(pk=self.kwargs["pk"]) work = self.collection.works.get(pk=self.kwargs["pk"])
doc = Document( doc = Document(
work=work, work=work,
upload=f"{self.collection.storage.name}:{link}", upload=f"gdrive:{link}",
# upload=f"{self.collection.storage.name}:{link}",
doctype=Document.DOCTYPE_PDF, doctype=Document.DOCTYPE_PDF,
) )
doc.save() doc.save()
+8 -4
View File
@@ -292,7 +292,11 @@ class Work(models.Model):
@property @property
def digital_parts(self): def digital_parts(self):
sections = [(s.tag, s.name) for s in Section.objects.filter(doc__work=self.pk)] sections = [
(s.tag, s.name)
for s in Section.objects.filter(doc__work=self.pk)
if ":" not in s.tag
]
sections.sort(key=self.orchestration.sorter()) sections.sort(key=self.orchestration.sorter())
# return [ s[1] for s in sections ] # return [ s[1] for s in sections ]
sections = list(dict(sections).items()) # primitive unique() sections = list(dict(sections).items()) # primitive unique()
@@ -366,8 +370,8 @@ class Work(models.Model):
def music_tags(self): def music_tags(self):
tags = dict(self.orchestration.as_list()) tags = dict(self.orchestration.as_list())
for section in Section.objects.filter(doc__work_id=self.pk): # for section in Section.objects.filter(doc__work_id=self.pk):
tags.setdefault(section.tag, section.name) # tags.setdefault(section.tag, section.name)
return tags.items() return tags.items()
@@ -434,7 +438,7 @@ class Document(models.Model):
filename = os.path.basename(str(self.upload)) filename = os.path.basename(str(self.upload))
inst = auto_tag(filename) inst = auto_tag(filename)
if inst: if inst:
self.sections.get_or_create(tag=inst.abbreviate()) self.sections.get_or_create(tag=inst.tag)
def delete(self, *args, **kwargs): def delete(self, *args, **kwargs):
self.upload.delete(save=False) self.upload.delete(save=False)
+110 -64
View File
@@ -1,13 +1,15 @@
from collections import namedtuple from dataclasses import dataclass
import re import re
GENERAL = """
mvmt Movement TAG_PREFIXES = {
ex Excerpt "mvmt": "Movement",
sec Section "ex": "Excerpt",
pce Piece "sect": "Section",
no Number "name": "Name",
""" "no": "Number",
"page": "Page",
}
# taken from https://imslp.org/wiki/IMSLP:Abbreviations_for_MusicTags # taken from https://imslp.org/wiki/IMSLP:Abbreviations_for_MusicTags
# Include any aliases at the top # Include any aliases at the top
@@ -19,6 +21,10 @@ cb Double Bass
mall Mallet Percussion mall Mallet Percussion
vln Violin vln Violin
vla Viola vla Viola
kit Drumkit
asax Alto Sax
tsax Tenor Sax
bsax Bari Sax
acc Accordion acc Accordion
afl Alto flute afl Alto flute
@@ -155,71 +161,110 @@ xyl Xylophone
zith Zither zith Zither
""" """
MUSIC_TAG = re.compile(r"((?P<prefix>\w+):)?(?P<name>.*?)(\-(?P<number>[0-9]+))?")
MUSIC_TAGS = [] MUSIC_TAGS = []
GENERAL_TAGS = set() TAG_ALIASES = {}
for i, abbreviations in enumerate((GENERAL, INSTRUMENTS)): # GENERAL_TAGS = set()
for line in abbreviations.split("\n"): for line in INSTRUMENTS.split("\n"):
parts = line.strip().split(maxsplit=1) parts = line.strip().split(maxsplit=1)
if len(parts) < 2: if len(parts) < 2:
continue continue
name, _, _ = parts[1].partition("(") name, _, _ = parts[1].partition("(")
MUSIC_TAGS.append((parts[0], name)) MUSIC_TAGS.append((parts[0], name.strip()))
if i == 0: # if i == 0:
GENERAL_TAGS.add(parts[0]) # GENERAL_TAGS.add(parts[0])
TAG_ALIASES.setdefault(name, []).append(parts[0])
MUSIC_NAME_BY_TAG = dict(MUSIC_TAGS) MUSIC_NAME_BY_TAG = dict(MUSIC_TAGS)
MUSIC_TAG_BY_NAME = dict(((x[1].lower(), x[0]) for x in MUSIC_TAGS)) MUSIC_TAG_BY_NAME = dict(((x[1].lower(), x[0]) for x in MUSIC_TAGS))
class MusicTag(namedtuple("MusicTag", ("name", "variant"), defaults=[None])): def slug(s):
"""
>>> slug("This is a test")
'This_is_a_test'
"""
return str(s).replace(" ", "_")
def deslug(s):
return s.replace("_", " ")
@dataclass
class MusicTag:
name: str
number: int | None = None
prefix: str = ""
@classmethod @classmethod
def from_tag(cls, tag): def from_tag(cls, tag):
""" """
>>> MusicTag.from_tag('vn-1') >>> MusicTag.from_tag('vn-1')
MusicTag(name='Violin', variant='1') MusicTag(name='Violin', number=1, prefix='')
>>> MusicTag.from_tag('db') >>> MusicTag.from_tag('db')
MusicTag(name='Double Bass', variant=None) MusicTag(name='Double Bass', number=None, prefix='')
>>> MusicTag.from_tag('Jaws Harp') >>> MusicTag.from_tag('Jaws Harp')
MusicTag(name='Jaws Harp', variant=None) MusicTag(name='Jaws Harp', number=None, prefix='')
>>> MusicTag.from_tag('mvmt-2') >>> MusicTag.from_tag('mvmt:Largo-2')
MusicTag(name='Movement', variant='2') MusicTag(name='Largo', number=2, prefix='mvmt')
>>> MusicTag.from_tag('pce-A2') >>> MusicTag.from_tag('name:A2')
MusicTag(name='Piece', variant='A2') MusicTag(name='A2', number=None, prefix='name')
>>> MusicTag.from_tag('name:Ode_to_Joy')
MusicTag(name='Ode to Joy', number=None, prefix='name')
>>> MusicTag.from_tag('no:-2')
MusicTag(name='', number=2, prefix='no')
""" """
abbr, _, variant = tag.partition("-")
name = MUSIC_NAME_BY_TAG.get(abbr.lower(), abbr)
if variant: match = MUSIC_TAG.fullmatch(tag)
return cls(name, variant)
return cls(name, None)
@property if match is None:
def tag(self): raise ValueError("Not a valid tag")
lc = self.name.lower()
return MUSIC_TAG_BY_NAME.get(lc, lc) result = match.groupdict()
if result["prefix"] is None:
result["name"] = MUSIC_NAME_BY_TAG.get(
result["name"].lower(), deslug(result["name"])
)
result["prefix"] = ""
else:
result["name"] = deslug(result["name"])
result["number"] = int(result["number"]) if result["number"] else None
return cls(**result)
@property @property
def is_general(self): def is_general(self):
""" """
>>> MusicTag('Piece', 'A3').is_general >>> MusicTag('A3', prefix="name").is_general
True True
>>> MusicTag('Violin', 2).is_general >>> MusicTag('Violin', 2).is_general
False False
""" """
return self.tag in GENERAL_TAGS return self.prefix in TAG_PREFIXES
def abbreviate(self): @property
def tag(self):
""" """
>>> MusicTag('Violin', 1).abbreviate() >>> MusicTag('Violin', 1).tag
'vn-1' 'vn-1'
>>> MusicTag('Double Bass').abbreviate() >>> MusicTag('Double Bass').tag
'db' 'db'
>>> MusicTag('left', prefix="page:").tag
'page:left'
>>> MusicTag("Ode to Joy", prefix="name:").tag
'name:Ode_to_Joy'
>>> MusicTag('Unknown Instrument').tag
'Unknown_Instrument'
""" """
tag = MUSIC_TAG_BY_NAME.get(self.name.lower()) parts = [self.prefix]
if self.variant: parts.append(MUSIC_TAG_BY_NAME.get(self.name.lower(), slug(self.name)))
tag = f"{tag}-{self.variant}" if self.number:
return tag parts.extend(["-", str(self.number)])
return "".join(parts)
def __str__(self): def __str__(self):
""" """
@@ -227,15 +272,17 @@ class MusicTag(namedtuple("MusicTag", ("name", "variant"), defaults=[None])):
'Violin 1' 'Violin 1'
>>> str(MusicTag('Double Bass')) >>> str(MusicTag('Double Bass'))
'Double Bass' 'Double Bass'
>>> str(MusicTag('Unknown Instrument'))
'Unknown Instrument'
""" """
if self.variant: if self.number:
return f"{self.name} {self.variant}" return f"{self.name} {self.number}"
return self.name return self.name
PATTERNS = [ PATTERNS = [
re.compile(r"(?P<inst>[A-Za-z]+)[_\- ]*(?P<ord>\d+)"), re.compile(r"(?P<inst>[A-Za-z]+)[_\- ]*(?P<number>\d+)"),
re.compile(r"(?P<ord>\d+)(st|nd|rd|th)[_\- ]*(?P<inst>[A-Za-z]+)"), re.compile(r"(?P<number>\d+)(st|nd|rd|th)[_\- ]*(?P<inst>[A-Za-z]+)"),
re.compile(r"(?P<inst>[A-Za-z]+)()"), re.compile(r"(?P<inst>[A-Za-z]+)()"),
] ]
@@ -243,34 +290,33 @@ PATTERNS = [
def auto_tag(filename): def auto_tag(filename):
""" """
>>> auto_tag('Ode to Joy - Violin 1.pdf') >>> auto_tag('Ode to Joy - Violin 1.pdf').tag
MusicTag(name='Violin', variant=1) 'vn-1'
>>> auto_tag('Ode to Joy_Cello.pdf') >>> auto_tag('Ode to Joy_Cello.pdf').tag
MusicTag(name='Cello', variant=None) 'vc'
>>> auto_tag('Ode to Joy violin - 1.pdf') >>> auto_tag('Ode to Joy violin - 1.pdf').tag
MusicTag(name='Violin', variant=1) 'vn-1'
>>> auto_tag('Ode to Joy - vla.pdf') >>> auto_tag('Ode to Joy - vla.pdf').tag
MusicTag(name='Viola', variant=None) 'va'
>>> auto_tag('Ode to Joy - fl-2 (piccolo).pdf') >>> auto_tag('Ode to Joy - fl-2 (piccolo).pdf').tag
MusicTag(name='Flute', variant=2) 'fl-2'
>>> auto_tag('1st Violin - Ode to Joy.pdf') >>> auto_tag('1st Violin - Ode to Joy.pdf').tag
MusicTag(name='Violin', variant=1) 'vn-1'
>>> auto_tag('Ode to Joy - 2nd Violin.pdf') >>> auto_tag('Ode to Joy - 2nd Violin.pdf').tag
MusicTag(name='Violin', variant=2) 'vn-2'
""" """
for pattern in PATTERNS: for pattern in PATTERNS:
for m in pattern.finditer(filename): for m in pattern.finditer(filename):
inst = m["inst"].lower() inst = m["inst"].lower()
try: try:
ordinal = int(m["ord"]) number = int(m["number"])
except IndexError: except IndexError:
ordinal = None number = None
if inst in MUSIC_TAG_BY_NAME: if inst in MUSIC_TAG_BY_NAME:
return MusicTag(inst.title(), ordinal) return MusicTag(inst.title(), number)
if inst in MUSIC_NAME_BY_TAG: if inst in MUSIC_NAME_BY_TAG:
return MusicTag(MUSIC_NAME_BY_TAG[inst], ordinal) return MusicTag(MUSIC_NAME_BY_TAG[inst], number)
if __name__ == "__main__": if __name__ == "__main__":
@@ -1,4 +1,5 @@
{% extends "interface/project_base.html" %} {% extends "interface/project_base.html" %}
{% load polyphonic %}
{% block page %} {% block page %}
<h3 class="title">Library collections for {% firstof request.user.first_name request.user.username %}</h3> <h3 class="title">Library collections for {% firstof request.user.first_name request.user.username %}</h3>
@@ -10,7 +11,9 @@
<input class="input" name="q" type="text" placeholder="Find a work" value="{{ request.GET.filter }}"/> <input class="input" name="q" type="text" placeholder="Find a work" value="{{ request.GET.filter }}"/>
</div> </div>
<div class="control"> <div class="control">
<a class="button" href="?"><i class="fas fa-times"></i></a> <a class="button" href="?">
{% icon "close" %}
</a>
</div> </div>
</div> </div>
</form> </form>
@@ -47,4 +50,5 @@
<div> <div>
<small>{{ ensemble.ensemble_code }}</small> <small>{{ ensemble.ensemble_code }}</small>
</div> </div>
{% endblock %} {% endblock %}
@@ -1,53 +1,111 @@
{% extends "interface/project_base.html" %} {% extends "interface/project_base.html" %}
{% load polyphonic %}
{% block admin %} {% block admin %}
<a href="#" onclick="saveTags()" class="button is-link"> <a href="#" onclick="saveTags()" class="button is-link">
<span class="icon"><i class="fas fa-save"></i></span> {% icon "save" %}
<span>Save</span> <span>Save</span>
</a> </a>
<a href="{% url 'work_detail' collection=collection.pk pk=object.work_id %}" class="button is-link is-light"> <a href="{% url 'work_detail' collection=collection.pk pk=object.work_id %}" class="button is-link is-light">
{% icon "backspace" %}
<span>Cancel</span> <span>Cancel</span>
</a> </a>
{% endblock %} {% endblock %}
{% block media %} {% block media %}
<style> <style>
#unassigned-area {
max-width: 200px;
}
#unassigned-area .tag {
cursor: pointer;
}
#area-left h3 {
margin-top: 10px;
text-align: center;
}
.tag-grid { .tag-grid {
display: flex; display: flex;
margin-bottom: 5px;
overflow-y: auto;
} }
.grid-column { .grid-column {
margin-left: 1em; margin-left: 0;
margin-right: 1em; margin-right: 0;
} }
.grid-page { .grid-page {
height: 25px; height: 25px;
margin-bottom: 5px; border: none;
border: 1px solid #999; border-top: 1px solid #DDD;
border-radius: 5px;
text-align: center; text-align: center;
width: 50px; width: 50px;
cursor: pointer; cursor: pointer;
} }
.grid-page:last-child {
border-bottom: 1px solid #DDD;
height: 26px;
}
.grid-page.is-active { .grid-page.is-active {
background-color: var(--primary); /*background-color: var(--bulma-link-light);*/
color: white; color: var(--bulma-link);
background-color: #EEE;
border-radius: 0px 6px 6px 0px;
} }
.grid-tag { .grid-tag {
border: 2px solid var(--primary); background: #fff;
background-color: rgba(240, 240, 240, 0.5); border: 1px solid #DDD;
border-radius: 5px; border-radius: 0px 4px 4px 0px;
padding-left: 1em; border-left: none;
padding-right: 1em; padding-left: 0.5em;
margin-bottom: 5px; min-width: 100px;
min-width: 200px;
position: absolute; position: absolute;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: space-between; justify-content: space-between;
align-items: start; align-items: top;
z-index: 1;
margin: 0;
box-shadow: 4px 0 6px -4px rgba(0,0,0,0.12);
} }
.grid-tag:hover {
z-index: 100 !important;
}
.tag-handle {
position: relative;
width: 20px;
background-color: var(--bulma-warning-light);
margin-left: 10px;
cursor: move;
}
.tag-name {
flex: 1;
text-align: right;
cursor: pointer;
}
.tag-handle-resize {
position: absolute;
height: 10px;
width: 10px;
bottom: 0px;
right: 0px;
border-radius: 3px 0px 0px 0px;
background-color: var(--bulma-warning);
cursor: ns-resize;
}
{% for tag, colour in tag_colours %}
.tag-type-{{ tag }} .tag-handle {
background-color: var(--bulma-{{ colour }}-light);
}
.tag-type-{{ tag }} .tag-handle-resize {
background-color: var(--bulma-{{ colour }});
}
{% endfor %}
#tag-area { #tag-area {
min-width: 220px; min-width: 220px;
position: relative;
font-size: 9pt;
} }
.instrument { .instrument {
display: list-item !important; display: list-item !important;
@@ -62,28 +120,34 @@
{% block page %} {% block page %}
<h3 class="subtitle"><a href="{% url 'work_detail' collection.pk document.work_id %}">{{ document.work.name }}</a></h3> <h3 class="subtitle"><a href="{% url 'work_detail' collection.pk document.work_id %}">{{ document.work.name }}</a></h3>
<div id="annotation-area" class="columns is-centered"> <div id="annotation-area" class="columns is-centered">
<div class="column is-narrow"> <div class="column is-narrow" id="area-left">
<h3>Page control</h3>
<div class="columns"> <div class="columns">
<div class="column is-narrow"> <div class="column is-narrow">
<button class="button is-small is-primary" id="prev">&lt;</button> <button class="button is-small is-primary" id="prev">&lt;</button>
</div> </div>
<div class="column"> <div class="column has-text-centered">
<span id="page-num">-</span> / <span id="page-count">-</span> <span id="page-num">-</span> / <span id="page-count">-</span>
</div> </div>
<div class="column is-narrow"> <div class="column is-narrow">
<button class="button is-small is-primary" id="next">&gt;</button> <button class="button is-small is-primary" id="next">&gt;</button>
</div> </div>
</div> </div>
<h3>Quick Tags</h3>
<ul id="unassigned-area"> <ul id="unassigned-area">
{% for tag, inst in document.work.music_tags %} {% for tag, inst in document.work.music_tags %}
<li class="is-clickable" onclick="assignInstrument('{{tag}}', this)")>{{ inst }}</li> <li class="tag is-warning" onclick="assignTag('{{tag}}', this)">{{ inst }}</li>
{% endfor %} {% endfor %}
</ul> </ul>
<a onclick="document.getElementById('add-modal').classList.add('is-active')">Add Tag</a> <h3>Actions</h3>
<div>
<a class="button is-small is-primary" onclick="showTagModal(createNewTag)">New Tag</a>
<button class="button is-small is-primary" onclick="expandEntries()">Expand Tags</button>
</div>
</div> </div>
<div class="column is-narrow"> <div class="column is-narrow">
<div class="has-text-centered"> <div class="has-text-centered">
<div class="box" style="display: inline-block;"> <div class="box" style="display: inline-block; padding: 0px;">
<canvas id="inline-viewer" style="width: 500px;"></canvas> <canvas id="inline-viewer" style="width: 500px;"></canvas>
</div> </div>
</div> </div>
@@ -96,41 +160,74 @@
<div class="grid-column" id="tag-area"> <div class="grid-column" id="tag-area">
</div> </div>
</div> </div>
<button class="button is-primary" style="width: 100%" onclick="expandEntries()">Expand</button>
</div> </div>
</div> </div>
<div class="modal" id="add-modal"> <div class="modal" id="tag-modal">
<div class="modal-background" onclick="closeAddModal()"></div> <div class="modal-background" onclick="closeTagModal()"></div>
<div class="modal-card"> <div class="modal-card">
<header class="modal-card-head"> <header class="modal-card-head">
<p class="modal-card-title">Add Instrument</p> <p class="modal-card-title">Tag Editor</p>
<button class="delete" aria-label="close" onclick="closeAddModal()"></button> <button class="delete" aria-label="close" onclick="closeTagModal()"></button>
</header> </header>
<section class="modal-card-body"> <section class="modal-card-body">
<div class="field has-addons"> <form>
<div class="field has-addons is-justify-content-center">
<span class="control"> <span class="control">
<input type="text" class="input" list="instrument-list" id="add-instrument-name"/> <span class="select">
<select onChange="onChangeTagType()" id="tag-prefix">
<option value="inst">Instrument</option>
<option value="mvmt">Movement</option>
<option value="sect">Section</option>
<option value="no">Number</option>
<option value="page">Page</option>
</select>
</span>
</span>
<span class="control tag-option enable-page">
<span class="select tag-name">
<select id="tag-name-page">
<option value="left">Left</option>
<option value="right">Right</option>
<option value="blank">Add Blank</option>
</select>
</span>
</span>
<span class="control tag-option enable-sect enable-mvmt">
<input type="text" class="input tag-name" id="tag-name" />
</span>
<span class="control tag-option enable-inst">
<input type="text" class="input tag-name" list="instrument-list" id="tag-name-inst"/>
<datalist id="instrument-list"> <datalist id="instrument-list">
{% for inst in json_data.instruments.values %} {% for inst in json_data.instrumentNames %}
<option value="{{inst}}"/> <option value="{{inst}}"/>
{% endfor %} {% endfor %}
</datalist> </datalist>
</span> </span>
<span class="control"> <span class="control tag-option enable-inst enable-mvmt enable-no">
<input type="number" class="input" max="4" min="1" size="3" id="add-instrument-variant"/> <input type="number" class="input tag-number" min="1" size="3" id="tag-number"/>
</span> </span>
<span class="control"> <span class="control">
<button class="button is-primary" onclick="addInstrument();">Add</button> <button type="submit" class="button is-primary" onclick="event.preventDefault(); confirmTagModal(); ">Save</button>
</span> </span>
</div> </div>
</form>
</section> </section>
</div> </div>
</div> </div>
<p>{{ document.upload.name }}</p> <p>{{ document.upload.name }}</p>
<template id="blank-tag">
<div class="grid-tag">
<span class="tag-name"></span>
<div class="tag-handle">
<div class="tag-handle-resize"></div>
</div>
</div>
</template>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
@@ -151,6 +248,7 @@
let data = JSON.parse(document.getElementById('data').textContent); let data = JSON.parse(document.getElementById('data').textContent);
let tagArea = document.getElementById('tag-area'); let tagArea = document.getElementById('tag-area');
var dirty = false; var dirty = false;
var tagModalCallback = null;
//document.getElementById('tag-list').onclick = (e) => setTag(e.target.dataset.tag); //document.getElementById('tag-list').onclick = (e) => setTag(e.target.dataset.tag);
@@ -160,7 +258,11 @@
pageNumPending = null, pageNumPending = null,
scale = 1, scale = 1,
canvas = document.getElementById('inline-viewer'), canvas = document.getElementById('inline-viewer'),
ctx = canvas.getContext('2d'); ctx = canvas.getContext('2d'),
draggedTag = null,
dragMode = null,
dragOrigStart = null,
dragOrigEnd = null;
/** /**
* Get page info from document, resize canvas accordingly, and render page. * Get page info from document, resize canvas accordingly, and render page.
@@ -245,6 +347,7 @@
pdfDoc = pdfDoc_; pdfDoc = pdfDoc_;
const pageList = document.getElementById('page-list'); const pageList = document.getElementById('page-list');
const tagGrid = document.querySelector('.tag-grid');
pageList.innerHTML = ''; pageList.innerHTML = '';
for (var i=0; i<pdfDoc.numPages; i++) { for (var i=0; i<pdfDoc.numPages; i++) {
let page = i+1; let page = i+1;
@@ -258,10 +361,47 @@
pageList.appendChild(el); pageList.appendChild(el);
} }
tagGrid.addEventListener('dragover', (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (draggedTag) {
const rect = pageList.getBoundingClientRect();
const y = e.clientY - rect.top;
const page = Math.min(Math.max(Math.floor(y / 25) + 1, 1), pdfDoc.numPages);
if (dragMode === 'resize') {
if (page >= parseInt(draggedTag.dataset.start) && page !== parseInt(draggedTag.dataset.end)) {
draggedTag.dataset.end = page;
updateTag(draggedTag);
resolveOverlaps();
}
} else {
const length = dragOrigEnd - dragOrigStart;
if (page !== parseInt(draggedTag.dataset.start)) {
draggedTag.dataset.start = page;
draggedTag.dataset.end = Math.min(page + length, pdfDoc.numPages);
updateTag(draggedTag);
resolveOverlaps();
}
}
}
});
tagGrid.addEventListener('drop', (e) => {
e.preventDefault();
if (draggedTag) {
draggedTag = null;
dragMode = null;
}
resolveOverlaps();
});
document.getElementById('page-count').textContent = pdfDoc.numPages; document.getElementById('page-count').textContent = pdfDoc.numPages;
// Initial/first page rendering // Initial/first page rendering
renderPage(pageNum); renderPage(pageNum);
setTimeout(() => {
const previewBox = document.querySelector('.box');
tagGrid.style.maxHeight = previewBox.offsetHeight + 'px';
}, 100);
tagArea.innerHTML = ''; tagArea.innerHTML = '';
for (let pageTag of data.pageTags) { for (let pageTag of data.pageTags) {
@@ -270,105 +410,238 @@
dirty = false; dirty = false;
}); });
function closeAddModal() { function showTagModal(callback, tag) {
document.getElementById('add-modal').classList.remove('is-active'); document.getElementById("tag-modal").classList.add("is-active");
document.getElementById('add-instrument-name').value = "";
document.getElementById('add-instrument-variant').value = ""; tagModalCallback = callback;
if(tag) {
const parts = parseTag(tag);
console.log(parts);
document.querySelector("#tag-prefix").value = parts.prefix;
onChangeTagType();
document.querySelectorAll(".tag-name").forEach(el => el.value = parts.name);
document.querySelector(".tag-number").value = parts.number;
} else {
onChangeTagType();
}
} }
function addInstrument() { function closeTagModal() {
let name = document.getElementById('add-instrument-name'); document.getElementById('tag-modal').classList.remove('is-active');
let variant = document.getElementById('add-instrument-variant'); }
let inst_name = name.value; function addNumberedInstrument(tag, e) {
var tag = null; let modal = document.getElementById('add-modal');
document.getElementById("add-instrument-name").value = data.instruments[tag];
document.getElementById("add-instrument-variant").value = 1;
document.getElementById("add-instrument-variant").focus();
modal.classList.add('is-active');
}
function onChangeTagType() {
const tag_prefix = document.querySelector("#tag-prefix");
const selected = tag_prefix.value;
document.querySelectorAll(".tag-option").forEach((x) => {x.classList.add("is-hidden")});
document.querySelectorAll(".tag-option input").forEach((x) => {x.value=""});
document.querySelectorAll(".enable-" + selected).forEach((x) => {x.classList.remove("is-hidden")});
tag_prefix.focus();
}
function confirmTagModal() {
const selected = document.querySelector("#tag-prefix").value;
let name = document.querySelector("#tag-name").value;
const n = document.querySelector("#tag-number").value;
switch (selected) {
case "no":
if (!n) throw new Error("Missing number");
case "page":
name = document.querySelector("#tag-name-page").value;
case "inst":
name = document.querySelector("#tag-name-inst").value;
for (let key in data.instruments) { for (let key in data.instruments) {
if (data.instruments[key] == inst_name) { if (data.instruments[key] == name) {
tag = key; name = key;
break; break;
} }
} }
}
name = name.replace(" ", "_");
if (!tag) { const prefix = (selected == "inst") ? "" : selected + ":";
alert("Unknown tag: " + name);
return; const tag = (n) ? prefix + name + "-" + n : prefix + name;
closeTagModal();
tagModalCallback(tag);
} }
if (variant.value) {
tag += "-" + variant.value;
}
variant.value = '';
name.value = '';
let li = document.createElement('li');
li.classList.add("is-clickable");
li.addEventListener('click', () => assignInstrument(tag, li));
li.innerHTML = get_instrument(tag);
document.getElementById('unassigned-area').appendChild(li);
function createNewTag(tag) {
addTag(tag, pageNum, pageNum); addTag(tag, pageNum, pageNum);
closeAddModal(); dirty = true;
} }
function assignInstrument(tag, el) { function assignTag(tag, el) {
addTag(tag, pageNum, pageNum); addTag(tag, pageNum, pageNum);
//el.remove(); //el.remove();
} }
function editTag(e) {
console.log("EDIT", e.target);
showTagModal((tag) => {
console.log("Updating to " + tag);
e.target.innerHTML = get_tag_name(tag);
e.target.parentNode.dataset["tag"] = tag;
dirty = true;
}, e.target.parentNode.dataset["tag"]);
}
function addTag(tag, start, end) { function addTag(tag, start, end) {
console.log("addTag", tag, start, end);
const el = document.createElement('div');
el.className = 'grid-tag'; //console.log("addTag", tag, start, end);
el.dataset.tag = tag; const fragment = document.importNode(document.querySelector("#blank-tag").content, true);
const el = fragment.querySelector("div");
const tagName = el.querySelector(".tag-name");
tagName.innerHTML=get_tag_name(tag);
tagName.addEventListener('dblclick', editTag);
//el.className = 'grid-tag';
el.dataset.start = start; el.dataset.start = start;
el.dataset.end = end; el.dataset.end = end;
el.dataset.tag = tag;
let setStart = document.createElement('span'); const parts = parseTag(tag)
setStart.className = "icon is-action"; el.classList.add("tag-type-" + parts.prefix);
setStart.innerHTML = '<i class="fas fa-sort-amount-down" title="Set start page"></i>';
setStart.addEventListener('click', () => setTagStart(el));
el.appendChild(setStart);
let label = document.createElement('span'); const handle = el.querySelector('.tag-handle');
handle.draggable = true;
let name = document.createElement('b'); handle.addEventListener('dragstart', (e) => {
name.innerHTML = get_instrument(tag); draggedTag = el;
label.appendChild(name); dragMode = 'move';
dragOrigStart = parseInt(el.dataset.start);
let del = document.createElement('span'); dragOrigEnd = parseInt(el.dataset.end);
del.className = "icon is-action"; el.style.zIndex = 1000;
del.innerHTML = '<i class="fas fa-trash-alt" title="Remove this tag"></i>'; e.dataTransfer.effectAllowed = 'move';
del.addEventListener('click', () => { const blankImg = new Image();
el.remove(); blankImg.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
dirty=true; e.dataTransfer.setDragImage(blankImg, 0, 0);
});
handle.addEventListener('dragend', () => {
if (draggedTag === el && dragMode === 'move') {
el.dataset.start = dragOrigStart;
el.dataset.end = dragOrigEnd;
updateTag(el);
el.remove();
dirty = true;
} else if (draggedTag === el) {
el.dataset.start = dragOrigStart;
el.dataset.end = dragOrigEnd;
updateTag(el);
el.style.zIndex = '';
}
resolveOverlaps();
draggedTag = null;
dragMode = null;
}); });
label.appendChild(del)
el.appendChild(label); const resize = el.querySelector('.tag-handle-resize');
resize.draggable = true;
resize.addEventListener('dragstart', (e) => {
let setEnd = document.createElement('span'); draggedTag = el;
setEnd.className = "icon is-action"; dragMode = 'resize';
setEnd.innerHTML = '<i class="fas fa-sort-amount-up" title="Set end page"></i>'; dragOrigEnd = parseInt(el.dataset.end);
setEnd.addEventListener('click', () => setTagEnd(el)); el.style.zIndex = 1000;
el.appendChild(setEnd); e.dataTransfer.effectAllowed = 'move';
const blankImg2 = new Image();
blankImg2.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
e.dataTransfer.setDragImage(blankImg2, 0, 0);
e.stopPropagation();
});
resize.addEventListener('dragend', () => {
if (draggedTag === el) {
el.dataset.end = dragOrigEnd;
updateTag(el);
el.style.zIndex = '';
}
resolveOverlaps();
draggedTag = null;
dragMode = null;
});
updateTag(el); updateTag(el);
tagArea.appendChild(el); tagArea.appendChild(el);
resolveOverlaps();
} }
function updateTag(tag) { function updateTag(tag) {
let start = tag.dataset.start; let start = tag.dataset.start;
let end = tag.dataset.end; let end = tag.dataset.end;
let span = end-start+1; let span = end-start+1;
let height = span * 25 + (span-1) * 5; let height = span * 25 + 1;
let top = (start-1) * 30; let top = (start-1) * 25;
tag.style.height = height + 'px'; tag.style.height = height + 'px';
tag.style.marginTop = top + 'px'; tag.style.marginTop = top + 'px';
dirty = true; dirty = true;
const name = tag.querySelector('.tag-name');
const handle = tag.querySelector('.tag-handle');
if (name) {
if (span >= 3) {
name.style.position = 'absolute';
name.style.top = '4px';
name.style.right = '20px';
name.style.writingMode = 'vertical-rl';
tag.style.justifyContent = 'flex-end';
if (handle) handle.style.marginLeft = 'auto';
} else {
name.style.position = '';
name.style.top = '';
name.style.right = '';
name.style.writingMode = '';
tag.style.justifyContent = '';
if (handle) handle.style.marginLeft = '';
}
}
}
function resolveOverlaps() {
const tags = Array.from(tagArea.children);
tags.sort((a, b) => parseInt(a.dataset.start) - parseInt(b.dataset.start));
const groups = [];
let current = [tags[0]];
for (let i = 1; i < tags.length; i++) {
const prevEnd = parseInt(tags[i-1].dataset.end);
const currStart = parseInt(tags[i].dataset.start);
if (currStart <= prevEnd) {
current.push(tags[i]);
} else {
groups.push(current);
current = [tags[i]];
}
}
if (current.length) groups.push(current);
for (let group of groups) {
group.sort((a, b) => {
const spanA = parseInt(a.dataset.end) - parseInt(a.dataset.start);
const spanB = parseInt(b.dataset.end) - parseInt(b.dataset.start);
return spanB - spanA;
});
const n = group.length;
group.forEach((tag, i) => {
tag.style.minWidth = (120 + (n - 1 - i) * 70) + 'px';
tag.style.zIndex = i;
});
}
} }
function setTagStart(el) { function setTagStart(el) {
@@ -390,6 +663,10 @@
} }
function expandEntries() { function expandEntries() {
const answer = confirm("This will expand all tags to fill up available pages");
if (!answer) return;
const entries = Array.from(tagArea.children); const entries = Array.from(tagArea.children);
entries.sort((a, b) => a.dataset.start-b.dataset.start); entries.sort((a, b) => a.dataset.start-b.dataset.start);
const c = entries.length; const c = entries.length;
@@ -402,6 +679,33 @@
updateTag(entries[c-1]); updateTag(entries[c-1]);
} }
const body = document.querySelector("body");
function keymap(e) {
if (e.target != body) return;
switch(e.key) {
case "ArrowRight":
case "l":
case "ArrowDown":
case "j":
onNextPage();
break;
case "ArrowLeft":
case "h":
case "ArrowUp":
case "k":
onPrevPage();
break;
case "n":
showTagModal(createNewTag);
break;
default:
return;
}
e.preventDefault();
}
body.addEventListener("keydown", keymap)
function saveTags() { function saveTags() {
const pageTags = []; const pageTags = [];
for (let pageTag of tagArea.children ) { for (let pageTag of tagArea.children ) {
@@ -430,13 +734,43 @@
dirty = false; dirty = false;
} }
function get_instrument(s) { function parseTag(tag) {
let parts = s.split('-'); const result = {
let instrument = data.instruments[parts[0]]; prefix: "inst",
if (parts.length == 2) { name: "",
return instrument + " " + parts[1]; number: "",
};
let parts = tag.split(":");
if(parts.length == 2) {
result.prefix = parts[0];
tag = parts[1];
} }
return instrument;
parts = tag.split('-');
if(parts.length == 2) {
result.number = parts[1];
}
result.name = parts[0];
if(result.prefix == "inst") {
result.name = data.instruments[result.name] || result.name;
}
result.name = result.name.replace("_", " ");
return result;
}
function get_tag_name(s) {
tag = parseTag(s);
console.log(tag);
let prefix = (tag.prefix == "inst") ? "" : data.prefixes[tag.prefix] + " ";
return (tag.number) ? prefix + tag.name + " " + tag.number : prefix + tag.name;
} }
function checkSaved(e) { function checkSaved(e) {
@@ -1,7 +1,10 @@
{% load path_filters %} {% load path_filters %}
{% load polyphonic %}
<tr> <tr>
<td><a href="{{ doc.upload.url }}" target="_blank"> <td style="white-space: nowrap">
{{ doc.upload.name|basename }}</a></td> <a href="{{ doc.upload.url }}" target="_blank">
{{ doc.upload.name|basename }}</a>
</td>
<td> <td>
{% for section in doc.sections.all %} {% for section in doc.sections.all %}
<a class="tag is-{{ section.bulma_class }}" target="_blank" href="{% url 'part_download' collection.pk section.pk section.filename %}">{{ section.name }}</a> <a class="tag is-{{ section.bulma_class }}" target="_blank" href="{% url 'part_download' collection.pk section.pk section.filename %}">{{ section.name }}</a>
@@ -10,10 +13,13 @@
<td class="has-text-right" style="white-space: nowrap;"> <td class="has-text-right" style="white-space: nowrap;">
{% if request.is_admin %} {% if request.is_admin %}
{% if doc.doctype == 1 %} {% if doc.doctype == 1 %}
<a href="{% url 'document_annotate' collection.pk doc.pk %}"><i class="fas fa-tags" <a href="{% url 'document_annotate' collection.pk doc.pk %}" title="Annotate">
title="Manage Tags"></i></a> {% icon "toc" %}
</a>
{% endif %} {% endif %}
<a href="{% url 'document_delete' collection.pk doc.pk %}"><i class="fas fa-trash-alt" title="Delete Document"></i></a> <a href="{% url 'document_delete' collection.pk doc.pk %}" title="Delete">
{% icon "delete" %}
</a>
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
@@ -1,12 +1,18 @@
{% extends "interface/project_base.html" %} {% extends "interface/project_base.html" %}
{% load polyphonic %}
{% block admin %} {% block admin %}
<a href="{% url 'work_detail' collection=collection.pk pk=object.pk %}" class="button is-link is-light"> <a href="{% url 'work_detail' collection=collection.pk pk=object.pk %}" class="button is-link is-light">
{% icon "backspace" %}
<span>Back to work</span> <span>Back to work</span>
</a> </a>
{% endblock %} {% endblock %}
{% block page %} {% block page %}
<h2 class="title">
{% icon "add_to_drive" %}
<span>Google Drive - Shared Links
</h2>
<h3 class="subtitle"><a href="{% url 'work_detail' collection.pk object.pk %}">{{ object.name }}</a></h3> <h3 class="subtitle"><a href="{% url 'work_detail' collection.pk object.pk %}">{{ object.name }}</a></h3>
<div class="m-3"> <div class="m-3">
<p>This page lets you link a work to a google drive folder. You can either paste a public link to a folder to enable syncing or a file to add individually</p> <p>This page lets you link a work to a google drive folder. You can either paste a public link to a folder to enable syncing or a file to add individually</p>
@@ -14,25 +20,23 @@
<div class="m-3"> <div class="m-3">
<p> <p>
{% if meta.folderid %} {% if meta.folderid %}
This work is currently linked to <b>{{ meta.folderid }}</b>. Pasting a new folder link will overwrite this. This work is currently linked to <b>{{ meta.folderid }}</b>.<br/>
Pasting a new folder link will overwrite this.
{% else %} {% else %}
There is currently no shared drive folder linked to this work - paste one here to enable syncing. There is currently no shared drive folder linked to this work - paste one here to enable syncing.
{% endif %} {% endif %}
</p> </p>
</div> </div>
<div> <div class="mt-5">
<form method="post"> <form method="post">
<div class="field"> <div class="field has-addons mx-6">
<div class="control"> <div class="control is-expanded">
<input name="link" class="input is-expanded" type="text" placeholder="Shared link"> <input class="input" name="link" type="text" placeholder="Paste shared link">
</div> </div>
{% for error in form.errors.link %}
<p class="help is-danger">{{ error }}</p>
{% endfor %}
</div>
<div class="field">
<div class="control"> <div class="control">
<button class="button is-info" type="submit">Add</button> <button class="button" type="submit">
Add
</button>
</div> </div>
</div> </div>
{% csrf_token %} {% csrf_token %}
@@ -1,8 +1,7 @@
{% extends "interface/project_base.html" %} {% extends "interface/project_base.html" %}
{% load polyphonic %}
{% block page %} {% block page %}
<form action="" method="post" target="_blank">
{% csrf_token %}
<div> <div>
<div class="field is-grouped is-grouped-centered is-grouped-multiline"> <div class="field is-grouped is-grouped-centered is-grouped-multiline">
<div class="field has-addons control has-addons-centered is-expanded"> <div class="field has-addons control has-addons-centered is-expanded">
@@ -15,7 +14,7 @@
<input type="text" class="input" list="instrument-list" id="instrument-name" onchange="updateParts()"/> <input type="text" class="input" list="instrument-list" id="instrument-name" onchange="updateParts()"/>
<datalist id="instrument-list"> <datalist id="instrument-list">
{% for inst in instruments %} {% for inst in instruments %}
<option value="{{inst.1}}"/> <option value="{{inst}}"/>
{% endfor %} {% endfor %}
</datalist> </datalist>
</span> </span>
@@ -31,20 +30,18 @@
</span> </span>
</span> </span>
</div> </div>
<span class="control">
<button type="submit" class="button is-primary">
<span class="icon"><i class="fas fa-copy"></i></span>
<span>Get My Parts!</span>
</button>
</span>
</div> </div>
</div> </div>
<form action="" method="post" target="_blank">
{% csrf_token %}
<table class="table is-striped mx-auto"> <table class="table is-striped mx-auto">
<thead> <thead>
<tr> <tr>
<th/> <th/>
<th>Piece</th> <th>Piece</th>
<th>Composer</th>
<th class="is-hidden-mobile">Running time</th> <th class="is-hidden-mobile">Running time</th>
<th>Part</th> <th>Part</th>
<th/> <th/>
@@ -61,11 +58,12 @@
{{ item.work.name }} {{ item.work.name }}
{% endif %} {% endif %}
</td> </td>
<td class="is-hidden-mobile">{% firstof item.work.running_time "------" %}</td> <td class="is-hidden-mobile">{{ item.work.composer }}</td>
<td class="is-hidden-mobile">{% firstof item.work.running_time "-:--" %}</td>
<td class="select-cell"> <td class="select-cell">
<input type="hidden" name="works" value="{{ item.work.pk }}"/> <input type="hidden" name="works" value="{{ item.work.pk }}"/>
<span class="select is-small" style="width: 100%"> <span class="select is-small" style="width: 100%">
<select name="instruments" id="part-{{ item.work.pk }}" style="width: 100%"> <select name="instrument-selection" id="part-{{ item.work.pk }}" style="width: 100%">
<option value='-'>None</option> <option value='-'>None</option>
{% for tag, name in item.work.digital_parts %} {% for tag, name in item.work.digital_parts %}
<option value='{{ tag }}'>{{ name }}</option> <option value='{{ tag }}'>{{ name }}</option>
@@ -74,8 +72,9 @@
</span> </span>
</td> </td>
<td> <td>
<span class="is-action" onclick="downloadPart({{ item.work.collection_id }}, {{ item.work.pk }})"> <span class="button is-link is-small" onclick="downloadPart({{ item.work.collection_id }}, {{ item.work.pk }})">
<i class="fas fa-download" title="Download Part"></i> {% icon "download" %}
<span>Get</span>
</span> </span>
</td> </td>
</tr> </tr>
@@ -85,13 +84,17 @@
<tr> <tr>
<td/> <td/>
<td/> <td/>
<td>{% firstof running_time "------" %}</td>
<td/> <td/>
<td> <td>{% firstof running_time "-:--" %}</td>
<!-- <td colspan="2">
<button class="button is-link is-small" type="submit"><span class="icon"><i class="fas fa-copy"></i></span><span>Single combined PDF</span></button> <button class="button is-link is-small" type="submit" name="action" value="pdf">
<a class="button is-link is-small"><span class="icon"><i class="fas fa-archive"></i></span><span>Individual files (zipped)</span></a> {% icon "two_pager" %}
--> <span>&nbsp;Single combined PDF</span>
</button>
<button class="button is-link is-small" type="submit" name="action" value="zip">
{% icon "folder_zip" %}
<span>&nbsp;Individual files (zipped)</span>
</button>
</td> </td>
</tr> </tr>
</tfoot> </tfoot>
@@ -114,39 +117,47 @@ const INSTRUMENTS = JSON.parse(document.getElementById('instruments').innerText)
function updateParts() { function updateParts() {
var inst = document.getElementById("instrument-name").value; var inst = document.getElementById("instrument-name").value;
window.localStorage.setItem('instrument-name', inst); window.localStorage.setItem('instrument-name', inst);
console.log("Changing to", inst);
for (let i of INSTRUMENTS) { for (let i in INSTRUMENTS) {
if (i[1] === inst) inst = i[0]; if (i.toLowerCase() === inst.toLowerCase()) {
inst = i;
break;
}
} }
let part = document.getElementById("part-preference").value; let part = document.getElementById("part-preference").value;
window.localStorage.setItem('part-preference', part); window.localStorage.setItem('part-preference', part);
console.log("Part preference:", inst, part);
selectParts(inst, part); selectParts(INSTRUMENTS[inst] || [inst.toLowerCase()], part);
} }
function selectParts(inst, part) { function selectParts(codes, part) {
let prefix = inst + "-" + part; let n = parseInt(part);
let instruments = document.getElementsByName("instruments"); let preferences = [];
for(let i=0; i<instruments.length; i++) { for (let i=n; i>0; i--) {
var result = "-" for (let code of codes) preferences.push(code + "-" + i);
let options = instruments[i].children;
for(let j=0; j<options.length; j++) {
let value = options[j].value;
if (value.startsWith(prefix)) {
result = value;
break;
} }
if (result==="-" && value.startsWith(inst)) { preferences.push(...codes);
result = value; console.log("Preferences:", preferences);
let selections = document.getElementsByName("instrument-selection");
for(let i=0; i<selections.length; i++) {
const selection = selections[i];
for(let pref of preferences) {
selection.value = pref;
if(selection.value == pref) break;
} }
}
instruments[i].value = result; if(!selection.value) selection.value = "-";
console.log("Selected:", selection.value);
} }
} }
@@ -159,7 +170,7 @@ function downloadPart(collection, work) {
} }
console.log(part); console.log(part);
let url = "/collections/" + collection + "/works/" + work + "/download?tag=" + part; let url = "/collections/" + collection + "/works/" + work + "/download?tag=" + part;
window.location = url; window.open(url, "_blank");
} }
document.getElementById("instrument-name").value = localStorage.getItem('instrument-name', 'All'); document.getElementById("instrument-name").value = localStorage.getItem('instrument-name', 'All');
@@ -1,18 +1,22 @@
{% extends "interface/project_base.html" %} {% extends "interface/project_base.html" %}
{% load polyphonic %}
{% block admin %} {% block admin %}
<a href="{% url 'item_list_append' project.pk %}" class="button is-link"> <a href="{% url 'project_work_list' project.pk %}" class="button is-link">
<span class="icon"><i class="fas fa-plus-circle"></i></span> {% icon "add_circle" %}
<span>Add</span> <span>Add</span>
</a> </a>
<a href="#" onclick="save()" class="button is-link"> <a href="#" onclick="save()" class="button is-link">
<span class="icon"><i class="fas fa-save"></i></span> {% icon "save" %}
<span>Save</span> <span>Save</span>
</a> </a>
{% endblock %} {% endblock %}
{% block page %} {% block page %}
<table style="max-width: 600px; margin: 10pt auto;" class="zebra"> <div class="columns">
<div class="column card">
<table style="max-width: 600px; margin: 10pt auto;" class="table is-striped is-narrow">
<thead> <thead>
<tr> <tr>
<th>Item</th> <th>Item</th>
@@ -24,11 +28,14 @@
{% for item in object_list %} {% for item in object_list %}
<tr data-pk="{{ item.pk }}" data-order="{{ forloop.counter }}"> <tr data-pk="{{ item.pk }}" data-order="{{ forloop.counter }}">
<td>{{ item.work.name }}</td> <td>{{ item.work.name }}</td>
<td>{{ item.work.duration }}</td> <td style="min-width: 100px;">{% firstof item.work.duration '-:--' %}</td>
<td style="text-align: center;"> <td style="text-align: center;">
<i class="fas fa-arrow-up clickable" title="Move up" onclick="moveItem({{ item.pk }}, -1)"></i> <span class="clickable is-clickable" title="Remove" onClick="removeItem({{ item.pk }})">
<i class="fas fa-arrow-down clickable" title="Move down" onclick="moveItem({{ item.pk }}, 1)"></i> {% icon "delete" %}
<i class="fas fa-trash clickable" title="Remove" onClick="moveItem({{ item.pk }}, 0)"></i> </span>
<span class="clickable drag-handle cursor-drag" title="Reorder">
{% icon "reorder" %}
</span>
</td> </td>
</tr> </tr>
@@ -36,7 +43,18 @@
<tr><td colspan="2">No items</td></tr> <tr><td colspan="2">No items</td></tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
<div>
</div>
{% endblock %}
{% block style %}
{{ block.super }}
<style>
tr.dragging { opacity: 0.4; }
.drag-handle { cursor: move; }
</style>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
@@ -44,36 +62,82 @@
let workList = document.getElementById('work-list'); let workList = document.getElementById('work-list');
var dirty = false; var dirty = false;
var dragSrc = null;
var dragSrcNext = null;
function moveItem(pk, dir) { function reorderItems() {
let items = Array.prototype.slice.call(workList.children, 0); let items = Array.prototype.slice.call(workList.children, 0);
var i=0; for (let j = 0; j < items.length; j++) {
pk = "" + pk; items[j].dataset.order = j;
for(i=0; i<items.length; i++) {
if(items[i].dataset.pk === pk) break;
} }
if(i >= items.length) return; }
// check the direction is sensible function removeItem(pk) {
if (i + dir < 0 || i + dir >= items.length) return; let items = Array.prototype.slice.call(workList.children, 0);
pk = "" + pk;
if (dir === 0) { for (let i = 0; i < items.length; i++) {
if (items[i].dataset.pk === pk) {
items[i].dataset.order = -1; items[i].dataset.order = -1;
items[i].style = "display: none"; items[i].style = "display: none";
} else { break;
items[i].dataset.order = parseInt(items[i].dataset.order) + dir;
items[i+dir].dataset.order = parseInt(items[i+dir].dataset.order) - dir;
} }
items.sort((a, b) => parseInt(a.dataset.order) - parseInt(b.dataset.order))
workList.innerHTML = ""
for(let j=0; j<items.length; j++) {
workList.appendChild(items[j]);
} }
dirty = true; dirty = true;
} }
workList.addEventListener('mousedown', function(e) {
let handle = e.target.closest('.drag-handle');
if (handle) handle.closest('tr').draggable = true;
});
workList.addEventListener('mouseup', function(e) {
let handle = e.target.closest('.drag-handle');
if (handle) handle.closest('tr').draggable = false;
});
workList.addEventListener('dragstart', function(e) {
dragSrc = e.target.closest('tr');
if (!dragSrc) return;
dragSrcNext = dragSrc.nextElementSibling;
dragSrc.classList.add('dragging');
e.dataTransfer.setDragImage(e.target, 0, 0);
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', dragSrc.dataset.pk);
});
workList.addEventListener('dragover', function(e) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
let target = e.target.closest('tr');
if (!target || target === dragSrc) return;
let rect = target.getBoundingClientRect();
let midY = rect.top + rect.height / 2;
if (e.clientY < midY) {
workList.insertBefore(dragSrc, target);
} else {
workList.insertBefore(dragSrc, target.nextElementSibling);
}
});
workList.addEventListener('drop', function(e) {
e.preventDefault();
dragSrcNext = null;
reorderItems();
dirty = true;
});
workList.addEventListener('dragend', function(e) {
if (!dragSrc) return;
dragSrc.classList.remove('dragging');
if (dragSrcNext) {
workList.insertBefore(dragSrc, dragSrcNext);
}
dragSrc = null;
dragSrcNext = null;
});
function save() { function save() {
let items = Array.prototype.slice.call(workList.children, 0); let items = Array.prototype.slice.call(workList.children, 0);
@@ -91,7 +155,7 @@ function save() {
}).then((response) => { }).then((response) => {
if (response.ok) { if (response.ok) {
dirty=false; dirty=false;
window.location = "{% url 'item_list' project=project.pk %}"; window.location = "{% url 'project_detail' project=project.pk %}";
} else { } else {
alert("Failed: " + response.statusText) alert("Failed: " + response.statusText)
} }
@@ -1,6 +1,9 @@
<div class="panel"> <div class="panel">
<p class="panel-heading"> <p class="panel-heading">
Items Items
{% if request.is_admin %}
<a href="{% url 'item_list_manage' project=project.pk %}" class="button is-small is-pulled-right">Edit Items</a>
{% endif %}
</p> </p>
{% for item in project.items.all %} {% for item in project.items.all %}
@@ -20,7 +23,7 @@
<div class="modal-background" onclick="closeDownloadModal()"></div> <div class="modal-background" onclick="closeDownloadModal()"></div>
<div class="modal-card"> <div class="modal-card">
<header class="modal-card-head"> <header class="modal-card-head">
<p class="modal-card-title">Parts</p> <p class="modal-card-title">Available Parts</p>
<button class="delete" aria-label="close" onclick="closeDownloadModal()"></button> <button class="delete" aria-label="close" onclick="closeDownloadModal()"></button>
</header> </header>
<section class="modal-card-body" id="download-target"> <section class="modal-card-body" id="download-target">
@@ -1,5 +1,6 @@
{% extends 'interface/project_base.html' %} {% extends 'interface/project_base.html' %}
{% load path_filters %} {% load path_filters %}
{% load polyphonic %}
{% block media %} {% block media %}
<script src="https://unpkg.com/dropzone@5/dist/min/dropzone.min.js"></script> <script src="https://unpkg.com/dropzone@5/dist/min/dropzone.min.js"></script>
@@ -8,11 +9,11 @@
{% block admin %} {% block admin %}
<a href="{% url 'work_edit' collection.pk work.pk %}" class="button is-link"> <a href="{% url 'work_edit' collection.pk work.pk %}" class="button is-link">
<span class="icon"><i class="fas fa-edit"></i></span> {% icon "edit" %}
<span>Edit</span> <span>Edit</span>
</a> </a>
<a href="{% url 'work_add_to_project' collection.pk work.pk %}" class="button is-link"> <a href="{% url 'work_add_to_project' collection.pk work.pk %}" class="button is-link">
<span class="icon"><i class="fas fa-plus-circle"></i></span> {% icon "add_ad" %}
<span>Add to project</span> <span>Add to project</span>
</a> </a>
{% endblock %} {% endblock %}
@@ -68,8 +69,8 @@
<div class="column is-half"> <div class="column is-half">
<div class="box"> <div class="box">
<h4 class="subtitle is-size-4"> <h4 class="subtitle is-size-4">
<span class="icon"><i class="fas fa-book"></i></span> {% icon "menu_book" %}
Printed Parts <span>Printed Parts</span>
</h4> </h4>
<div class="tags"> <div class="tags">
{% for inst, c in work.physical_parts %} {% for inst, c in work.physical_parts %}
@@ -81,8 +82,8 @@
</div> </div>
<div class="box"> <div class="box">
<h4 class="subtitle is-size-4"> <h4 class="subtitle is-size-4">
<span class="icon"><i class="fas fa-print"></i></span> {% icon "file_copy" %}
Digital Parts <span>Digital Parts</span>
</h4> </h4>
<div class="tags"> <div class="tags">
{% with sections=work.digital_parts %} {% with sections=work.digital_parts %}
@@ -105,8 +106,8 @@
<div class="box"> <div class="box">
<div class="level"> <div class="level">
<h4 class="subtitle is-size-4"> <h4 class="subtitle is-size-4">
<span class="icon"><i class="fas fa-file"></i></span> {% icon "file_present" %}
Files <span>Files<span>
</h4> </h4>
</div> </div>
<div class="columns"> <div class="columns">
@@ -129,16 +130,19 @@
{% if request.is_admin %} {% if request.is_admin %}
<div class="column is-one-quarter"> <div class="column is-one-quarter">
<h4 class="is-size-5">Add Files</h4> <h4 class="is-size-5">Add Files</h4>
{% if "gdrive" in methods %}
<div class="has-text-centered mt-3">
<a class="button button-primary" href="{% url 'work_gdrive' collection.pk object.pk %}">Link Google Drive Files</a><br/>
</div>
{% endif %}
{% if "upload" in methods %} {% if "upload" in methods %}
<form action="{% url 'document_add' collection.pk object.pk %}" class="dropzone" id="doc-upload" style="-moz-user-select: none"> <form action="{% url 'document_add' collection.pk object.pk %}" class="dropzone" id="doc-upload" style="-moz-user-select: none">
{% csrf_token %} {% csrf_token %}
</form> </form>
{% endif %} {% endif %}
{% if "gdrive" in methods %}
<div class="has-text-centered mt-3">
<a class="button button-is-primary is-size-7" href="{% url 'work_gdrive' collection.pk object.pk %}">
{% icon "add_to_drive" %}
<span>Google Drive</span>
</a><br/>
</div>
{% endif %}
</div> </div>
{% endif %} {% endif %}
</div> </div>
@@ -149,12 +153,14 @@
<div class="box"> <div class="box">
<div class="level"> <div class="level">
<h4 class="is-size-4"> <h4 class="is-size-4">
<span class="icon"><i class="fas fa-book-reader"></i></span> {{ "folder_open"| icon }}
Loans Projects
</h4> </h4>
<span class="level-right"> <span class="level-right">
<a class="icon-text" href="{% url 'work_add_to_project' collection.pk work.pk %}"><span class="icon"><i <a class="icon-text" href="{% url 'work_add_to_project' collection.pk work.pk %}">
class="fas fa-plus-circle"></i></span> Checkout</a> {% icon "shopping_cart_checkout" %}
<span>Checkout</span>
</a>
</span> </span>
</div> </div>
<table class="table is-fullwidth"> <table class="table is-fullwidth">
@@ -178,7 +184,7 @@
</tr> </tr>
{% empty %} {% empty %}
<tr> <tr>
<td>No current loans</td> <td>No current assignments</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
@@ -1,13 +1,20 @@
{% extends "interface/project_base.html" %} {% extends "interface/project_base.html" %}
{% load url_tools %} {% load url_tools %}
{% load polyphonic %}
{% block admin %} {% block admin %}
{% if collection %} {% if collection %}
<a href="{% url 'work_add' collection.pk %}" class="button is-link"> <a href="{% url 'work_add' collection.pk %}" class="button is-link">
<span class="icon"><i class="fas fa-plus-circle"></i></span> {% icon "add_notes" %}
<span>Add a work</span> <span>Add a work</span>
</a> </a>
{% endif %} {% endif %}
{% if project %}
<a href="{% url 'item_list_manage' project.pk %}" class="button">
{% icon "add_notes" %}
<span>Back</span>
</a>
{% endif %}
{% endblock %} {% endblock %}
{% block page %} {% block page %}
@@ -18,7 +25,9 @@
<input class="input" name="q" type="text" placeholder="Filter" value="{{ request.GET.q }}"/> <input class="input" name="q" type="text" placeholder="Filter" value="{{ request.GET.q }}"/>
</div> </div>
<div class="control"> <div class="control">
<a class="button" href="?"><i class="fas fa-times"></i></a> <a class="button" href="?">
{% icon "close" %}
</a>
</div> </div>
</div> </div>
</form> </form>
@@ -27,7 +36,7 @@
<div class="has-text-centered"> <div class="has-text-centered">
<a href="?">_</a> <a href="?">_</a>
{% for letter in letters %} {% for letter in letters %}
<a href="?start={{ letter }}">{{ letter }}</a>&nbsp; <a href="?start={{ letter }}&sort={{ sort }}">{{ letter }}</a>&nbsp;
{% endfor %} {% endfor %}
</div> </div>
{% endif %} {% endif %}
@@ -35,10 +44,11 @@
<table class="table is-striped is-fullwidth"> <table class="table is-striped is-fullwidth">
<thead> <thead>
<tr> <tr>
<th>Work</th> <th>Work <span class="icon-text"><a href="?start={{ start }}&sort=name">{{ "list_arrow"|icon }}</a></span></th>
<th>Composer</th> <th>Composer <span class="icon-text"><a href="?start={{ start }}&sort=composer">{{ "list_arrow"|icon }}</a></span></th>
<th class="is-hidden-mobile">Edition</th> <th class="is-hidden-mobile">Edition</th>
{% if not collection %}<th class="is-hidden-touch">Collection</th>{% endif %} {% if not collection and not project %}<th class="is-hidden-touch">Collection</th>{% endif %}
{% if project %}<th/>{% endif %}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -49,7 +59,14 @@
</td> </td>
<td title="{{ work.composer }}">{{ work.composer|truncatewords:3 }}</td> <td title="{{ work.composer }}">{{ work.composer|truncatewords:3 }}</td>
<td class="is-hidden-mobile" title="{{ work.edition }}">{{ work.edition|truncatewords:2 }}</td> <td class="is-hidden-mobile" title="{{ work.edition }}">{{ work.edition|truncatewords:2 }}</td>
{% if not collection %}<td class="is-hidden-touch">{{ work.collection.name }}</td>{% endif %} {% if not collection and not project %}<td class="is-hidden-touch">{{ work.collection.name }}</td>{% endif %}
{% if project %}
<td>
<button data-work="{{ work.pk }}" onclick="addToProject({{ work.pk }})" class="button is-primary is-small">
{% if work.pk in current %} Added {% else %} Add {% endif %}
</button>
</td>
{% endif %}
</tr> </tr>
{% empty %} {% empty %}
<tr><td colspan="4">No works found</td></tr> <tr><td colspan="4">No works found</td></tr>
@@ -65,6 +82,7 @@
{% else %} {% else %}
disabled disabled
{% endif %}> {% endif %}>
{% icon "arrow_back" %}
Previous</a> Previous</a>
<a class="pagination-next" <a class="pagination-next"
{% if page_obj.has_next %} {% if page_obj.has_next %}
@@ -73,6 +91,7 @@
disabled disabled
{% endif %}> {% endif %}>
Next Next
{% icon "arrow_forward" %}
</a> </a>
<ul class="pagination-list"> <ul class="pagination-list">
{% for page in page_range %} {% for page in page_range %}
@@ -95,3 +114,24 @@ Query="{{ meta.query }}"
</footer> </footer>
{% endblock %} {% endblock %}
{% block scripts %}
<script>
async function addToProject(work) {
console.log("ADD", work);
const response = await fetch("", {
method: "POST",
headers: {"Content-Type": "application/x-www-form-urlencoded"},
body: new URLSearchParams({ work, csrfmiddlewaretoken: "{{ csrf_token }}" }),
});
if (response.status === 201) {
document.querySelector(`[data-work='${work}']`).innerHTML = "Added";
} else {
alert("Failed to add work\nCheck console for details");
console.error(response);
}
console.log(response);
}
</script>
{% endblock %}
@@ -1,27 +1,10 @@
<h3 class="subtitle">{{ work.name }}</h3> <h3 class="subtitle">{{ work.name }}</h3>
<div class="block tags"> <div class="block tags">
{% for tag, name in work.digital_parts %} {% for tag, name in work.digital_parts %}
<a class="tag is-info" href="{% url 'work_download' work.collection_id work.pk %}?tag={{ tag }}" target="polyphonic_parts">{{ name }}</a> <a class="tag is-info" href="{% url 'work_download' work.collection_id work.pk %}?tag={{ tag }}" target="_blank">{{ name }}</a>
{% endfor %} {% endfor %}
</div> </div>
<!-- <p class="has-text-right">
<h3 class="subtitle">Files</h3> <a href="{% url 'work_detail' work.collection_id work.pk %}">Full details for {{ work.name }}</a>
<table class="table is-narrow is-fullwidth">
<tbody>
{% for doc in work.pdfs %}
<tr>
<td><a href="{{ doc.upload.url }}">{{ doc.filename }}</a></td>
</tr>
{% empty %}
<tr>
<td>There are no files available for this work</td>
</tr>
{% endfor %}
</tbody>
</table>
-->
<p>
<a href="{% url 'work_detail' work.collection_id work.pk %}">More details...</a>
</p> </p>
@@ -1,4 +1,5 @@
{% extends "interface/project_base.html" %} {% extends "interface/project_base.html" %}
{% load polyphonic %}
@@ -35,12 +36,13 @@
<div class="field is-grouped"> <div class="field is-grouped">
<div class="control"> <div class="control">
<button class="button is-link"> <button class="button is-link">
<span class="icon"><i class="fas fa-print"></i></span> {% icon "print" %}
<span>Print Set</span> <span>Print Set</span>
</button> </button>
</div> </div>
<div class="control"> <div class="control">
<a class="button is-link is-light" href="{% url 'work_detail' collection.pk object.pk %}"> <a class="button is-link is-light" href="{% url 'work_detail' collection.pk object.pk %}">
{% icon "backspace" %}
<span>Cancel</span> <span>Cancel</span>
</a> </a>
</div> </div>
+1 -1
View File
@@ -1,6 +1,6 @@
from doctest import DocTestSuite from doctest import DocTestSuite
from library import music_tags from polyphonic.library import music_tags
def load_tests(loader, tests, ignore): def load_tests(loader, tests, ignore):
+5
View File
@@ -27,6 +27,11 @@ urlpatterns = [
), ),
path("library", views.LibraryWorkListView.as_view(), name="work_list"), path("library", views.LibraryWorkListView.as_view(), name="work_list"),
path("collections", views.CollectionListView.as_view(), name="collection_list"), path("collections", views.CollectionListView.as_view(), name="collection_list"),
path(
"projects/<int:project>/collections",
views.ProjectWorkListView.as_view(),
name="project_work_list",
),
path( path(
"collections/<int:collection>", "collections/<int:collection>",
views.CollectionWorkListView.as_view(), views.CollectionWorkListView.as_view(),
+90 -23
View File
@@ -1,5 +1,5 @@
from django.shortcuts import get_object_or_404, redirect, resolve_url from django.shortcuts import get_object_or_404, redirect, resolve_url
from django.http import HttpRequest from django.http import HttpRequest, HttpResponse
from django.views.generic import TemplateView from django.views.generic import TemplateView
from django.views.generic.detail import DetailView, SingleObjectMixin, View from django.views.generic.detail import DetailView, SingleObjectMixin, View
from django.views.generic.list import ListView from django.views.generic.list import ListView
@@ -11,6 +11,7 @@ from django.utils.timezone import now
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.core.exceptions import SuspiciousOperation from django.core.exceptions import SuspiciousOperation
from django.core.paginator import Paginator from django.core.paginator import Paginator
from django.http import Http404, HttpResponseRedirect from django.http import Http404, HttpResponseRedirect
import json import json
@@ -20,10 +21,16 @@ import string
from polyphonic.interface.views import ProjectMixin, AuthorizedResourceMixin from polyphonic.interface.views import ProjectMixin, AuthorizedResourceMixin
from polyphonic.interface.utils import signed_url from polyphonic.interface.utils import signed_url
from polyphonic.library.models import Collection, Work, Document, Section from polyphonic.library.models import Collection, Work, Document, Section
from polyphonic.library.music_tags import MUSIC_TAGS, MusicTag from polyphonic.library.music_tags import (
MUSIC_TAGS,
MUSIC_NAME_BY_TAG,
TAG_PREFIXES,
TAG_ALIASES,
MusicTag,
)
from polyphonic.library import forms, models from polyphonic.library import forms, models
from polyphonic.library.pdf_utils import extract_pages, extract_and_concat from polyphonic.library.pdf_utils import extract_pages, extract_and_concat
from polyphonic.library.indexer import indexer, model_search from polyphonic.library.indexer import index_works, model_search
import logging import logging
@@ -35,10 +42,10 @@ class ProjectItemListView(ProjectMixin, ListView):
model = models.ProjectItem model = models.ProjectItem
def post(self, request: HttpRequest, **kwargs): def post(self, request: HttpRequest, **kwargs):
project_works = self.project.works.all() project_works = self.project.works.all()
instruments = request.POST.getlist("instruments") print(request.POST)
instruments = request.POST.getlist("instrument-selection")
works = request.POST.getlist("works") works = request.POST.getlist("works")
request.session["part"] = request.POST.get("part", "") request.session["part"] = request.POST.get("part", "")
@@ -65,14 +72,17 @@ class ProjectItemListView(ProjectMixin, ListView):
(part.doc.upload.path, part.doc.work.name, part.start, part.end, 1) (part.doc.upload.path, part.doc.work.name, part.start, part.end, 1)
) )
action = request.POST.get("action")
if action == "pdf":
result = extract_and_concat(sections) result = extract_and_concat(sections)
download_name = f"{self.project.name}.pdf" download_name = f"{self.project.name}.pdf"
response = FileResponse(result, content_type="application/pdf") response = FileResponse(result, content_type="application/pdf")
response["Content-Disposition"] = f'inline; filename="{download_name}"' response["Content-Disposition"] = f'inline; filename="{download_name}"'
return response return response
return HttpResponse(f"Unknown action: {action}", status=400)
def get_queryset(self): def get_queryset(self):
return ( return (
super(ProjectItemListView, self) super(ProjectItemListView, self)
@@ -82,7 +92,7 @@ class ProjectItemListView(ProjectMixin, ListView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
data = super(ProjectItemListView, self).get_context_data(**kwargs) data = super(ProjectItemListView, self).get_context_data(**kwargs)
data["instruments"] = MUSIC_TAGS data["instruments"] = TAG_ALIASES
data["instrument"] = self.request.session.get("instrument", "Score") data["instrument"] = self.request.session.get("instrument", "Score")
data["part"] = self.request.session.get("part", "0") data["part"] = self.request.session.get("part", "0")
data["running_time"] = self.get_queryset().aggregate(Sum("work__running_time"))[ data["running_time"] = self.get_queryset().aggregate(Sum("work__running_time"))[
@@ -196,13 +206,8 @@ class WorkListView(CollectionMixin, TemplateView):
data["meta"] = qs.meta data["meta"] = qs.meta
# data["page_range"] = data["page_obj"]["paginator"] # data["page_range"] = data["page_obj"]["paginator"]
else: else:
qs = self.get_queryset() qs, ctx = self.get_filtered_queryset()
data.update(ctx)
start = self.request.GET.get("start")
if start:
start = start.upper()
qs = qs.filter(name__gte=start, name__lt=start + "~")
data["start"] = start
data["letters"] = string.ascii_uppercase data["letters"] = string.ascii_uppercase
@@ -219,9 +224,27 @@ class WorkListView(CollectionMixin, TemplateView):
def get_collections(self): def get_collections(self):
raise NotImplementedError raise NotImplementedError
def get_queryset(self): def get_filtered_queryset(self):
works = self.get_works() works = self.get_works()
return works.order_by("name", "composer", "edition", "pk").distinct() ctx = {}
sort = self.request.GET.get("sort", "name").lower()
order = ["name", "composer", "edition"]
if sort not in order:
raise KeyError(f"Cannot sort by {sort}")
order.remove(sort)
order = [sort] + order + ["pk"]
ctx["sort"] = sort
start = self.request.GET.get("start")
if start:
start = start.upper()
filters = {f"{sort}__gte": start, f"{sort}__lt": start + "~"}
works = works.filter(**filters)
ctx["start"] = start
return works.order_by(*order).distinct(), ctx
def get_results(self, query, page): def get_results(self, query, page):
try: try:
@@ -278,6 +301,39 @@ class CollectionWorkListView(WorkListView):
return data return data
class ProjectWorkListView(WorkListView, ProjectMixin):
http_method_names = ["get", "post"]
def is_authorized(self):
return ProjectMixin.is_authorized(self)
def post(self, request, project):
work = request.POST.get("work")
print("WORK", work)
self.project.items.create(
work_id=work, checkout=now(), approved_by=request.user, order=100
)
return HttpResponse(status=201)
def get_works(self):
collections = self.get_collections() or []
print(collections)
return Work.objects.filter(collection_id__in=collections)
def get_collections(self):
return self.project.ensemble.allowed_collections.values_list(
"collection_id", flat=True
)
def get_context_data(self, *args, **kwargs):
data = super(ProjectWorkListView, self).get_context_data(*args, **kwargs)
data["title"] = f"Library for {self.project.ensemble}"
data["current"] = set(self.project.items.values_list("work_id", flat=True))
return data
class WorkAddView(CollectionMixin, FormView): class WorkAddView(CollectionMixin, FormView):
template_name = "interface/default_form.html" template_name = "interface/default_form.html"
form_class = forms.WorkCreateForm form_class = forms.WorkCreateForm
@@ -304,8 +360,7 @@ class WorkAddView(CollectionMixin, FormView):
for f in uploads: for f in uploads:
docs.append(work.docs.create(upload=f).pk) docs.append(work.docs.create(upload=f).pk)
ix = indexer.get_index() index_works([work])
indexer.index_works(ix, [work])
if len(docs) == 1: if len(docs) == 1:
return redirect("document_annotate", docs[0]) return redirect("document_annotate", docs[0])
@@ -319,11 +374,13 @@ class WorkDetailView(CollectionMixin, DetailView):
def get_context_data(self, *args, **kwargs): def get_context_data(self, *args, **kwargs):
context = super().get_context_data(*args, **kwargs) context = super().get_context_data(*args, **kwargs)
methods = set("upload") methods = set(["upload"])
match self.collection.storage.storage: match self.collection.storage.storage:
case "library.storage.GDriveLinkStorage": case "library.gdrive.storage.GDriveLinkStorage":
methods.discard("upload") methods.discard("upload")
methods.add("gdrive") methods.add("gdrive")
case _:
methods.add("gdrive")
context["methods"] = methods context["methods"] = methods
return context return context
@@ -337,8 +394,7 @@ class WorkUpdateView(CollectionMixin, UpdateView):
def form_valid(self, form): def form_valid(self, form):
response = super().form_valid(form) response = super().form_valid(form)
ix = indexer.get_index() index_works([self.object])
indexer.index_works(ix, [self.object])
return response return response
@@ -593,8 +649,19 @@ class DocumentAnnotateView(DocumentMixin, DetailView):
collection=data["collection"].pk, collection=data["collection"].pk,
pk=data["document"].pk, pk=data["document"].pk,
) )
data["tag_colours"] = [
("page", "danger"),
("no", "link"),
("mvmt", "link"),
("sect", "success"),
]
data["json_data"] = {"pageTags": pages, "instruments": dict(MUSIC_TAGS)} data["json_data"] = {
"pageTags": pages,
"instruments": MUSIC_NAME_BY_TAG,
"instrumentNames": list({x[1] for x in MUSIC_TAGS}),
"prefixes": TAG_PREFIXES,
}
return data return data
+10
View File
@@ -26,12 +26,22 @@ dependencies = [
django-debug-toolbar = "5.2" django-debug-toolbar = "5.2"
ruff = "^0.15.12" ruff = "^0.15.12"
coverage = "^7.14.0" coverage = "^7.14.0"
django-types = "^0.24.0"
[tool.poetry.scripts] [tool.poetry.scripts]
poly-tool = "polyphonic.manage:main" poly-tool = "polyphonic.manage:main"
[tool.ruff] [tool.ruff]
extend-exclude = ["**/migrations/"] extend-exclude = ["**/migrations/"]
lint.extend-select = [
#"DJ", # flake8-django: Django-specific bugs
#"E", # pycodestyle errors
"F", # Pyflakes
#"W", # pycodestyle warnings
#"I", # isort
#"UP", # pyupgrade
#"B", # flake8-bugbear
]
[build-system] [build-system]
requires = ["poetry-core>=2.0.0,<3.0.0"] requires = ["poetry-core>=2.0.0,<3.0.0"]