Created library app
This commit is contained in:
parent
98fe7b77ce
commit
526ae4f59b
0
library/__init__.py
Normal file
0
library/__init__.py
Normal file
26
library/admin.py
Normal file
26
library/admin.py
Normal file
@ -0,0 +1,26 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from . import models
|
||||
|
||||
class PlaylistInline(admin.TabularInline):
|
||||
model = models.Playlist
|
||||
|
||||
class WorkAdmin(admin.ModelAdmin):
|
||||
list_display = ['name', 'orchestration']
|
||||
inlines = [PlaylistInline]
|
||||
|
||||
class PartInline(admin.TabularInline):
|
||||
model = models.Part
|
||||
fields = ['tag', 'start', 'end']
|
||||
|
||||
class DocumentAdmin(admin.ModelAdmin):
|
||||
list_display = ['work', '__str__']
|
||||
inlines = [PartInline]
|
||||
|
||||
class PlaylistAdmin(admin.ModelAdmin):
|
||||
list_display = ['project', 'work', 'order']
|
||||
list_filter = ['project']
|
||||
|
||||
admin.site.register(models.Work, WorkAdmin)
|
||||
admin.site.register(models.Document, DocumentAdmin)
|
||||
admin.site.register(models.Playlist, PlaylistAdmin)
|
||||
5
library/apps.py
Normal file
5
library/apps.py
Normal file
@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class LibraryConfig(AppConfig):
|
||||
name = 'library'
|
||||
24
library/forms.py
Normal file
24
library/forms.py
Normal file
@ -0,0 +1,24 @@
|
||||
from django import forms
|
||||
from .models import Work
|
||||
|
||||
class WorkCreateForm(forms.ModelForm):
|
||||
uploads = forms.FileField(label="PDFs to upload", widget=forms.ClearableFileInput(attrs={'multiple': True}))
|
||||
|
||||
class Meta:
|
||||
model = Work
|
||||
fields = ['uploads', 'name', 'orchestration', 'running_time', 'notes']
|
||||
|
||||
class PlaylistAddForm(forms.Form):
|
||||
work = forms.ModelChoiceField(queryset=Work.objects.all())
|
||||
|
||||
def __init__(self, instance, *args, **kwargs):
|
||||
super(PlaylistAddForm, self).__init__(*args, **kwargs)
|
||||
|
||||
existing = [ x[0] for x in instance.works.values_list('pk') ]
|
||||
|
||||
qs = Work.objects.filter(ensemble_id=instance.ensemble_id).exclude(id__in=existing)
|
||||
self.fields['work'].queryset = qs
|
||||
self.instance = instance
|
||||
|
||||
def save(self):
|
||||
self.instance.works.add(self.cleaned_data['work'])
|
||||
76
library/migrations/0001_initial.py
Normal file
76
library/migrations/0001_initial.py
Normal file
@ -0,0 +1,76 @@
|
||||
# Generated by Django 3.1.1 on 2021-03-10 22:20
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import library.models
|
||||
import library.storage
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('interface', '0022_auto_20210303_2043'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Document',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('upload', models.FileField(storage=library.storage.RemoteCachedStorage(), upload_to=library.models.upload_filename)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Playlist',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('order', models.SmallIntegerField(default=0)),
|
||||
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='interface.project')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['order', 'work'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Work',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('slug', models.SlugField(editable=False, max_length=100)),
|
||||
('name', models.CharField(max_length=255)),
|
||||
('orchestration', models.CharField(choices=[('SATB', 'SATB'), ('String Quartet', 'String Quartet'), ('Chamber Orchestra', 'Chamber Orchestra'), ('RWE', 'RWE'), ('Custom', 'Custom')], max_length=100)),
|
||||
('running_time', models.IntegerField(blank=True, null=True)),
|
||||
('notes', models.TextField(blank=True)),
|
||||
('ensemble', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='works', to='interface.ensemble')),
|
||||
('projects', models.ManyToManyField(related_name='works', through='library.Playlist', to='interface.Project')),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('ensemble', 'slug')},
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='playlist',
|
||||
name='work',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='library.work'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Part',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('tag', models.SlugField(max_length=20)),
|
||||
('start', models.SmallIntegerField(blank=True, null=True)),
|
||||
('end', models.SmallIntegerField(blank=True, null=True)),
|
||||
('notes', models.TextField(blank=True)),
|
||||
('doc', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='parts', to='library.document')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['doc', 'start', 'pk'],
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='document',
|
||||
name='work',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='docs', to='library.work'),
|
||||
),
|
||||
]
|
||||
0
library/migrations/__init__.py
Normal file
0
library/migrations/__init__.py
Normal file
138
library/models.py
Normal file
138
library/models.py
Normal file
@ -0,0 +1,138 @@
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.utils.text import slugify
|
||||
from django.core.files.storage import get_storage_class
|
||||
|
||||
import re
|
||||
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
library_storage = get_storage_class(settings.LIBRARY_STORAGE)()
|
||||
except (ImportError, AttributeError):
|
||||
library_storage = get_storage_class()()
|
||||
logger.info("Library storage: %s", library_storage.__class__.__name__)
|
||||
|
||||
INSTRUMENTS = [
|
||||
('Score', 'Score'),
|
||||
('S', 'Soprano'),
|
||||
('A', 'Alto'),
|
||||
('T', 'Tenor'),
|
||||
('B', 'Bass'),
|
||||
('V', 'Vocals'),
|
||||
('Vln', 'Violin'),
|
||||
('Vla', 'Viola'),
|
||||
('Vc', 'Violoncello'),
|
||||
('Cb', 'Contrabass'),
|
||||
('Fl', 'Flute'),
|
||||
('Picc', 'Piccolo'),
|
||||
('Cl', 'Clarinet'),
|
||||
('Ob', 'Oboe'),
|
||||
('Hn', 'Horn'),
|
||||
('Tpt', 'Trumpet'),
|
||||
('Tbn', 'Trombone'),
|
||||
('Tuba', 'Tuba'),
|
||||
('Timp', 'Timpani'),
|
||||
('Drum', 'Drupset'),
|
||||
('Perc', 'Percussion'),
|
||||
('Pno', 'Piano'),
|
||||
('Hp', 'Harp'),
|
||||
]
|
||||
|
||||
ORCHESTRATIONS = {
|
||||
'SATB': ('S', 'A', 'T', 'B'),
|
||||
'String Quartet': ('Vln1', 'Vln2', 'Vla', 'Vc'),
|
||||
'Chamber Orchestra': ('Vln1', 'Vln2', 'Vla', 'Vc', 'Cb',
|
||||
'Fl1', 'Fl2', 'Cl1', 'Cl2', 'Hn1', 'Hn2',
|
||||
'Tpt1', 'Tpt2', 'Tbn1', 'Tbn2', 'Tuba',
|
||||
'Timp', 'Drum', 'Perc'),
|
||||
'RWE': ('Fl1', 'Fl2', 'Cl', 'Tbn', 'Vln1', 'Vln2', 'Vla', 'Vc'),
|
||||
'Custom': ()
|
||||
}
|
||||
|
||||
def tag_to_instrument(tag):
|
||||
m = re.match(r'([A-Za-z]+)(\d*)', tag)
|
||||
if not m:
|
||||
return tag
|
||||
l = m.groups()
|
||||
return "{0} {1}".format(dict(INSTRUMENTS).get(l[0],l[0]), l[1]).strip()
|
||||
|
||||
class Work(models.Model):
|
||||
ensemble = models.ForeignKey('interface.Ensemble', on_delete=models.CASCADE, related_name="works")
|
||||
slug = models.SlugField(max_length=100, editable=False)
|
||||
name = models.CharField(max_length=255)
|
||||
orchestration = models.CharField(max_length=100, choices=[ (k, k) for k, v in ORCHESTRATIONS.items() ])
|
||||
running_time = models.IntegerField(null=True, blank=True)
|
||||
notes = models.TextField(blank=True)
|
||||
projects = models.ManyToManyField('interface.Project', through='Playlist', related_name="works")
|
||||
|
||||
class Meta:
|
||||
unique_together = ['ensemble', 'slug']
|
||||
|
||||
@property
|
||||
def parts(self):
|
||||
return Part.objects.filter(doc__work=self.pk)
|
||||
|
||||
@property
|
||||
def instruments(self):
|
||||
tags = ORCHESTRATIONS.get(self.orchestration, 'Custom')
|
||||
return [ (tag, tag_to_instrument(tag)) for tag in tags ]
|
||||
|
||||
def save(self):
|
||||
if not self.slug:
|
||||
self.slug = slugify(self.name)
|
||||
super(Work, self).save()
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
class Playlist(models.Model):
|
||||
project = models.ForeignKey('interface.Project', on_delete=models.CASCADE)
|
||||
work = models.ForeignKey('Work', on_delete=models.CASCADE)
|
||||
order = models.SmallIntegerField(default=0)
|
||||
|
||||
class Meta:
|
||||
ordering = ['order', 'work']
|
||||
|
||||
def __str__(self):
|
||||
return f"<{self.project.slug}:{self.work.slug}>"
|
||||
|
||||
def upload_filename(instance, filename):
|
||||
return f'{instance.work.ensemble.slug}/works/{instance.work.slug}/{filename}'
|
||||
|
||||
class Document(models.Model):
|
||||
work = models.ForeignKey('Work', on_delete=models.CASCADE, related_name="docs")
|
||||
upload = models.FileField(upload_to=upload_filename, storage=library_storage)
|
||||
|
||||
def __str__(self):
|
||||
return self.upload.name
|
||||
|
||||
class Part(models.Model):
|
||||
doc = models.ForeignKey(Document, on_delete=models.CASCADE, related_name="parts")
|
||||
tag = models.SlugField(max_length=20)
|
||||
start = models.SmallIntegerField(null=True, blank=True)
|
||||
end = models.SmallIntegerField(null=True, blank=True)
|
||||
notes = models.TextField(blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['doc', 'start', 'pk']
|
||||
|
||||
@property
|
||||
def instrument(self):
|
||||
return tag_to_instrument(self.tag)
|
||||
|
||||
@property
|
||||
def filename(self):
|
||||
return slugify(f'{self.doc.work.name}_{self.instrument}') + '.pdf'
|
||||
|
||||
@property
|
||||
def pagerange(self):
|
||||
if self.start:
|
||||
if self.end:
|
||||
return f"{self.start}-{self.end}"
|
||||
return str(self.start)
|
||||
return "all"
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.doc.upload} [{self.pagerange}]'
|
||||
46
library/pdf_utils.py
Normal file
46
library/pdf_utils.py
Normal file
@ -0,0 +1,46 @@
|
||||
import tempfile
|
||||
import subprocess
|
||||
import os.path
|
||||
|
||||
def extract_pages(source, bookmark, start=None, end=None):
|
||||
|
||||
return extract_and_concat([(source, bookmark, start, end)])
|
||||
|
||||
def extract_and_concat(items):
|
||||
|
||||
# create a temporary directory for our sections
|
||||
d = tempfile.TemporaryDirectory(prefix="polyphonic_")
|
||||
|
||||
sections = []
|
||||
|
||||
for i, (source, bookmark, start, end) in enumerate(items):
|
||||
|
||||
if start is None:
|
||||
sections.append(source)
|
||||
|
||||
else:
|
||||
|
||||
if end is None:
|
||||
end = start
|
||||
|
||||
dest = os.path.join(d.name, f'section_{i}.pdf')
|
||||
|
||||
cmd = ['gs', '-sDEVICE=pdfwrite', '-q', '-dBATCH', '-dNOPAUSE',
|
||||
f'-dFirstPage={start}', f'-dLastPage={end}',
|
||||
f'-sOutputFile={dest}',
|
||||
source]
|
||||
|
||||
subprocess.run(cmd, check=True)
|
||||
sections.append(dest)
|
||||
|
||||
# concat the items
|
||||
output = tempfile.NamedTemporaryFile(prefix="polyphonic_", suffix='.pdf')
|
||||
|
||||
cmd = ['gs', '-sDEVICE=pdfwrite', '-q', '-dBATCH', '-dNOPAUSE',
|
||||
'-sOutputFile=-']
|
||||
cmd.extend(sections)
|
||||
|
||||
subprocess.run(cmd, stdout=output)
|
||||
|
||||
output.seek(0)
|
||||
return output
|
||||
96
library/storage.py
Normal file
96
library/storage.py
Normal file
@ -0,0 +1,96 @@
|
||||
from django.core.files.storage import Storage, get_storage_class
|
||||
from django.utils.deconstruct import deconstructible
|
||||
from django.conf import settings
|
||||
from hashlib import sha1
|
||||
import os.path
|
||||
import shutil
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@deconstructible
|
||||
class RemoteCachedStorage(Storage):
|
||||
|
||||
CACHE_EXPIRES = 30
|
||||
|
||||
def __init__(self, remote=None, cachedir=None):
|
||||
if not remote:
|
||||
remote = settings.CACHED_STORAGE_REMOTE
|
||||
if not cachedir:
|
||||
cachedir = settings.CACHED_STORAGE_DIR
|
||||
self.remote = get_storage_class(remote)()
|
||||
self.cachedir = cachedir
|
||||
os.makedirs(self.cachedir, exist_ok=True)
|
||||
self.clean()
|
||||
|
||||
def _filepath(self, name):
|
||||
base, ext = os.path.splitext(name)
|
||||
filename = sha1(base.encode('utf8')).hexdigest() + ext
|
||||
return os.path.join(self.cachedir, filename)
|
||||
|
||||
def _cached(self, name):
|
||||
p = self._filepath(name)
|
||||
if not os.path.exists(p):
|
||||
logger.debug("Caching %s to %s", name, p)
|
||||
source = self.remote._open(name, 'rb')
|
||||
dest = tempfile.NamedTemporaryFile(dir=self.cachedir, delete=False, prefix="_")
|
||||
shutil.copyfileobj(source, dest)
|
||||
source.close()
|
||||
dest.close()
|
||||
os.rename(dest.name, p)
|
||||
now = time.time()
|
||||
os.utime(p, (now, now))
|
||||
|
||||
if now > self.next_check:
|
||||
self.clean() # wont get this file as we just touched it
|
||||
|
||||
return p
|
||||
|
||||
|
||||
def _open(self, name, mode='rb'):
|
||||
p = self._cached(name)
|
||||
return open(p, mode)
|
||||
|
||||
def path(self, name):
|
||||
return self._cached(name)
|
||||
|
||||
def _save(self, name, content):
|
||||
return self.remote._save(name, content)
|
||||
|
||||
def delete(self, name):
|
||||
return self.remote.delete(name)
|
||||
|
||||
def exists(self, name):
|
||||
return self.remote.exists(name)
|
||||
|
||||
def listdir(self, name):
|
||||
return self.remote.listdir(name)
|
||||
|
||||
def size(self, name):
|
||||
return self.remote.size(name)
|
||||
|
||||
def url(self, name):
|
||||
return self.remote.url(name)
|
||||
|
||||
def get_valid_name(self, name):
|
||||
return self.remote.get_valid_name(name)
|
||||
|
||||
def get_available_name(self, name, max_length=None):
|
||||
return self.remote.get_available_name(name, max_length)
|
||||
|
||||
def get_alternative_name(self, file_root, file_ext):
|
||||
return self.remote.get_alternative_name(file_root, file_ext)
|
||||
|
||||
def clean(self):
|
||||
now = time.time()
|
||||
threshold = now - self.CACHE_EXPIRES
|
||||
logger.info("Removing cached files older than %d seconds", self.CACHE_EXPIRES)
|
||||
for f in os.listdir(self.cachedir):
|
||||
f = os.path.join(self.cachedir, f)
|
||||
s = os.stat(f)
|
||||
if s.st_atime < threshold:
|
||||
logger.debug("Removing %s", f)
|
||||
os.unlink(f)
|
||||
self.next_check = now + 300
|
||||
193
library/templates/library/document_annotate.html
Normal file
193
library/templates/library/document_annotate.html
Normal file
@ -0,0 +1,193 @@
|
||||
{% extends "interface/project_base.html" %}
|
||||
|
||||
{% block admin %}
|
||||
<a href="#" onclick="saveTags()"><i class="fas fa-save"></i> Save</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block page %}
|
||||
<h2><a href="{% url 'work_list' %}">Works</a> / <a href="{% url 'work_detail' document.work.pk %}">{{ document.work.name }}</a></h2>
|
||||
<div id="annotation-area">
|
||||
<div style="display: flex">
|
||||
<canvas id="inline-viewer" style="width: 600px;"></canvas>
|
||||
<div style="margin: 0px 20px">
|
||||
<div>
|
||||
<div style="text-align: center;">
|
||||
<p>Page: <span id="page_num"></span> / <span id="page_count"></span></p>
|
||||
</div>
|
||||
<button id="prev"><i class="fas fa-backward"></i></button>
|
||||
<button id="next"><i class="fas fa-forward"></i></button>
|
||||
</div>
|
||||
<div id="tag-list" style="cursor: pointer; margin-top: 20px;">
|
||||
<div id="tag-None" data-tag="None">No tag</div>
|
||||
<div id="tag-Score" data-tag="Score">Score</div>
|
||||
{% for instrument in document.work.instruments %}
|
||||
<div id="tag-{{ instrument.0 }}" data-tag="{{ instrument.0 }}">{{ instrument.1 }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p>{{ document.upload.name }}</p>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/2.7.570/pdf.min.js" integrity="sha512-g4FwCPWM/fZB1Eie86ZwKjOP+yBIxSBM/b2gQAiSVqCgkyvZ0XxYPDEcN2qqaKKEvK6a05+IPL1raO96RrhYDQ==" crossorigin="anonymous"></script>
|
||||
{{ json_data|json_script:"data" }}
|
||||
<script type="text/javascript">
|
||||
let url = "{{ document.upload.url|safe }}";
|
||||
|
||||
// Loaded via <script> tag, create shortcut to access PDF.js exports.
|
||||
let pdfjsLib = window['pdfjs-dist/build/pdf'];
|
||||
|
||||
// The workerSrc property shall be specified.
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/2.7.570/pdf.worker.min.js';
|
||||
|
||||
// get current page tags
|
||||
let data = JSON.parse(document.getElementById('data').textContent);
|
||||
let currentTags = document.getElementById('current-tags');
|
||||
var selectedTag = document.getElementById('tag-None');
|
||||
var dirty = false;
|
||||
|
||||
document.getElementById('tag-list').onclick = (e) => setTag(e.target.dataset.tag);
|
||||
|
||||
var pdfDoc = null,
|
||||
pageNum = 1,
|
||||
pageRendering = false,
|
||||
pageNumPending = null,
|
||||
scale = 1,
|
||||
canvas = document.getElementById('inline-viewer'),
|
||||
ctx = canvas.getContext('2d');
|
||||
|
||||
/**
|
||||
* Get page info from document, resize canvas accordingly, and render page.
|
||||
* @param num Page number.
|
||||
*/
|
||||
function renderPage(num) {
|
||||
pageRendering = true;
|
||||
// Using promise to fetch the page
|
||||
pdfDoc.getPage(num).then(function(page) {
|
||||
var viewport = page.getViewport({scale: scale});
|
||||
canvas.height = viewport.height;
|
||||
canvas.width = viewport.width;
|
||||
|
||||
// Render PDF page into canvas context
|
||||
var renderContext = {
|
||||
canvasContext: ctx,
|
||||
viewport: viewport
|
||||
};
|
||||
var renderTask = page.render(renderContext);
|
||||
|
||||
// Wait for rendering to finish
|
||||
renderTask.promise.then(function() {
|
||||
pageRendering = false;
|
||||
if (pageNumPending !== null) {
|
||||
// New page rendering is pending
|
||||
renderPage(pageNumPending);
|
||||
pageNumPending = null;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Update page counters
|
||||
document.getElementById('page_num').textContent = num;
|
||||
|
||||
// get the page tags
|
||||
let tag = data.pageTags[num];
|
||||
if(tag) {
|
||||
selectTag(tag);
|
||||
} else {
|
||||
if (selectedTag) {
|
||||
setTag(selectedTag.dataset.tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If another page rendering in progress, waits until the rendering is
|
||||
* finised. Otherwise, executes rendering immediately.
|
||||
*/
|
||||
function queueRenderPage(num) {
|
||||
if (pageRendering) {
|
||||
pageNumPending = num;
|
||||
} else {
|
||||
renderPage(num);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays previous page.
|
||||
*/
|
||||
function onPrevPage() {
|
||||
if (pageNum <= 1) {
|
||||
return;
|
||||
}
|
||||
pageNum--;
|
||||
queueRenderPage(pageNum);
|
||||
}
|
||||
document.getElementById('prev').addEventListener('click', onPrevPage);
|
||||
|
||||
/**
|
||||
* Displays next page.
|
||||
*/
|
||||
function onNextPage() {
|
||||
if (pageNum >= pdfDoc.numPages) {
|
||||
return;
|
||||
}
|
||||
pageNum++;
|
||||
queueRenderPage(pageNum);
|
||||
}
|
||||
document.getElementById('next').addEventListener('click', onNextPage);
|
||||
|
||||
/**
|
||||
* Asynchronously downloads PDF.
|
||||
*/
|
||||
pdfjsLib.getDocument(url).promise.then(function(pdfDoc_) {
|
||||
pdfDoc = pdfDoc_;
|
||||
document.getElementById('page_count').textContent = pdfDoc.numPages;
|
||||
|
||||
// Initial/first page rendering
|
||||
renderPage(pageNum);
|
||||
});
|
||||
|
||||
function setTag(tag) {
|
||||
data.pageTags[pageNum] = tag;
|
||||
dirty = true;
|
||||
selectTag(tag);
|
||||
}
|
||||
|
||||
function selectTag(tag) {
|
||||
if( selectedTag ) {
|
||||
selectedTag.classList.remove('selected');
|
||||
}
|
||||
selectedTag = document.getElementById('tag-' + tag);
|
||||
if( selectedTag) {
|
||||
selectedTag.classList.add('selected');
|
||||
}
|
||||
}
|
||||
|
||||
function saveTags() {
|
||||
console.log(data.pageTags);
|
||||
fetch("", {
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/json", "X-CSRFToken": "{{ csrf_token }}"},
|
||||
body: JSON.stringify(data.pageTags)}
|
||||
).then((response) => {
|
||||
if(response.ok) {
|
||||
window.location = "{% url 'work_detail' document.work.pk %}"
|
||||
} else {
|
||||
alert("Failed: " + response.statusText)
|
||||
}
|
||||
});
|
||||
dirty = false;
|
||||
}
|
||||
|
||||
function checkSaved(e) {
|
||||
if (dirty) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
window.addEventListener('beforeunload', checkSaved);
|
||||
|
||||
</script>
|
||||
{% endblock %}
|
||||
11
library/templates/library/folder_detail.html
Normal file
11
library/templates/library/folder_detail.html
Normal file
@ -0,0 +1,11 @@
|
||||
{% extends 'interface/project_base.html' %}
|
||||
|
||||
{% block page %}
|
||||
<h2>{{ folder.name }}</h2>
|
||||
<h3>Works</h3>
|
||||
<ul>
|
||||
{% for work in folder.works.all %}
|
||||
<li>{{ work.name }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endblock %}
|
||||
94
library/templates/library/playlist_manage.html
Normal file
94
library/templates/library/playlist_manage.html
Normal file
@ -0,0 +1,94 @@
|
||||
{% extends "interface/project_base.html" %}
|
||||
|
||||
{% block admin %}
|
||||
<a href="#" onclick="save()"><i class="fas fa-save"></i><span class="smhide action">Save</span></a>
|
||||
<a href="{% url 'playlist_append' project.pk %}"><i class="fas fa-plus-circle"></i><span class="smhide action">Add</a></a>
|
||||
{% endblock %}
|
||||
|
||||
{% block page %}
|
||||
<table style="max-width: 600px; margin: 10pt auto;" class="item-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Piece</th>
|
||||
<th>Part</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="work-list">
|
||||
{% for item in object_list %}
|
||||
<tr data-pk="{{ item.pk }}" data-order="{{ forloop.counter }}">
|
||||
<td>{{ item.work.name }}</td>
|
||||
<td style="text-align: center;">
|
||||
<i class="fas fa-arrow-up clickable" title="Move up" onclick="moveItem({{ item.pk }}, -1)"></i>
|
||||
<i class="fas fa-arrow-down clickable" title="Move down" onclick="moveItem({{ item.pk }}, 1)"></i>
|
||||
<i class="fas fa-trash clickable" title="Remove"></i>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script type="text/javascript">
|
||||
|
||||
let workList = document.getElementById('work-list');
|
||||
var dirty = false;
|
||||
|
||||
function moveItem(pk, dir) {
|
||||
let items = Array.prototype.slice.call(workList.children, 0);
|
||||
var i=0;
|
||||
pk = "" + pk;
|
||||
for(i=0; i<items.length; i++) {
|
||||
if(items[i].dataset.pk === pk) break;
|
||||
}
|
||||
if(i >= items.length) return;
|
||||
|
||||
// check the direction is sensible
|
||||
if (i + dir < 0 || i + dir >= items.length) return;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function save() {
|
||||
|
||||
let items = Array.prototype.slice.call(workList.children, 0);
|
||||
let data = {};
|
||||
for(var i=0; i<items.length; i++) {
|
||||
let item = items[i].dataset;
|
||||
data[item.pk] = item.order;
|
||||
}
|
||||
console.log(data);
|
||||
|
||||
fetch("", {
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/json", "X-CSRFToken": "{{ csrf_token }}"},
|
||||
body: JSON.stringify(data)
|
||||
}).then((response) => {
|
||||
if (response.ok) {
|
||||
dirty=false;
|
||||
window.location = "{% url 'project_playlist' project=project.pk %}";
|
||||
} else {
|
||||
alert("Failed: " + response.statusText)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function checkSaved(e) {
|
||||
if(dirty) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
window.addEventListener('beforeunload', checkSaved);
|
||||
|
||||
</script>
|
||||
{% endblock %}
|
||||
84
library/templates/library/playlist_view.html
Normal file
84
library/templates/library/playlist_view.html
Normal file
@ -0,0 +1,84 @@
|
||||
{% extends "interface/project_base.html" %}
|
||||
|
||||
{% block admin %}
|
||||
<a href="{% url 'playlist_manage' project.pk %}"><i class="fas fa-list"></i><span class="smhide admin">Change works</span></a>
|
||||
{% endblock %}
|
||||
|
||||
{% block page %}
|
||||
<p>
|
||||
This page lets you download a complete set of music for your part by selecting your instrument
|
||||
and optionally a part (e.g. Flute 1 or 2).<br/>
|
||||
You can also tweak which parts you get in the list or click on a piece for more download options.
|
||||
</p>
|
||||
<form action="" method="post" target="_blank" style="display: flex;">
|
||||
{% csrf_token %}
|
||||
<table style="max-width: 600px; margin: 10pt auto;" class="item-table">
|
||||
<thead>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in object_list %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{% url 'work_detail' item.work.pk %}">{{ item.work.name }}</a>
|
||||
</td>
|
||||
<td class="select-cell">
|
||||
<input type="hidden" name="works" value="{{ item.work.pk }}"/>
|
||||
<select name="instruments">
|
||||
<option value='-'>None</option>
|
||||
{% for part in item.work.parts %}
|
||||
<option value='{{ part.tag }}'>{{ part.instrument }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div style="margin: 0px 20px; text-align: right;">
|
||||
<label for="instrument-select">Pick your instrument</label>
|
||||
<select id="instrument-select" name="instrument" onchange="updateParts()">
|
||||
{% for tag, instrument in instruments %}
|
||||
<option value="{{ tag }}">{{ instrument }}</option>
|
||||
{% endfor %}
|
||||
</select><br/><br/>
|
||||
<label for="part-preference">Part preference</label>
|
||||
<input id="part-preference" name="part" type="number" value="0" min="0" max="4" onchange="updateParts()" size="1"/><br/><br/>
|
||||
<button type="submit" style="height: 32px;"><i class="fas fa-copy"></i> Get My Parts!</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script type="text/javascript">
|
||||
|
||||
function updateParts() {
|
||||
let inst = document.getElementById("instrument-select").value;
|
||||
let part = document.getElementById("part-preference").value;
|
||||
let prefix = inst + part;
|
||||
|
||||
let instruments = document.getElementsByName("instruments");
|
||||
for(let i=0; i<instruments.length; i++) {
|
||||
var result = "-"
|
||||
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)) {
|
||||
result = value;
|
||||
}
|
||||
}
|
||||
instruments[i].value = result;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
document.getElementById("instrument-select").value="{{instrument}}";
|
||||
document.getElementById("part-preference").value="{{part}}";
|
||||
updateParts();
|
||||
|
||||
</script>
|
||||
{% endblock %}
|
||||
1
library/templates/library/project_menu.html
Normal file
1
library/templates/library/project_menu.html
Normal file
@ -0,0 +1 @@
|
||||
<a href="{% url 'project_playlist' project=project.pk %}">My Music</a>
|
||||
28
library/templates/library/work_detail.html
Normal file
28
library/templates/library/work_detail.html
Normal file
@ -0,0 +1,28 @@
|
||||
{% extends 'interface/project_base.html' %}
|
||||
|
||||
{% block admin %}
|
||||
<a href="{% url 'document_add' work.pk %}"><i class="fas fa-plus-circle"></i> Upload file</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block page %}
|
||||
<h2><a href="{% url 'work_list' %}">Works</a> / {{ work.name }}</h2>
|
||||
<p>{{ work.notes }}</p>
|
||||
<h3>Parts</h3>
|
||||
<ul>
|
||||
{% for part in work.parts %}
|
||||
<li><a href="{% url 'part_download' pk=part.pk filename=part.filename %}" target="part_{{ part.pk }}" rel="">{{ part.instrument }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
<h3>Original Documents</h3>
|
||||
<ul>
|
||||
{% for doc in work.docs.all %}
|
||||
<li>
|
||||
<a href="{% url 'document_download' pk=doc.pk %}" target="_blank">{{ doc.upload.name }}</a> [{{ doc.parts.count }} parts]
|
||||
{% if request.is_admin %}
|
||||
<a href="{% url 'document_annotate' pk=doc.pk %}"><i class="fas fa-edit"></i></a>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endblock %}
|
||||
25
library/templates/library/work_list.html
Normal file
25
library/templates/library/work_list.html
Normal file
@ -0,0 +1,25 @@
|
||||
{% extends "interface/project_base.html" %}
|
||||
|
||||
{% block admin %}
|
||||
<a href="{% url 'work_add' %}"><i class="fas fa-plus-circle"></i> Add new</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block page %}
|
||||
<h2>Works</h2>
|
||||
<table style="max-width: 800px; margin: 10pt auto;">
|
||||
<thead>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for work in object_list %}
|
||||
{% with work.docs.count as doc_count %}
|
||||
{% with work.parts.count as part_count %}
|
||||
<tr>
|
||||
<td><a href="{% url 'work_detail' pk=work.pk %}">{{ work.name }}</a></td>
|
||||
<td>{{ doc_count }} file{{ doc_count|pluralize }} with {{ part_count }} part{{ part_count|pluralize }}</td>
|
||||
</tr>
|
||||
{% endwith %}
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endblock %}
|
||||
3
library/tests.py
Normal file
3
library/tests.py
Normal file
@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
19
library/urls.py
Normal file
19
library/urls.py
Normal file
@ -0,0 +1,19 @@
|
||||
from django.urls import path
|
||||
from django.contrib.auth import views as auth_views
|
||||
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
|
||||
path('projects/<int:project>/works', views.PlaylistView.as_view(), name="project_playlist"),
|
||||
path('projects/<int:project>/works/manage', views.PlaylistManageView.as_view(), name="playlist_manage"),
|
||||
path('projects/<int:project>/works/append', views.PlaylistAddView.as_view(), name="playlist_append"),
|
||||
|
||||
path('library/works', views.WorkListView.as_view(), name="work_list"),
|
||||
path('library/works/add', views.WorkAddView.as_view(), name="work_add"),
|
||||
path('library/works/<int:pk>', views.WorkDetailView.as_view(), name="work_detail"),
|
||||
path('library/works/<int:pk>/upload', views.DocumentAddView.as_view(), name="document_add"),
|
||||
path('library/documents/<int:pk>/download', views.DocumentDownloadView.as_view(), name="document_download"),
|
||||
path('library/documents/<int:pk>/annotate', views.DocumentAnnotateView.as_view(), name="document_annotate"),
|
||||
path('library/parts/<int:pk>/<str:filename>', views.PartDownloadView.as_view(), name="part_download"),
|
||||
]
|
||||
217
library/views.py
Normal file
217
library/views.py
Normal file
@ -0,0 +1,217 @@
|
||||
from django.shortcuts import render, redirect, resolve_url
|
||||
from django.views.generic.detail import DetailView, SingleObjectMixin, View
|
||||
from django.views.generic.list import ListView
|
||||
from django.views.generic.edit import CreateView, FormView, UpdateView
|
||||
from django.http import FileResponse, HttpResponse
|
||||
from django.db import IntegrityError
|
||||
|
||||
import json
|
||||
|
||||
from interface.views import EnsembleMixin, ProjectMixin
|
||||
from interface.models import Project
|
||||
from .models import Work, Document, Part, Playlist, INSTRUMENTS
|
||||
from . import forms
|
||||
from .pdf_utils import extract_pages, extract_and_concat
|
||||
|
||||
class PlaylistView(ProjectMixin, ListView):
|
||||
template_name = "library/playlist_view.html"
|
||||
model = Playlist
|
||||
|
||||
def post(self, request, **kwargs):
|
||||
|
||||
project = self.get_project()
|
||||
|
||||
project_works = project.works.all()
|
||||
|
||||
instruments = request.POST.getlist('instruments')
|
||||
works = request.POST.getlist('works')
|
||||
|
||||
self.request.session['part'] = request.POST.get('part', '')
|
||||
self.request.session['instrument'] = request.POST.get('instrument')
|
||||
|
||||
valid_pks = [ x.pk for x in project_works ]
|
||||
|
||||
sections = []
|
||||
|
||||
for i, pk in enumerate(works):
|
||||
|
||||
if int(pk) not in valid_pks:
|
||||
raise Exception(f"Not a valid work pk: {pk}")
|
||||
|
||||
tag = instruments[i]
|
||||
if tag == '-':
|
||||
continue
|
||||
|
||||
part = Part.objects.filter(tag=tag, doc__work=pk).select_related('doc').get()
|
||||
sections.append((part.doc.upload.path, part.doc.work.name, part.start, part.end))
|
||||
|
||||
result = extract_and_concat(sections)
|
||||
|
||||
download_name = f'{project.name}.pdf'
|
||||
|
||||
response = FileResponse(result, content_type="application/pdf")
|
||||
response['Content-Disposition'] = f'inline; filename="{download_name}"'
|
||||
return response
|
||||
|
||||
|
||||
def get_queryset(self):
|
||||
return super(PlaylistView, self).get_queryset().select_related('project', 'work')
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
data = super(PlaylistView, self).get_context_data(**kwargs)
|
||||
data['instruments'] = INSTRUMENTS
|
||||
data['instrument'] = self.request.session.get('instrument', 'Score')
|
||||
data['part'] = self.request.session.get('part', '0')
|
||||
return data
|
||||
|
||||
class PlaylistManageView(ProjectMixin, ListView):
|
||||
template_name = "library/playlist_manage.html"
|
||||
model = Playlist
|
||||
|
||||
def post(self, request, **kwargs):
|
||||
self.request = request
|
||||
self.kwargs = kwargs
|
||||
|
||||
data = json.loads(request.body)
|
||||
|
||||
q = self.get_queryset()
|
||||
for pk, order in data.items():
|
||||
i = q.filter(pk=pk).update(order=order)
|
||||
|
||||
return HttpResponse(status=204)
|
||||
|
||||
def get_queryset(self):
|
||||
return super(PlaylistManageView, self).get_queryset().select_related('project', 'work')
|
||||
|
||||
class PlaylistAddView(ProjectMixin, UpdateView):
|
||||
form_class = forms.PlaylistAddForm
|
||||
template_name = "interface/default_form.html"
|
||||
|
||||
def get_success_url(self):
|
||||
return resolve_url('playlist_manage', project=self.kwargs['project'])
|
||||
|
||||
def get_object(self):
|
||||
return self.get_project()
|
||||
|
||||
class WorkListView(EnsembleMixin, ListView):
|
||||
|
||||
def get_queryset(self):
|
||||
return Work.objects.filter(ensemble=self.request.ensemble_id).order_by('name')
|
||||
|
||||
class WorkAddView(EnsembleMixin, FormView):
|
||||
template_name = "interface/default_form.html"
|
||||
form_class = forms.WorkCreateForm
|
||||
|
||||
def form_valid(self, form):
|
||||
obj = form.save(commit=False)
|
||||
obj.ensemble_id = self.request.ensemble_id
|
||||
try:
|
||||
obj.save()
|
||||
except IntegrityError:
|
||||
form.add_error('name', 'Name must be unique')
|
||||
return self.form_invalid(form)
|
||||
|
||||
# handle the files
|
||||
uploads = self.request.FILES.getlist('uploads')
|
||||
docs = []
|
||||
for f in uploads:
|
||||
docs.append(obj.docs.create(upload=f).pk)
|
||||
|
||||
if len(docs) == 1:
|
||||
return redirect('document_annotate', docs[0])
|
||||
else:
|
||||
return redirect('work_detail', pk=obj.pk)
|
||||
|
||||
class WorkDetailView(EnsembleMixin, DetailView):
|
||||
|
||||
def get_queryset(self):
|
||||
return Work.objects.filter(ensemble=self.request.ensemble_id)
|
||||
|
||||
|
||||
class DocumentDetailView(EnsembleMixin, DetailView):
|
||||
|
||||
def get_queryset(self):
|
||||
return Document.objects.filter(work__ensemble=self.request.ensemble_id).select_related('work')
|
||||
|
||||
class DocumentAddView(EnsembleMixin, CreateView):
|
||||
template_name = "interface/default_form.html"
|
||||
model = Document
|
||||
fields = ['upload']
|
||||
|
||||
def form_valid(self, form):
|
||||
self.object = form.save(commit=False)
|
||||
self.object.work_id = self.kwargs['pk']
|
||||
self.object.save()
|
||||
return redirect('document_annotate', self.object.pk)
|
||||
|
||||
class DocumentDownloadView(EnsembleMixin, SingleObjectMixin, View):
|
||||
|
||||
def get(self, request, **args):
|
||||
self.request = request
|
||||
self.args = args
|
||||
self.object = self.get_object()
|
||||
|
||||
#response = FileResponse(self.object.upload, content_type="application/pdf")
|
||||
#return response
|
||||
return redirect(self.object.upload.url)
|
||||
|
||||
def get_queryset(self):
|
||||
return Document.objects.filter(work__ensemble=self.request.ensemble_id)
|
||||
|
||||
class DocumentAnnotateView(EnsembleMixin, DetailView):
|
||||
template_name = 'library/document_annotate.html'
|
||||
|
||||
def post(self, request, **args):
|
||||
self.request = request
|
||||
self.args = args
|
||||
self.object = self.get_object()
|
||||
|
||||
data = json.loads(request.body)
|
||||
|
||||
tags = {}
|
||||
for page, tag in data.items():
|
||||
tags.setdefault(tag, []).append(int(page))
|
||||
try:
|
||||
del(tags['None'])
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
self.object.parts.all().delete()
|
||||
for tag, pages in tags.items():
|
||||
pages.sort()
|
||||
end = pages[-1] if len(pages) > 1 else None
|
||||
o = self.object.parts.create(tag=tag, start=pages[0], end=end)
|
||||
|
||||
return HttpResponse(status=204)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
data = super(DocumentAnnotateView, self).get_context_data(**kwargs)
|
||||
|
||||
pages = {}
|
||||
for part in data['document'].parts.all():
|
||||
for i in range(part.start, (part.end or part.start)+1):
|
||||
pages[i] = part.tag
|
||||
|
||||
data['json_data'] = {'pageTags': pages, 'instruments': data['document'].work.instruments}
|
||||
return data
|
||||
|
||||
def get_queryset(self):
|
||||
return Document.objects.filter(work__ensemble=self.request.ensemble_id).select_related('work')
|
||||
|
||||
class PartDownloadView(EnsembleMixin, SingleObjectMixin, View):
|
||||
|
||||
def get(self, request, **args):
|
||||
self.request = request
|
||||
self.args = args
|
||||
self.object = self.get_object()
|
||||
|
||||
result = extract_pages(self.object.doc.upload.path, self.object.doc.work.name, self.object.start, self.object.end)
|
||||
|
||||
download_name = f'{self.object.doc.work.name}_{self.object.instrument}.pdf'
|
||||
|
||||
response = FileResponse(result, content_type="application/pdf")
|
||||
response['Content-Disposition'] = f'inline; filename="foo.pdf"'
|
||||
return response
|
||||
|
||||
def get_queryset(self):
|
||||
return Part.objects.filter(doc__work__ensemble=self.request.ensemble_id).select_related('doc', 'doc__work')
|
||||
Loading…
x
Reference in New Issue
Block a user