47 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
tris 4cef5800bc Merge pull request 'Repackaged as a single project' (#13) from repackage into master
Reviewed-on: #13
2026-05-24 11:11:27 +10:00
tris 78441dc142 Version bump 2026-05-24 11:07:49 +10:00
tris c96a2ad80b Refactored docker 2026-05-24 11:00:05 +10:00
tris 7e0c77f260 Refactored to polyphonic package 2026-05-24 10:51:43 +10:00
tris ff17114514 Minor test change 2026-05-24 10:07:05 +10:00
tris 533d6f09e8 Fixed compose launch 2026-05-23 22:10:37 +10:00
tris eeb35ce4f6 Modify PYTHONPATH 2026-05-23 21:46:51 +10:00
tris 947626c2af Added indexer tests 2026-05-23 11:38:40 +10:00
tris c1f0e48f80 Another go at sorting the settings mess 2026-05-23 10:00:09 +10:00
tris ab7d32d46e Renamed to poly-tool 2026-05-23 00:05:29 +10:00
tris 5116247ae8 Simplified settings 2026-05-22 23:51:25 +10:00
tris 93c4926dfd Updated docker build 2026-05-22 14:11:03 +10:00
tris 27d1b03c3c Added fuzzy matching 2026-05-14 11:02:55 +10:00
tris 4e102c07ac Skip migrations 2026-05-13 11:08:03 +10:00
tris b1ea75cec0 Merge pull request 'GdriveFolder code' (#12) from gdrive into master
Reviewed-on: #12
2026-05-13 09:35:48 +10:00
tris 7d041e1fd0 GdriveFolder code 2026-05-13 09:32:22 +10:00
122 changed files with 2365 additions and 1175 deletions
+3
View File
@@ -5,7 +5,10 @@ __pycache__
credentials.json
credentials
local_settings.py
local.mk
.coverage
.lint
.deploy
Session.vim
poetry.lock
/env
+18 -12
View File
@@ -1,21 +1,27 @@
FROM alpine:3.14
FROM alpine:latest
RUN apk add --no-cache python3 git ghostscript sqlite
ARG VERSION=0.8.4
ENV TARGET=/opt/polyphonic
#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
WORKDIR /root
RUN python3 -m ensurepip
RUN pip3 install -U pip --no-cache-dir
COPY app/requirements.txt .
RUN pip3 install -r requirements.txt --no-cache-dir
RUN python3 -m venv ${TARGET}
ENV PATH="${TARGET}/bin:$PATH"
COPY app /opt/polyphonic
WORKDIR /opt/polyphonic
#COPY dist/${RELEASE} .
RUN pip3 install ${RELEASE} --no-cache-dir
RUN pip3 install gunicorn whitenoise
COPY docker_settings.py polyphonic/local_settings.py
RUN SECRET_KEY=_ python3 manage.py collectstatic --noinput
WORKDIR ${TARGET}
RUN SECRET_KEY=_ poly-tool collectstatic --noinput
VOLUME ["/var/polyphonic"]
EXPOSE 8000/tcp
ENTRYPOINT ["python3", "manage.py"]
CMD ["runserver", "0.0.0.0:8000", "--insecure"]
CMD ["gunicorn", "-b", "0.0.0.0", "polyphonic.config.wsgi"]
+39 -7
View File
@@ -1,15 +1,36 @@
PYTHON=env/bin/python
DROPZONE=5.7.0
test: check
poetry run coverage run --include "app/*" --omit "*/migrations/*" app/manage.py test app
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 html
poetry run coverage report
check:
poetry run ruff check app
.lint: polyphonic
poetry run ruff check polyphonic
poetry run ruff format --check polyphonic
touch $@
build:
build: dist/polyphonic-${VERSION}-py3-none-any.whl
dist/polyphonic-${VERSION}-py3-none-any.whl: polyphonic
poetry build
dev-setup:
@@ -22,7 +43,18 @@ upgrade:
poetry run manage collectstatic
${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:
wget -O dropzone-${DROPZONE}.zip https://github.com/enyo/dropzone/archive/v${DROPZONE}.zip
-18
View File
@@ -1,18 +0,0 @@
from django import template
from django.utils import timesince
register = template.Library()
def roughtimesince(value):
return timesince.timesince(value, depth=1)
register.filter("roughtimesince", roughtimesince)
def roughtimeuntil(value):
return timesince.timeuntil(value, depth=1)
register.filter("roughtimeuntil", roughtimeuntil)
-267
View File
@@ -1,267 +0,0 @@
from collections import namedtuple
import re
GENERAL = """
mvmt Movement
ex Excerpt
sec Section
pce Piece
no Number
"""
# taken from https://imslp.org/wiki/IMSLP:Abbreviations_for_MusicTags
# Include any aliases at the top
INSTRUMENTS = """
score Score
cb Double Bass
mall Mallet Percussion
vln Violin
vla Viola
acc Accordion
afl Alto flute
alt Alto (voice) (contralto)
arp Arpeggione
bag Bagpipe
bar Baritone (voice)
bass Bass (voice)
bbar Bass baritone (voice)
bc Continuo (Basso continuo)
bcl Bass clarinet
bell Bell (Chimes)
bfl Bass flute
bgtr Bass guitar
bjo Banjo
bn Bassoon
bob Bass oboe (Baritone oboe)
br Brass MusicTags
bryt Baryton
bstcl Basset clarinet
bsthn Basset horn
bug Bugle
cbcl Contrabass clarinet
cbn Contrabassoon
cch Children's chorus
cel Celesta
ch Mixed chorus
cimb Cimbalom
cit Cittern
cl Clarinet
clvd Clavichord
cm Chalumeau
conc Concertina
crh Crumhorn
crt Cornet
crtt Cornett (Zink)
cv Child's voice
db Double Bass
drum Drumset
dlcn Dulcian
dom Domra
dulc Dulcimer
egtr Electric guitar
eh English horn (Cor anglais)
elec Electronic MusicTags
epf Electric piano
eq Equal voices
erhu Erhu
euph Euphonium
fch Female chorus
fda Flute d'amore (Tenor flute)
fgh Flugelhorn
fife Fife
fl Flute
flag Flageolet
ghca Glass harmonica (Bowl organ)
gl Glockenspiel
gtr Guitar
harm Harmonium
hca Harmonica (Mouth Organ)
heck Heckelphone
hn Horn
hp Harp
hpd Harpsichord
kbd Keyboard MusicTag
lute Lute
lyre Lyre
mand Mandolin
mar Marimba
mch Male chorus
mez Mezzo-soprano
mus Musette
nar Narrator (Reciter)
ob Oboe
oca Ocarina
oda Oboe d'amore
om Ondes Martenot
oph Ophicleide
orch Orchestra
org Organ
oud Oud
pan Pan flute (Pan-pipes)
perc Percussion
pf Piano
pf3h Piano 3 hands
pf4h Piano 4 hands
pf5h Piano 5 hands
pf6h Piano 6 hands
pflh Piano left hand
pfped Pedal piano
pfrh Piano right hand
picc Piccolo
pipa Pipa
pk Timpani
ptpt Piccolo trumpet
reb Rebec
rec Recorder
sar Sarrusophone
sax Saxophone
sheng Sheng
shw Shawm
sit Sitar
skbt Sackbut
sop Soprano (voice)
srp Serpent
stpt Slide trumpet
str String MusicTags
sxh Saxhorn
syn Synthesizer
tba Tuba
tbn Trombone
ten Tenor
thrm Theremin
timp Timpani
tpt Trumpet
uch Unison chorus
uke Ukelele (Ukulele)
v Voice (solo)
va Viola
vap Viola pomposa
vc Cello
vda Viola d'amore
vib Vibraphone
vie Vielle (Hurdy-Gurdy)
viol Viol (Viola da gamba)
vlne Violone
vn Violin
vuv Vuvuzela
vv Voices (multiple soloists)
wag Wagner tuba
ww Woodwind MusicTags
xiao Xiao
xyl Xylophone
zith Zither
"""
MUSIC_TAGS = []
GENERAL_TAGS = set()
for i, abbreviations in enumerate((GENERAL, INSTRUMENTS)):
for line in abbreviations.split("\n"):
parts = line.strip().split(maxsplit=1)
if len(parts) < 2:
continue
name, _, _ = parts[1].partition("(")
MUSIC_TAGS.append((parts[0], name))
if i == 0:
GENERAL_TAGS.add(parts[0])
MUSIC_NAME_BY_TAG = dict(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])):
@classmethod
def from_tag(cls, tag):
"""
>>> MusicTag.from_tag('vn-1')
MusicTag(name='Violin', variant='1')
>>> MusicTag.from_tag('db')
MusicTag(name='Double Bass', variant=None)
>>> MusicTag.from_tag('Jaws Harp')
MusicTag(name='Jaws Harp', variant=None)
>>> MusicTag.from_tag('mvmt-2')
MusicTag(name='Movement', variant='2')
>>> MusicTag.from_tag('pce-A2')
MusicTag(name='Piece', variant='A2')
"""
abbr, _, variant = tag.partition("-")
name = MUSIC_NAME_BY_TAG.get(abbr.lower(), abbr)
if variant:
return cls(name, variant)
return cls(name, None)
@property
def tag(self):
lc = self.name.lower()
return MUSIC_TAG_BY_NAME.get(lc, lc)
@property
def is_general(self):
"""
>>> MusicTag('Piece', 'A3').is_general
True
>>> MusicTag('Violin', 2).is_general
False
"""
return self.tag in GENERAL_TAGS
def abbreviate(self):
"""
>>> MusicTag('Violin', 1).abbreviate()
'vn-1'
>>> MusicTag('Double Bass').abbreviate()
'db'
"""
tag = MUSIC_TAG_BY_NAME.get(self.name.lower())
if self.variant:
tag = f"{tag}-{self.variant}"
return tag
def __str__(self):
"""
>>> str(MusicTag('Violin', 1))
'Violin 1'
>>> str(MusicTag('Double Bass'))
'Double Bass'
"""
if self.variant:
return f"{self.name} {self.variant}"
return self.name
PATTERNS = [re.compile(r"([A-Za-z]+)[_\- ]*(\d+)"), re.compile(r"([A-Za-z]+)()")]
def auto_tag(filename):
"""
>>> auto_tag('Ode to Joy - Violin 1.pdf')
MusicTag(name='Violin', variant=1)
>>> auto_tag('Ode to Joy_Cello.pdf')
MusicTag(name='Cello', variant=None)
>>> auto_tag('Ode to Joy violin - 1.pdf')
MusicTag(name='Violin', variant=1)
>>> auto_tag('Ode to Joy - vla.pdf')
MusicTag(name='Viola', variant=None)
>>> auto_tag('Ode to Joy - fl-2 (piccolo).pdf')
MusicTag(name='Flute', variant=2)
"""
for pattern in PATTERNS:
for inst, ordinal in pattern.findall(filename):
inst = inst.lower()
ordinal = int(ordinal) if ordinal else None
if inst in MUSIC_TAG_BY_NAME:
return MusicTag(inst.title(), ordinal)
if inst in MUSIC_NAME_BY_TAG:
return MusicTag(MUSIC_NAME_BY_TAG[inst], ordinal)
if __name__ == "__main__":
import doctest
print(doctest.testmod())
@@ -1,450 +0,0 @@
{% extends "interface/project_base.html" %}
{% block admin %}
<a href="#" onclick="saveTags()" class="button is-link">
<span class="icon"><i class="fas fa-save"></i></span>
<span>Save</span>
</a>
<a href="{% url 'work_detail' collection=collection.pk pk=object.work_id %}" class="button is-link is-light">
<span>Cancel</span>
</a>
{% endblock %}
{% block media %}
<style>
.tag-grid {
display: flex;
}
.grid-column {
margin-left: 1em;
margin-right: 1em;
}
.grid-page {
height: 25px;
margin-bottom: 5px;
border: 1px solid #999;
border-radius: 5px;
text-align: center;
width: 50px;
cursor: pointer;
}
.grid-page.is-active {
background-color: var(--primary);
color: white;
}
.grid-tag {
border: 2px solid var(--primary);
background-color: rgba(240, 240, 240, 0.5);
border-radius: 5px;
padding-left: 1em;
padding-right: 1em;
margin-bottom: 5px;
min-width: 200px;
position: absolute;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: start;
}
#tag-area {
min-width: 220px;
}
.instrument {
display: list-item !important;
}
.instrument:hover {
background-color: var(--primary);
color: white;
}
</style>
{% endblock %}
{% block page %}
<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 class="column is-narrow">
<div class="columns">
<div class="column is-narrow">
<button class="button is-small is-primary" id="prev">&lt;</button>
</div>
<div class="column">
<span id="page-num">-</span> / <span id="page-count">-</span>
</div>
<div class="column is-narrow">
<button class="button is-small is-primary" id="next">&gt;</button>
</div>
</div>
<ul id="unassigned-area">
{% for tag, inst in document.work.music_tags %}
<li class="is-clickable" onclick="assignInstrument('{{tag}}', this)")>{{ inst }}</li>
{% endfor %}
</ul>
<a onclick="document.getElementById('add-modal').classList.add('is-active')">Add Tag</a>
</div>
<div class="column is-narrow">
<div class="has-text-centered">
<div class="box" style="display: inline-block;">
<canvas id="inline-viewer" style="width: 500px;"></canvas>
</div>
</div>
</div>
<div class="column is-narrow">
<div class="tag-grid">
<div class="grid-column" id="page-list">
</div>
<div class="grid-column" id="tag-area">
</div>
</div>
<button class="button is-primary" style="width: 100%" onclick="expandEntries()">Expand</button>
</div>
</div>
<div class="modal" id="add-modal">
<div class="modal-background" onclick="closeAddModal()"></div>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">Add Instrument</p>
<button class="delete" aria-label="close" onclick="closeAddModal()"></button>
</header>
<section class="modal-card-body">
<div class="field has-addons">
<span class="control">
<input type="text" class="input" list="instrument-list" id="add-instrument-name"/>
<datalist id="instrument-list">
{% for inst in json_data.instruments.values %}
<option value="{{inst}}"/>
{% endfor %}
</datalist>
</span>
<span class="control">
<input type="number" class="input" max="4" min="1" size="3" id="add-instrument-variant"/>
</span>
<span class="control">
<button class="button is-primary" onclick="addInstrument();">Add</button>
</span>
</div>
</section>
</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 = "{% url 'document_download' collection.pk document.pk %}";
// 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 tagArea = document.getElementById('tag-area');
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);
try {
document.getElementById('grid-page-' + pageNum).classList.remove('is-active');
} catch(e) {}
pageNum = num;
document.getElementById('grid-page-' + pageNum).classList.add('is-active');
// 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;
}
/**
* 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;
}
queueRenderPage(pageNum-1);
}
document.getElementById('prev').addEventListener('click', onPrevPage);
/**
* Displays next page.
*/
function onNextPage() {
if (pageNum >= pdfDoc.numPages) {
return;
}
queueRenderPage(pageNum+1);
}
document.getElementById('next').addEventListener('click', onNextPage);
/**
* Asynchronously downloads PDF.
*/
pdfjsLib.getDocument(url).promise.then(function (pdfDoc_) {
pdfDoc = pdfDoc_;
const pageList = document.getElementById('page-list');
pageList.innerHTML = '';
for (var i=0; i<pdfDoc.numPages; i++) {
let page = i+1;
let el = document.createElement('div');
el.className = 'grid-page';
el.id = 'grid-page-' + page;
el.innerHTML = page;
el.addEventListener('click', (evt) => {
queueRenderPage(page);
});
pageList.appendChild(el);
}
document.getElementById('page-count').textContent = pdfDoc.numPages;
// Initial/first page rendering
renderPage(pageNum);
tagArea.innerHTML = '';
for (let pageTag of data.pageTags) {
addTag(pageTag[0], pageTag[1], pageTag[2]);
}
dirty = false;
});
function closeAddModal() {
document.getElementById('add-modal').classList.remove('is-active');
document.getElementById('add-instrument-name').value = "";
document.getElementById('add-instrument-variant').value = "";
}
function addInstrument() {
let name = document.getElementById('add-instrument-name');
let variant = document.getElementById('add-instrument-variant');
let inst_name = name.value;
var tag = null;
for (let key in data.instruments) {
if (data.instruments[key] == inst_name) {
tag = key;
break;
}
}
if (!tag) {
alert("Unknown tag: " + name);
return;
}
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);
addTag(tag, pageNum, pageNum);
closeAddModal();
}
function assignInstrument(tag, el) {
addTag(tag, pageNum, pageNum);
//el.remove();
}
function addTag(tag, start, end) {
console.log("addTag", tag, start, end);
const el = document.createElement('div');
el.className = 'grid-tag';
el.dataset.tag = tag;
el.dataset.start = start;
el.dataset.end = end;
let setStart = document.createElement('span');
setStart.className = "icon is-action";
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');
let name = document.createElement('b');
name.innerHTML = get_instrument(tag);
label.appendChild(name);
let del = document.createElement('span');
del.className = "icon is-action";
del.innerHTML = '<i class="fas fa-trash-alt" title="Remove this tag"></i>';
del.addEventListener('click', () => {
el.remove();
dirty=true;
});
label.appendChild(del)
el.appendChild(label);
let setEnd = document.createElement('span');
setEnd.className = "icon is-action";
setEnd.innerHTML = '<i class="fas fa-sort-amount-up" title="Set end page"></i>';
setEnd.addEventListener('click', () => setTagEnd(el));
el.appendChild(setEnd);
updateTag(el);
tagArea.appendChild(el);
}
function updateTag(tag) {
let start = tag.dataset.start;
let end = tag.dataset.end;
let span = end-start+1;
let height = span * 25 + (span-1) * 5;
let top = (start-1) * 30;
tag.style.height = height + 'px';
tag.style.marginTop = top + 'px';
dirty = true;
}
function setTagStart(el) {
if (pageNum > el.dataset.end) {
alert("Select the first page and click extend");
return;
}
el.dataset.start = pageNum;
updateTag(el);
}
function setTagEnd(el) {
if (pageNum < el.dataset.start) {
alert("Select the last page and click extend");
return;
}
el.dataset.end = pageNum;
updateTag(el);
}
function expandEntries() {
const entries = Array.from(tagArea.children);
entries.sort((a, b) => a.dataset.start-b.dataset.start);
const c = entries.length;
for (let i=0; i<c-1; i++) {
entries[i].dataset.end = entries[i+1].dataset.start - 1;
updateTag(entries[i]);
}
entries[c-1].dataset.end = pdfDoc.numPages;
updateTag(entries[c-1]);
}
function saveTags() {
const pageTags = [];
for (let pageTag of tagArea.children ) {
let start = pageTag.dataset.start;
let end = pageTag.dataset.end;
if (start == 1 && end == pdfDoc.numPages) {
start = null;
end = null;
}
pageTags.push([pageTag.dataset.tag, start, end])
}
console.log(pageTags);
fetch("", {
method: "POST",
headers: { "Content-Type": "application/json", "X-CSRFToken": "{{ csrf_token }}" },
body: JSON.stringify(pageTags)
}
).then((response) => {
if (response.ok) {
window.location = "{% url 'work_detail' collection.pk document.work_id %}"
} else {
alert("Failed: " + response.statusText)
}
});
dirty = false;
}
function get_instrument(s) {
let parts = s.split('-');
let instrument = data.instruments[parts[0]];
if (parts.length == 2) {
return instrument + " " + parts[1];
}
return instrument;
}
function checkSaved(e) {
if (dirty) {
e.preventDefault();
}
}
window.addEventListener('beforeunload', checkSaved);
</script>
{% endblock %}
@@ -1,109 +0,0 @@
{% extends "interface/project_base.html" %}
{% block admin %}
<a href="{% url 'item_list_append' project.pk %}" class="button is-link">
<span class="icon"><i class="fas fa-plus-circle"></i></span>
<span>Add</span>
</a>
<a href="#" onclick="save()" class="button is-link">
<span class="icon"><i class="fas fa-save"></i></span>
<span>Save</span>
</a>
{% endblock %}
{% block page %}
<table style="max-width: 600px; margin: 10pt auto;" class="zebra">
<thead>
<tr>
<th>Item</th>
<th>Time</th>
<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>{{ item.work.duration }}</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" onClick="moveItem({{ item.pk }}, 0)"></i>
</td>
</tr>
{% empty %}
<tr><td colspan="2">No items</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;
if (dir === 0) {
items[i].dataset.order = -1;
items[i].style = "display: none";
} else {
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 'item_list' project=project.pk %}";
} else {
alert("Failed: " + response.statusText)
}
})
}
function checkSaved(e) {
if(dirty) {
e.preventDefault();
}
}
window.addEventListener('beforeunload', checkSaved);
</script>
{% endblock %}
@@ -1,27 +0,0 @@
<h3 class="subtitle">{{ work.name }}</h3>
<div class="block tags">
{% 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>
{% endfor %}
</div>
<!--
<h3 class="subtitle">Files</h3>
<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>
-6
View File
@@ -1,6 +0,0 @@
try:
from .local_settings import * # noqa
except ImportError:
from .default_settings import * # noqa
INSTALLED_APPS += POLYPHONIC_MODULES # type: ignore #noqa
+15
View File
@@ -0,0 +1,15 @@
services:
polyphonic:
image: "polyphonic:0.8.4"
build: .
ports:
- "8001:8000"
volumes:
- "./data:/var/polyphonic"
- "./local_settings.py:/opt/polyphonic/local_settings.py"
env_file: "compose.env"
environment:
DJANGO_SETTINGS_MODULE: local_settings
PYTHONPATH: /opt/polyphonic
WORK_DIR: /var/polyphonic
VERSION: 0.8.4
-8
View File
@@ -1,8 +0,0 @@
from .default_settings import *
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': '/var/polyphonic/db.sqlite3',
}
}
@@ -11,6 +11,6 @@ import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'polyphonic.settings')
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "polyphonic.settings")
application = get_asgi_application()
@@ -11,26 +11,29 @@ https://docs.djangoproject.com/en/3.1/ref/settings/
"""
from pathlib import Path
from os import environ
import os
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
BASE_DIR = Path(__file__).resolve().parent.parent.parent.parent
SECRET_KEY = environ.get("SECRET_KEY")
# A place to put things
WORK_DIR = os.environ.get("WORK_DIR") or os.path.join(BASE_DIR, "data")
# Will fail to start if not defined
SECRET_KEY = os.environ.get("SECRET_KEY")
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/3.1/howto/deployment/checklist/
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = False
ALLOWED_HOSTS = ["localhost"]
INTERNAL_IPS = ["127.0.0.1"]
ALLOWED_HOSTS = ["localhost", "127.0.0.1"]
# Application definition
POLYPHONIC_MODULES = ["library"]
POLYPHONIC_MODULES = ["polyphonic.library"]
INSTALLED_APPS = [
"django.contrib.admin",
@@ -44,9 +47,11 @@ INSTALLED_APPS = [
"crispy_forms",
"crispy_bulma",
"byostorage",
"interface",
"polyphonic.interface",
]
INSTALLED_APPS += POLYPHONIC_MODULES
CRISPY_ALLOWED_TEMPLATE_PACKS = ("bulma",)
CRISPY_TEMPLATE_PACK = "bulma"
@@ -60,7 +65,7 @@ MIDDLEWARE = [
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
ROOT_URLCONF = "polyphonic.urls"
ROOT_URLCONF = "polyphonic.config.urls"
TEMPLATES = [
{
@@ -78,7 +83,7 @@ TEMPLATES = [
},
]
WSGI_APPLICATION = "polyphonic.wsgi.application"
WSGI_APPLICATION = "polyphonic.config.wsgi.application"
# Database
@@ -87,7 +92,7 @@ WSGI_APPLICATION = "polyphonic.wsgi.application"
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
"NAME": os.path.join(WORK_DIR, "db.sqlite3"),
}
}
@@ -120,17 +125,18 @@ LANGUAGE_CODE = "en-us"
TIME_ZONE = "Australia/Melbourne"
USE_I18N = True
USE_L10N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/3.1/howto/static-files/
STATIC_URL = "/static/"
STATIC_ROOT = "static"
# Need to set this
AWS_BUCKET = ""
# Library settings
CACHED_STORAGE_REMOTE = "byostorage.user.BYOStorage"
CACHED_STORAGE_DIR = os.path.join(WORK_DIR, "cache")
WHOOSH_INDEX = os.path.join(WORK_DIR, "index")
STORAGE_CLASSES = ["polyphonic.library.gdrive.storage.GDriveLinkStorage"]
+28
View File
@@ -0,0 +1,28 @@
from .base import * # noqa
from os import environ
DEBUG = True
SECRET_KEY = "DO NOT USE IN PRODUCTION"
# Enable debug toolbar
INSTALLED_APPS.append("debug_toolbar") # noqa
MIDDLEWARE.insert(1, "debug_toolbar.middleware.DebugToolbarMiddleware") # noqa
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
View File
@@ -0,0 +1,4 @@
from .base import * # noqa
# Enable WhiteNoise for static files
MIDDLEWARE.insert(1, "whitenoise.middleware.WhiteNoiseMiddleware") # noqa
@@ -19,9 +19,9 @@ from django.urls import path, include
urlpatterns = [
path("admin/", admin.site.urls),
path("", include("interface.urls")),
path("", include("polyphonic.interface.urls")),
# path('', include('submissions.urls')),
path("", include("library.urls")),
path("", include("polyphonic.library.urls")),
]
try:
@@ -11,6 +11,6 @@ import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'polyphonic.settings')
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "polyphonic.config.settings.base")
application = get_wsgi_application()
@@ -2,4 +2,4 @@ from django.apps import AppConfig
class InterfaceConfig(AppConfig):
name = "interface"
name = "polyphonic.interface"
@@ -31,12 +31,12 @@ class ProjectForm(forms.ModelForm, BaseForm):
class Meta:
model = models.Project
fields = ["name", "description", "modules", "event_date"]
# widgets = {
# 'event_date': forms.DateTimeInput(attrs={'type': 'date'})
# }
widgets = {"event_date": forms.DateTimeInput()}
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,
required=False,
)
@@ -4,7 +4,7 @@ import byostorage.user
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import interface.models
import polyphonic.interface.models
class Migration(migrations.Migration):
@@ -23,7 +23,7 @@ class Migration(migrations.Migration):
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Display name', max_length=100)),
('slug', models.SlugField(editable=False, help_text='Short name for the ensemble - used for folders', max_length=100, unique=True)),
('code', models.CharField(default=interface.models.generate_code, help_text='Ensemble registration code', max_length=9)),
('code', models.CharField(default=polyphonic.interface.models.generate_code, help_text='Ensemble registration code', max_length=9)),
('passphrase', models.CharField(help_text='Used to register ensembles', max_length=100)),
('details', models.TextField(blank=True, help_text='Description of the ensemble (markdown)')),
('admins', models.ManyToManyField(related_name='ensembles', to=settings.AUTH_USER_MODEL)),
@@ -60,7 +60,7 @@ class Migration(migrations.Migration):
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('description', models.TextField(blank=True)),
('file', models.FileField(storage=byostorage.user.BYOStorage(), upload_to=interface.models.resource_key)),
('file', models.FileField(storage=byostorage.user.BYOStorage(), upload_to=polyphonic.interface.models.resource_key)),
('media_type', models.CharField(choices=[('audio', 'Audio'), ('video', 'Video'), ('general', 'General')], default='*', max_length=10)),
('visible', models.BooleanField(default=True)),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='resources', to='interface.project')),
@@ -192,8 +192,9 @@ class Project(models.Model):
class Module(models.Model):
"""Enable modules on a oriject"""
name = models.SlugField(
max_length=20, choices=[(x, x.title()) for x in settings.POLYPHONIC_MODULES]
name = models.CharField(
max_length=100,
choices=[(x, x.replace(".", " ").title()) for x in settings.POLYPHONIC_MODULES],
)
project = models.ForeignKey(
Project, related_name="modules", on_delete=models.CASCADE
@@ -32,10 +32,11 @@
.menu-label {
color: var(--primary);
}
/*
.button.is-primary, .button.is-primary:hover {
background-color: var(--primary);
}
*/
A.admin-link:after {
content: "*";
@@ -60,4 +61,4 @@ TEXTAREA.input {
padding: 10px 10px;
margin-top: 30px;
border-top-left-radius: 6px;
}
}

Before

Width:  |  Height:  |  Size: 426 KiB

After

Width:  |  Height:  |  Size: 426 KiB

Before

Width:  |  Height:  |  Size: 258 B

After

Width:  |  Height:  |  Size: 258 B

@@ -1,17 +1,15 @@
{% load static %}
<!DOCTYPE html>
<html lang="en">
<html lang="en" data-theme="light">
<head>
<!-- Required meta tags -->
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/png" href="{% static 'interface/icon.png' %}" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.3/css/bulma.min.css">
<link rel="stylesheet" href="{% static 'interface/css/polyphonic.css' %}"></link>
<script src="{% static 'interface/js/interface.js' %}"></script>
<script src="//unpkg.com/alpinejs" defer></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>
<link rel="icon" type="image/png" href="{% static 'images/icon.png' %}" />
<link rel="stylesheet" href="{% static 'css/bulma.min.css' %}" />
<link rel="stylesheet" href="{% static 'css/polyphonic.css' %}" />
<link rel="stylesheet" href="{% static 'css/material.css' %}" />
<script src="{% static 'js/interface.js' %}"></script>
<title>{% block title %}Polyphonic{% endblock %}</title>
{% block media %}{% endblock %}
<style>{% block style %}{% endblock %}</style>
@@ -23,7 +21,7 @@
<nav class="navbar" role="navigation">
<div class="navbar-brand has-text-primary">
<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>
</a>
<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'%}
<span class="file-cta">
<span class="file-icon">
<i class="fas fa-upload"></i>
<span class="material-symbols-outlined">file_upload</span>
</span>
<span class="file-label">
Choose a file…
@@ -25,4 +25,4 @@
fileName.textContent = fileInput.files[0].name;
}
}
</script>
</script>
@@ -1,19 +1,20 @@
{% extends "interface/project_base.html" %}
{% load md2 %}
{% load polyphonic %}
{% block admin %}
<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>
</a>
{% if inactive %}
<a href="?" class="button is-link">
<span class="icon"><i class="fas fa-archive"></i></span>
{{ "preview_off"|icon }}
<span>Hide old</span>
</a>
{% else %}
<a href="?inactive" class="button is-link">
<span class="icon"><i class="fas fa-archive"></i></span>
{{ "preview"|icon }}
<span>Show all</span>
</a>
{% endif %}
@@ -35,23 +36,17 @@ Contacts:
{% include 'interface/project_items.html' %}
{% if request.is_admin %}
<div class="">
<div class="card">
<header class="card header">
<p class="card-header-title">Admin Details</p>
</header>
<div class="card-content">
<ul>
<li><a href="{{ ensemble_link }}">Ensemble Sharing Link</a></li>
<li><a href="{% url 'project_create' ensemble.pk %}">Add a new project</a></li>
</ul>
</div>
</div>
<div class="box">
<h3 class="subtitle">Admin Details</h3>
<ul>
<li><a href="{{ ensemble_link }}">Ensemble Sharing Link</a></li>
<li><a href="{% url 'project_create' ensemble.pk %}">Add a new project</a></li>
</ul>
</div>
{% endif %}
<div>
<div class="has-text-right is-size-6">
<a href="{% url 'forget_resource' 'ensemble' ensemble.slug %}">Forget this ensemble</a>
</div>
{% endblock %}
{% endblock %}
@@ -5,7 +5,7 @@
{% comment %}
<div class="admin-tools is-pulled-right">
<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>
</a>
</div>
@@ -25,10 +25,13 @@
<img src="https://www.gravatar.com/avatar/{{ ensemble.email }}?d=mp" alt="Placeholder image">
</figure>
</div>
<div class="media-content" style="min-height: 60px">
<div class="media-content" style="min-height: 100px">
<a href="{% url 'ensemble_detail' ensemble.slug %}">
<p class="title is-4">{{ ensemble.name }}</p>
</a>
<div class="mt-3">
{{ ensemble.details|markdown }}
</div>
</div>
</div>
</div>
@@ -40,9 +43,12 @@
</div>
</div>
{% empty %}
<div class="hero">
You don't currently have access to any ensembles - ask your administrator for a link.
<div class="hero mx-auto my-4">
<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>
{% endfor %}
</div>
{% endblock %}
{% endblock %}
@@ -21,7 +21,7 @@
<p class="menu-label">This Project</p>
<ul class="menu-list">
<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>
{% endif %}
{% for page in project.wiki_pages.all %}
@@ -4,11 +4,11 @@
{% block admin %}
<a href="{% url 'wiki_create' project=project.pk %}" class="button is-link">
<span class="icon"><i class="fas fa-file"></i></span>
<span>Add Page</span>
{{ "add_notes"|icon }}
<span>Add Page</span>
</a>
<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>
</a>
{% endblock %}
@@ -29,7 +29,7 @@
</h3>
{% endif %}
<div class="block">
<div class="block content">
{% if project.description %}
<p class="content">{{ project.description|markdown }}</p>
{% else %}
@@ -40,40 +40,36 @@
{% if project.owner %}
<div class="block">
{% 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 %}
The project owner is {{ project.owner }}.
The project owner is <strong>{{ project.owner }}</strong>.
{% endif %}
</div>
{% endif %}
</div>
{% if request.is_admin %}
<div class="box">
<h3 class="subtitle">Admin Actions</h3>
<ul>
<li><a href="{{ project_link }}">Project Link</a></li>
</ul>
</div>
{% endif %}
</div>
{% if 'library' in modules %}
<div class="column is-one-third">
{% if 'polyphonic.library' in modules %}
{% include 'library/project_detail.html' %}
</div>
{% endif %}
{% if 'submission' in modules %}
<div class="column">
{% if 'polyphonic.submission' in modules %}
{% include 'submissions/project_detail.html' %}
</div>
{% endif %}
</div>
</div>
{% if request.is_admin %}
<div class="box">
<h3 class="subtitle">Admin Actions</h3>
<ul>
<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>
</div>
{% endif %}
<!--
<div>
@@ -12,7 +12,8 @@
<p class="card-header-icon" style="color: black;">{{ project.rough_date }}</p>
</header>
</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">
{{ project.description | markdown }}
</div>
@@ -26,11 +27,11 @@
</div>
</div>
{% empty %}
<div class="hero">
<div class="hero-body">
<div class="hero mx-auto my-4">
<div class="hero-body has-text-centered">
<p class="title">No projects currently planned</p>
<p class="subtitle">Go put your feet up!</p>
</div>
</div>
{% endfor %}
</div>
</div>
@@ -1,9 +1,10 @@
{% extends "interface/project_base.html" %}
{% load md2 %}
{% load polyphonic %}
{% block admin %}
<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>
</a>
{% endblock %}
@@ -27,11 +28,11 @@
<div class="card-header-icon">
{% if request.is_admin %}
<a href="{% url 'resource_upload' project=project.pk pk=resource.pk %}" class="icon" title="Upload">
<i class="fas fa-upload"></i>
<a href="{% url 'resource_upload' project=project.pk pk=resource.pk %}" title="Upload">
{% icon "upload_file" %}
</a>
<a href="{% url 'resource_edit' project=project.pk pk=resource.pk %}" class="icon" title="Edit">
<i class="fas fa-edit"></i>
<a href="{% url 'resource_edit' project=project.pk pk=resource.pk %}" title="Edit">
{% icon "edit" %}
</a>
{% endif %}
</div>
@@ -1,9 +1,10 @@
{% extends "interface/project_base.html" %}
{% load polyphonic %}
{% block admin %}
<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>
<span>Edit</span>
{{ "edit"|icon }}
<span>Edit</span>
</a>
{% endblock %}
@@ -12,4 +13,4 @@
<div class="box content wiki-page">
{{ wiki_html|safe }}
</div>
{% endblock %}
{% endblock %}
@@ -5,7 +5,7 @@ register = template.Library()
def basename(value):
return os.path.basename(value)
return os.path.basename(str(value))
register.filter("basename", basename)
@@ -0,0 +1,36 @@
from django import template
from django.utils import timesince
from django.utils.html import format_html
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):
return timesince.timesince(value, depth=1)
register.filter("roughtimesince", roughtimesince)
def roughtimeuntil(value):
return timesince.timeuntil(value, depth=1)
register.filter("roughtimeuntil", roughtimeuntil)
@@ -1,5 +1,5 @@
from django.test import TestCase
from interface import models
from polyphonic.interface import models
from django.contrib.auth.models import User
from django.utils import timezone
from datetime import timedelta
@@ -1,5 +1,4 @@
from interface import models
from polyphonic.interface import models
from . import AccessTestCase
@@ -15,7 +15,7 @@ from django.contrib.auth import logout
from markdown2 import markdown
from . import models, forms
from interface.utils import check_signed_url
from .utils import check_signed_url
import logging
@@ -2,4 +2,4 @@ from django.apps import AppConfig
class LibraryConfig(AppConfig):
name = "library"
name = "polyphonic.library"
@@ -1,7 +1,7 @@
from django import forms
from .models import Work
from interface.models import Project
from interface.forms import BaseForm
from polyphonic.interface.models import Project
from polyphonic.interface.forms import BaseForm
class WorkCreateForm(forms.ModelForm, BaseForm):
@@ -44,3 +44,11 @@ class ProjectEnsembleChoiceField(forms.ModelChoiceField):
class ProjectSelectForm(BaseForm):
project = ProjectEnsembleChoiceField(queryset=Project.objects.all())
class DocumentLinkForm(BaseForm):
link = forms.URLField(help_text="Paste the direct link relevant to this storage")
class DocumentBulkForm(BaseForm):
folder_link = forms.URLField(help_text="Paste the folder link for this storage")
+90
View File
@@ -0,0 +1,90 @@
from polyphonic.library.models import Collection, Work, WorkMeta, Document
from byostorage.models import UserStorage
import logging
logger = logging.getLogger(__name__)
def sync_work(work: Work):
try:
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
logger.info("Syncing '%s' from %r", work.name, folder_id)
storage = UserStorage.objects.get(name="gdrive").instance()
existing = set(
[
storage.parse_resource(x.partition(":")[2]).id
for x in work.docs.values_list("upload", flat=True)
]
)
logger.debug("%d existing documents", len(existing))
_, files = storage.listdir(folder_id)
logger.debug("Remote files: %r", files)
for file in files:
if file.id in existing:
logger.debug("%30s: Skipping existing (%s)", file.name, file.id)
existing.discard(file.id)
continue
if not file.name.lower().endswith(".pdf"):
logger.debug("%40s: Not a PDF", file.name)
continue
logger.info("%40s: Adding", file.name)
doc = work.docs.create(upload=f"gdrive:{file}", doctype=Document.DOCTYPE_PDF)
doc.auto_tag()
for uri in existing:
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):
logger.info("Syncing '%s'", collection)
if not collection.storage.storage.endswith("GDriveLinkStorage"):
return sync_partial_collection(collection, sync_existing)
existing = dict(
WorkMeta.objects.filter(
work__collection=collection, name="folderid"
).values_list("value", "work_id")
)
folder = collection.prefix
storage = collection.storage.instance()
folders, _ = storage.listdir(folder)
for folder in folders:
if folder[0] == "_":
continue
if folder.id in existing:
if sync_existing:
logger.info("%40s: Syncing (%s)", folder.name, folder.id[:12])
sync_work(Work.objects.get(pk=existing[folder.id]))
del existing[folder.id]
continue
logger.info("%40s: Adding", folder.name)
work = Work(name=folder.name, collection=collection)
work.save()
work.meta_info.create(name="folderid", value=folder.id)
sync_work(work)
for folderid, work in existing:
logger.warning("Folder for work %d no longer in drive (%s)", work, folderid)
+141
View File
@@ -0,0 +1,141 @@
from django.core.files.storage import Storage
from collections import namedtuple
import requests
import re
from gzip import GzipFile
import logging
logger = logging.getLogger(__name__)
SHARED_FOLDER = re.compile(
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"
class DriveObject(namedtuple("DriveObject", ("id", "key", "name"))):
@classmethod
def from_string(cls, s: str):
resource, _, name = s.partition("/")
id, _, key = resource.partition("#")
return cls(id, key, name)
def __str__(self):
resource = f"{self.id}#{self.key}" if self.key else self.id
return f"{resource}/{self.name}"
class GDriveLinkStorage(Storage):
is_writable = False
def __init__(self, api_key):
self.api_key = api_key
super().__init__()
def parse_resource(self, name) -> DriveObject:
return DriveObject.from_string(name)
def extract_resource(self, url, *patterns) -> DriveObject:
logger.debug("EXTRACT_RESOURCE: %r", url)
for pattern in patterns:
match = pattern.match(url)
if match:
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}")
def get_json(self, url, resource: DriveObject):
headers = {}
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()
logger.debug("Data: %r", data)
return data
def listdir(self, path) -> tuple[list[str], list[str]]:
# used to test for valid connection parameters - should do something to validate API key here
logger.debug("listdir: %s", path)
if path == "":
return [], []
folder = self.parse_resource(path)
url = f"{FILES_API}?q='{folder.id}'+in+parents&key={self.api_key}"
data = self.get_json(url, folder)
files = []
folders = []
for x in data["files"]:
if x["mimeType"] == "application/vnd.google-apps.folder":
# folders.append(f"{x['id']}/{x['name']}")
folders.append(DriveObject(x["id"], x.get("resourceKey"), x["name"]))
else:
# files.append(f"{x['id']}/{x['name']}")
files.append(DriveObject(x["id"], x.get("resourceKey"), x["name"]))
return folders, files
def get_meta(self, name):
file_resource = self.parse_resource(name)
url = f"{FILES_API}/{file_resource.id}?key={self.api_key}"
return self.get_json(url, file_resource)
def open(self, name, mode="rb"):
resource = self.parse_resource(name)
url = f"{FILES_API}/{resource.id}?alt=media&key={self.api_key}"
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)
def size(self, name):
raise NotImplementedError()
def delete(self, name):
pass
def url(self, name):
logger.debug("URL: %r", name)
resource = self.parse_resource(name)
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):
try:
return self.extract_resource(url, SHARED_FOLDER)
except FileNotFoundError:
return None
def get_file_id(self, url):
try:
return self.extract_resource(url, SHARED_FILE)
except FileNotFoundError:
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 folder_import(self, url) -> list[str]:
folder_id = self.extract_id(url, SHARED_FOLDER)
_, files = self.listdir(folder_id)
return files
"""
+62
View File
@@ -0,0 +1,62 @@
from django.shortcuts import resolve_url, redirect
from django.views.generic import FormView
from django.views.generic.detail import SingleObjectMixin
from polyphonic.library.views import CollectionMixin
from polyphonic.library.models import Work, Document
from polyphonic.library import forms
from byostorage.models import UserStorage
from . import sync_work
class WorkGDriveView(CollectionMixin, SingleObjectMixin, FormView):
model = Work
template_name = "library/gdrive.html"
form_class = forms.DocumentLinkForm
@property
def cancel_url(self):
return resolve_url("work_detail", self.collection.pk, self.kwargs["pk"])
def get_context_data(self, *args, **kwargs):
self.object = self.get_object()
data = super().get_context_data(*args, **kwargs)
data["meta"] = dict(self.object.meta_info.values_list("name", "value"))
print(data["meta"])
return data
def form_valid(self, form):
link = form.cleaned_data["link"]
# storage = self.collection.storage.instance()
storage = UserStorage.objects.get(name="gdrive").instance()
self.object = self.get_object()
folderid = storage.get_folder_id(link)
if folderid:
self.object.meta_info.update_or_create(
name="folderid", defaults={"value": folderid}
)
sync_work(self.object)
return redirect("work_detail", self.collection.pk, self.kwargs["pk"])
link = storage.import_link(link)
if link is None:
form.add_error("link", "Not a valid link")
return self.form_invalid(form)
work = self.collection.works.get(pk=self.kwargs["pk"])
doc = Document(
work=work,
upload=f"gdrive:{link}",
# upload=f"{self.collection.storage.name}:{link}",
doctype=Document.DOCTYPE_PDF,
)
doc.save()
doc.auto_tag()
return redirect("work_detail", self.collection.pk, self.kwargs["pk"])
@@ -3,9 +3,9 @@ from django.conf import settings
from django.utils.module_loading import import_module
from django.db.models import QuerySet
from library.models import Work, Collection
from polyphonic.library.models import Work, Collection
instance = getattr(settings, "INDEXER", "library.indexer.whoosh")
instance = getattr(settings, "INDEXER", "polyphonic.library.indexer.whoosh")
class Indexer(Protocol):
@@ -3,7 +3,7 @@ from whoosh.analysis import StemmingAnalyzer, CharsetFilter
from whoosh.support.charset import accent_map
from whoosh.fields import Schema, TEXT, KEYWORD, NUMERIC
from whoosh.qparser import QueryParser
from whoosh.query import Term, NullQuery
from whoosh.query import Term, NullQuery, FuzzyTerm
from typing import Any
@@ -24,7 +24,7 @@ schema = Schema(
)
index_path = os.path.join(os.path.dirname(settings.BASE_DIR), "index")
index_path = settings.WHOOSH_INDEX
def create_index() -> Index:
@@ -61,20 +61,26 @@ def search(
pagesize: int = 20,
) -> tuple[list[dict], dict[str, Any]]:
meta = {}
query = query.lower()
qp = QueryParser("text", schema=schema)
q = qp.parse(query.lower())
meta["query"] = str(q)
q = qp.parse(query)
terms = NullQuery
limit = NullQuery
for c in collections:
terms = terms | Term("collection", c)
q = q & terms
limit |= Term("collection", c)
hits = []
ix = get_index()
with ix.searcher() as searcher:
results = searcher.search_page(q, page, pagesize)
results = searcher.search_page(q & limit, page, pagesize)
# if no results, do a prefix search
if results.results.is_empty():
qp.termclass = FuzzyTerm
q = qp.parse(query)
results = searcher.search_page(q & limit, page, pagesize)
for result in results:
hits.append(
dict(
@@ -85,7 +91,7 @@ def search(
collection_id=int(result["collection"]),
)
)
meta["query"] = str(q & limit)
meta["total"] = len(results)
return hits, meta
@@ -1,7 +1,7 @@
from django.core.management.base import BaseCommand
from library import models
from library.indexer import model_search, index_works, indexer
from polyphonic.library import models
from polyphonic.library.indexer import model_search, index_works, indexer
FORMATTER = "{w.name:50s} {w.edition:15s} {w.collection.name:15s}"
@@ -0,0 +1,25 @@
from django.core.management.base import BaseCommand, CommandError
from polyphonic.library.models import Work, Collection
from polyphonic.library.gdrive import sync_work, sync_collection
class Command(BaseCommand):
help = "Synchronizes folders and works"
def add_arguments(self, parser):
parser.add_argument("type", type=str, choices=["work", "collection"])
parser.add_argument("pk", nargs="?", type=int)
def handle(self, *args, **options):
if options["type"] == "work":
work = Work.objects.get(pk=options["pk"])
sync_work(work)
return
if options["type"] == "collection":
collection = Collection.objects.get(pk=options["pk"])
sync_collection(collection)
return
raise CommandError("Unknown object type")
@@ -4,7 +4,7 @@ import byostorage.user
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import library.models
import polyphonic.library.models
class Migration(migrations.Migration):
@@ -35,7 +35,7 @@ class Migration(migrations.Migration):
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('doctype', models.PositiveSmallIntegerField(choices=[(1, 'PDF'), (2, 'Audio'), (3, 'Video'), (4, 'Source')], default=1)),
('upload', models.FileField(storage=byostorage.user.BYOStorage(), upload_to=library.models.doc_upload_filename)),
('upload', models.FileField(storage=byostorage.user.BYOStorage(), upload_to=polyphonic.library.models.doc_upload_filename)),
('created', models.DateTimeField(auto_now_add=True)),
('version', models.CharField(blank=True, max_length=30)),
],
@@ -3,7 +3,7 @@
import byostorage.cached
import byostorage.user
from django.db import migrations, models
import library.models
import polyphonic.library.models
class Migration(migrations.Migration):
@@ -26,7 +26,7 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='document',
name='upload',
field=models.FileField(storage=byostorage.cached.CachedStorage(byostorage.user.BYOStorage()), upload_to=library.models.doc_upload_filename),
field=models.FileField(storage=byostorage.cached.CachedStorage(byostorage.user.BYOStorage()), upload_to=polyphonic.library.models.doc_upload_filename),
),
migrations.AlterField(
model_name='work',
@@ -11,8 +11,8 @@ import os.path
from byostorage.user import BYOStorage
from byostorage.cached import CachedStorage
from library.music_tags import MusicTag
from interface.utils import sign_data
from polyphonic.library.music_tags import MusicTag, auto_tag
from polyphonic.interface.utils import sign_data
import logging
@@ -292,7 +292,11 @@ class Work(models.Model):
@property
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())
# return [ s[1] for s in sections ]
sections = list(dict(sections).items()) # primitive unique()
@@ -366,8 +370,8 @@ class Work(models.Model):
def music_tags(self):
tags = dict(self.orchestration.as_list())
for section in Section.objects.filter(doc__work_id=self.pk):
tags.setdefault(section.tag, section.name)
# for section in Section.objects.filter(doc__work_id=self.pk):
# tags.setdefault(section.tag, section.name)
return tags.items()
@@ -383,6 +387,7 @@ class WorkMeta(models.Model):
("genre", "Genre"),
("style", "Style"),
("orchestration", "Orchestration"),
("folderid", "GDrive"),
)
work = models.ForeignKey(Work, on_delete=models.CASCADE, related_name="meta_info")
@@ -427,6 +432,14 @@ class Document(models.Model):
created = models.DateTimeField(auto_now_add=True)
version = models.CharField(max_length=30, blank=True)
def auto_tag(self):
if self.doctype != self.DOCTYPE_PDF:
return
filename = os.path.basename(str(self.upload))
inst = auto_tag(filename)
if inst:
self.sections.get_or_create(tag=inst.tag)
def delete(self, *args, **kwargs):
self.upload.delete(save=False)
return super().delete(*args, **kwargs)
+325
View File
@@ -0,0 +1,325 @@
from dataclasses import dataclass
import re
TAG_PREFIXES = {
"mvmt": "Movement",
"ex": "Excerpt",
"sect": "Section",
"name": "Name",
"no": "Number",
"page": "Page",
}
# taken from https://imslp.org/wiki/IMSLP:Abbreviations_for_MusicTags
# Include any aliases at the top
INSTRUMENTS = """
score Score
cb Double Bass
mall Mallet Percussion
vln Violin
vla Viola
kit Drumkit
asax Alto Sax
tsax Tenor Sax
bsax Bari Sax
acc Accordion
afl Alto flute
alt Alto (voice) (contralto)
arp Arpeggione
bag Bagpipe
bar Baritone (voice)
bass Bass (voice)
bbar Bass baritone (voice)
bc Continuo (Basso continuo)
bcl Bass clarinet
bell Bell (Chimes)
bfl Bass flute
bgtr Bass guitar
bjo Banjo
bn Bassoon
bob Bass oboe (Baritone oboe)
br Brass MusicTags
bryt Baryton
bstcl Basset clarinet
bsthn Basset horn
bug Bugle
cbcl Contrabass clarinet
cbn Contrabassoon
cch Children's chorus
cel Celesta
ch Mixed chorus
cimb Cimbalom
cit Cittern
cl Clarinet
clvd Clavichord
cm Chalumeau
conc Concertina
crh Crumhorn
crt Cornet
crtt Cornett (Zink)
cv Child's voice
db Double Bass
drum Drumset
dlcn Dulcian
dom Domra
dulc Dulcimer
egtr Electric guitar
eh English horn (Cor anglais)
elec Electronic MusicTags
epf Electric piano
eq Equal voices
erhu Erhu
euph Euphonium
fch Female chorus
fda Flute d'amore (Tenor flute)
fgh Flugelhorn
fife Fife
fl Flute
flag Flageolet
ghca Glass harmonica (Bowl organ)
gl Glockenspiel
gtr Guitar
harm Harmonium
hca Harmonica (Mouth Organ)
heck Heckelphone
hn Horn
hp Harp
hpd Harpsichord
kbd Keyboard MusicTag
lute Lute
lyre Lyre
mand Mandolin
mar Marimba
mch Male chorus
mez Mezzo-soprano
mus Musette
nar Narrator (Reciter)
ob Oboe
oca Ocarina
oda Oboe d'amore
om Ondes Martenot
oph Ophicleide
orch Orchestra
org Organ
oud Oud
pan Pan flute (Pan-pipes)
perc Percussion
pf Piano
pf3h Piano 3 hands
pf4h Piano 4 hands
pf5h Piano 5 hands
pf6h Piano 6 hands
pflh Piano left hand
pfped Pedal piano
pfrh Piano right hand
picc Piccolo
pipa Pipa
pk Timpani
ptpt Piccolo trumpet
reb Rebec
rec Recorder
sar Sarrusophone
sax Saxophone
sheng Sheng
shw Shawm
sit Sitar
skbt Sackbut
sop Soprano (voice)
srp Serpent
stpt Slide trumpet
str String MusicTags
sxh Saxhorn
syn Synthesizer
tba Tuba
tbn Trombone
ten Tenor
thrm Theremin
timp Timpani
tpt Trumpet
uch Unison chorus
uke Ukelele (Ukulele)
v Voice (solo)
va Viola
vap Viola pomposa
vc Cello
vda Viola d'amore
vib Vibraphone
vie Vielle (Hurdy-Gurdy)
viol Viol (Viola da gamba)
vlne Violone
vn Violin
vuv Vuvuzela
vv Voices (multiple soloists)
wag Wagner tuba
ww Woodwind MusicTags
xiao Xiao
xyl Xylophone
zith Zither
"""
MUSIC_TAG = re.compile(r"((?P<prefix>\w+):)?(?P<name>.*?)(\-(?P<number>[0-9]+))?")
MUSIC_TAGS = []
TAG_ALIASES = {}
# GENERAL_TAGS = set()
for line in INSTRUMENTS.split("\n"):
parts = line.strip().split(maxsplit=1)
if len(parts) < 2:
continue
name, _, _ = parts[1].partition("(")
MUSIC_TAGS.append((parts[0], name.strip()))
# if i == 0:
# GENERAL_TAGS.add(parts[0])
TAG_ALIASES.setdefault(name, []).append(parts[0])
MUSIC_NAME_BY_TAG = dict(MUSIC_TAGS)
MUSIC_TAG_BY_NAME = dict(((x[1].lower(), x[0]) for x in MUSIC_TAGS))
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
def from_tag(cls, tag):
"""
>>> MusicTag.from_tag('vn-1')
MusicTag(name='Violin', number=1, prefix='')
>>> MusicTag.from_tag('db')
MusicTag(name='Double Bass', number=None, prefix='')
>>> MusicTag.from_tag('Jaws Harp')
MusicTag(name='Jaws Harp', number=None, prefix='')
>>> MusicTag.from_tag('mvmt:Largo-2')
MusicTag(name='Largo', number=2, prefix='mvmt')
>>> MusicTag.from_tag('name: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')
"""
match = MUSIC_TAG.fullmatch(tag)
if match is None:
raise ValueError("Not a valid tag")
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
def is_general(self):
"""
>>> MusicTag('A3', prefix="name").is_general
True
>>> MusicTag('Violin', 2).is_general
False
"""
return self.prefix in TAG_PREFIXES
@property
def tag(self):
"""
>>> MusicTag('Violin', 1).tag
'vn-1'
>>> MusicTag('Double Bass').tag
'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'
"""
parts = [self.prefix]
parts.append(MUSIC_TAG_BY_NAME.get(self.name.lower(), slug(self.name)))
if self.number:
parts.extend(["-", str(self.number)])
return "".join(parts)
def __str__(self):
"""
>>> str(MusicTag('Violin', 1))
'Violin 1'
>>> str(MusicTag('Double Bass'))
'Double Bass'
>>> str(MusicTag('Unknown Instrument'))
'Unknown Instrument'
"""
if self.number:
return f"{self.name} {self.number}"
return self.name
PATTERNS = [
re.compile(r"(?P<inst>[A-Za-z]+)[_\- ]*(?P<number>\d+)"),
re.compile(r"(?P<number>\d+)(st|nd|rd|th)[_\- ]*(?P<inst>[A-Za-z]+)"),
re.compile(r"(?P<inst>[A-Za-z]+)()"),
]
def auto_tag(filename):
"""
>>> auto_tag('Ode to Joy - Violin 1.pdf').tag
'vn-1'
>>> auto_tag('Ode to Joy_Cello.pdf').tag
'vc'
>>> auto_tag('Ode to Joy violin - 1.pdf').tag
'vn-1'
>>> auto_tag('Ode to Joy - vla.pdf').tag
'va'
>>> auto_tag('Ode to Joy - fl-2 (piccolo).pdf').tag
'fl-2'
>>> auto_tag('1st Violin - Ode to Joy.pdf').tag
'vn-1'
>>> auto_tag('Ode to Joy - 2nd Violin.pdf').tag
'vn-2'
"""
for pattern in PATTERNS:
for m in pattern.finditer(filename):
inst = m["inst"].lower()
try:
number = int(m["number"])
except IndexError:
number = None
if inst in MUSIC_TAG_BY_NAME:
return MusicTag(inst.title(), number)
if inst in MUSIC_NAME_BY_TAG:
return MusicTag(MUSIC_NAME_BY_TAG[inst], number)
if __name__ == "__main__":
import doctest
print(doctest.testmod())
@@ -1,4 +1,5 @@
{% extends "interface/project_base.html" %}
{% load polyphonic %}
{% block page %}
<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 }}"/>
</div>
<div class="control">
<a class="button" href="?"><i class="fas fa-times"></i></a>
<a class="button" href="?">
{% icon "close" %}
</a>
</div>
</div>
</form>
@@ -47,4 +50,5 @@
<div>
<small>{{ ensemble.ensemble_code }}</small>
</div>
{% endblock %}
@@ -0,0 +1,784 @@
{% extends "interface/project_base.html" %}
{% load polyphonic %}
{% block admin %}
<a href="#" onclick="saveTags()" class="button is-link">
{% icon "save" %}
<span>Save</span>
</a>
<a href="{% url 'work_detail' collection=collection.pk pk=object.work_id %}" class="button is-link is-light">
{% icon "backspace" %}
<span>Cancel</span>
</a>
{% endblock %}
{% block media %}
<style>
#unassigned-area {
max-width: 200px;
}
#unassigned-area .tag {
cursor: pointer;
}
#area-left h3 {
margin-top: 10px;
text-align: center;
}
.tag-grid {
display: flex;
margin-bottom: 5px;
overflow-y: auto;
}
.grid-column {
margin-left: 0;
margin-right: 0;
}
.grid-page {
height: 25px;
border: none;
border-top: 1px solid #DDD;
text-align: center;
width: 50px;
cursor: pointer;
}
.grid-page:last-child {
border-bottom: 1px solid #DDD;
height: 26px;
}
.grid-page.is-active {
/*background-color: var(--bulma-link-light);*/
color: var(--bulma-link);
background-color: #EEE;
border-radius: 0px 6px 6px 0px;
}
.grid-tag {
background: #fff;
border: 1px solid #DDD;
border-radius: 0px 4px 4px 0px;
border-left: none;
padding-left: 0.5em;
min-width: 100px;
position: absolute;
display: flex;
flex-direction: row;
justify-content: space-between;
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 {
min-width: 220px;
position: relative;
font-size: 9pt;
}
.instrument {
display: list-item !important;
}
.instrument:hover {
background-color: var(--primary);
color: white;
}
</style>
{% endblock %}
{% block page %}
<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 class="column is-narrow" id="area-left">
<h3>Page control</h3>
<div class="columns">
<div class="column is-narrow">
<button class="button is-small is-primary" id="prev">&lt;</button>
</div>
<div class="column has-text-centered">
<span id="page-num">-</span> / <span id="page-count">-</span>
</div>
<div class="column is-narrow">
<button class="button is-small is-primary" id="next">&gt;</button>
</div>
</div>
<h3>Quick Tags</h3>
<ul id="unassigned-area">
{% for tag, inst in document.work.music_tags %}
<li class="tag is-warning" onclick="assignTag('{{tag}}', this)">{{ inst }}</li>
{% endfor %}
</ul>
<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 class="column is-narrow">
<div class="has-text-centered">
<div class="box" style="display: inline-block; padding: 0px;">
<canvas id="inline-viewer" style="width: 500px;"></canvas>
</div>
</div>
</div>
<div class="column is-narrow">
<div class="tag-grid">
<div class="grid-column" id="page-list">
</div>
<div class="grid-column" id="tag-area">
</div>
</div>
</div>
</div>
<div class="modal" id="tag-modal">
<div class="modal-background" onclick="closeTagModal()"></div>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">Tag Editor</p>
<button class="delete" aria-label="close" onclick="closeTagModal()"></button>
</header>
<section class="modal-card-body">
<form>
<div class="field has-addons is-justify-content-center">
<span class="control">
<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">
{% for inst in json_data.instrumentNames %}
<option value="{{inst}}"/>
{% endfor %}
</datalist>
</span>
<span class="control tag-option enable-inst enable-mvmt enable-no">
<input type="number" class="input tag-number" min="1" size="3" id="tag-number"/>
</span>
<span class="control">
<button type="submit" class="button is-primary" onclick="event.preventDefault(); confirmTagModal(); ">Save</button>
</span>
</div>
</form>
</section>
</div>
</div>
<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 %}
{% 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 = "{% url 'document_download' collection.pk document.pk %}";
// 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 tagArea = document.getElementById('tag-area');
var dirty = false;
var tagModalCallback = null;
//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'),
draggedTag = null,
dragMode = null,
dragOrigStart = null,
dragOrigEnd = null;
/**
* 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);
try {
document.getElementById('grid-page-' + pageNum).classList.remove('is-active');
} catch(e) {}
pageNum = num;
document.getElementById('grid-page-' + pageNum).classList.add('is-active');
// 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;
}
/**
* 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;
}
queueRenderPage(pageNum-1);
}
document.getElementById('prev').addEventListener('click', onPrevPage);
/**
* Displays next page.
*/
function onNextPage() {
if (pageNum >= pdfDoc.numPages) {
return;
}
queueRenderPage(pageNum+1);
}
document.getElementById('next').addEventListener('click', onNextPage);
/**
* Asynchronously downloads PDF.
*/
pdfjsLib.getDocument(url).promise.then(function (pdfDoc_) {
pdfDoc = pdfDoc_;
const pageList = document.getElementById('page-list');
const tagGrid = document.querySelector('.tag-grid');
pageList.innerHTML = '';
for (var i=0; i<pdfDoc.numPages; i++) {
let page = i+1;
let el = document.createElement('div');
el.className = 'grid-page';
el.id = 'grid-page-' + page;
el.innerHTML = page;
el.addEventListener('click', (evt) => {
queueRenderPage(page);
});
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;
// Initial/first page rendering
renderPage(pageNum);
setTimeout(() => {
const previewBox = document.querySelector('.box');
tagGrid.style.maxHeight = previewBox.offsetHeight + 'px';
}, 100);
tagArea.innerHTML = '';
for (let pageTag of data.pageTags) {
addTag(pageTag[0], pageTag[1], pageTag[2]);
}
dirty = false;
});
function showTagModal(callback, tag) {
document.getElementById("tag-modal").classList.add("is-active");
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 closeTagModal() {
document.getElementById('tag-modal').classList.remove('is-active');
}
function addNumberedInstrument(tag, e) {
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) {
if (data.instruments[key] == name) {
name = key;
break;
}
}
}
name = name.replace(" ", "_");
const prefix = (selected == "inst") ? "" : selected + ":";
const tag = (n) ? prefix + name + "-" + n : prefix + name;
closeTagModal();
tagModalCallback(tag);
}
function createNewTag(tag) {
addTag(tag, pageNum, pageNum);
dirty = true;
}
function assignTag(tag, el) {
addTag(tag, pageNum, pageNum);
//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) {
//console.log("addTag", tag, start, end);
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.end = end;
el.dataset.tag = tag;
const parts = parseTag(tag)
el.classList.add("tag-type-" + parts.prefix);
const handle = el.querySelector('.tag-handle');
handle.draggable = true;
handle.addEventListener('dragstart', (e) => {
draggedTag = el;
dragMode = 'move';
dragOrigStart = parseInt(el.dataset.start);
dragOrigEnd = parseInt(el.dataset.end);
el.style.zIndex = 1000;
e.dataTransfer.effectAllowed = 'move';
const blankImg = new Image();
blankImg.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
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;
});
const resize = el.querySelector('.tag-handle-resize');
resize.draggable = true;
resize.addEventListener('dragstart', (e) => {
draggedTag = el;
dragMode = 'resize';
dragOrigEnd = parseInt(el.dataset.end);
el.style.zIndex = 1000;
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);
tagArea.appendChild(el);
resolveOverlaps();
}
function updateTag(tag) {
let start = tag.dataset.start;
let end = tag.dataset.end;
let span = end-start+1;
let height = span * 25 + 1;
let top = (start-1) * 25;
tag.style.height = height + 'px';
tag.style.marginTop = top + 'px';
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) {
if (pageNum > el.dataset.end) {
alert("Select the first page and click extend");
return;
}
el.dataset.start = pageNum;
updateTag(el);
}
function setTagEnd(el) {
if (pageNum < el.dataset.start) {
alert("Select the last page and click extend");
return;
}
el.dataset.end = pageNum;
updateTag(el);
}
function expandEntries() {
const answer = confirm("This will expand all tags to fill up available pages");
if (!answer) return;
const entries = Array.from(tagArea.children);
entries.sort((a, b) => a.dataset.start-b.dataset.start);
const c = entries.length;
for (let i=0; i<c-1; i++) {
entries[i].dataset.end = entries[i+1].dataset.start - 1;
updateTag(entries[i]);
}
entries[c-1].dataset.end = pdfDoc.numPages;
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() {
const pageTags = [];
for (let pageTag of tagArea.children ) {
let start = pageTag.dataset.start;
let end = pageTag.dataset.end;
if (start == 1 && end == pdfDoc.numPages) {
start = null;
end = null;
}
pageTags.push([pageTag.dataset.tag, start, end])
}
console.log(pageTags);
fetch("", {
method: "POST",
headers: { "Content-Type": "application/json", "X-CSRFToken": "{{ csrf_token }}" },
body: JSON.stringify(pageTags)
}
).then((response) => {
if (response.ok) {
window.location = "{% url 'work_detail' collection.pk document.work_id %}"
} else {
alert("Failed: " + response.statusText)
}
});
dirty = false;
}
function parseTag(tag) {
const result = {
prefix: "inst",
name: "",
number: "",
};
let parts = tag.split(":");
if(parts.length == 2) {
result.prefix = parts[0];
tag = parts[1];
}
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) {
if (dirty) {
e.preventDefault();
}
}
window.addEventListener('beforeunload', checkSaved);
</script>
{% endblock %}

Some files were not shown because too many files have changed in this diff Show More