Music tag fixes

This commit is contained in:
Tris Forster 2026-08-05 08:10:16 +10:00
parent 965fc49501
commit 471e465133
5 changed files with 269 additions and 141 deletions

View File

@ -292,7 +292,11 @@ class Work(models.Model):
@property @property
def digital_parts(self): def digital_parts(self):
sections = [(s.tag, s.name) for s in Section.objects.filter(doc__work=self.pk)] sections = [
(s.tag, s.name)
for s in Section.objects.filter(doc__work=self.pk)
if ":" not in s.tag
]
sections.sort(key=self.orchestration.sorter()) sections.sort(key=self.orchestration.sorter())
# return [ s[1] for s in sections ] # return [ s[1] for s in sections ]
sections = list(dict(sections).items()) # primitive unique() sections = list(dict(sections).items()) # primitive unique()

View File

@ -1,14 +1,15 @@
from collections import namedtuple from dataclasses import dataclass
import re import re
GENERAL = """
mvmt Movement TAG_PREFIXES = {
ex Excerpt "mvmt": "Movement",
sect Section "ex": "Excerpt",
pce Piece "sect": "Section",
no No. "name": "Name",
page Page "no": "Number",
""" "page": "Page",
}
# taken from https://imslp.org/wiki/IMSLP:Abbreviations_for_MusicTags # taken from https://imslp.org/wiki/IMSLP:Abbreviations_for_MusicTags
# Include any aliases at the top # Include any aliases at the top
@ -160,71 +161,110 @@ xyl Xylophone
zith Zither zith Zither
""" """
MUSIC_TAG = re.compile(r"((?P<prefix>\w+):)?(?P<name>.*?)(\-(?P<number>[0-9]+))?")
MUSIC_TAGS = [] MUSIC_TAGS = []
GENERAL_TAGS = set() TAG_ALIASES = {}
for i, abbreviations in enumerate((GENERAL, INSTRUMENTS)): # GENERAL_TAGS = set()
for line in abbreviations.split("\n"): for line in INSTRUMENTS.split("\n"):
parts = line.strip().split(maxsplit=1) parts = line.strip().split(maxsplit=1)
if len(parts) < 2: if len(parts) < 2:
continue continue
name, _, _ = parts[1].partition("(") name, _, _ = parts[1].partition("(")
MUSIC_TAGS.append((parts[0], name)) MUSIC_TAGS.append((parts[0], name.strip()))
if i == 0: # if i == 0:
GENERAL_TAGS.add(parts[0]) # GENERAL_TAGS.add(parts[0])
TAG_ALIASES.setdefault(name, []).append(parts[0])
MUSIC_NAME_BY_TAG = dict(MUSIC_TAGS) MUSIC_NAME_BY_TAG = dict(MUSIC_TAGS)
MUSIC_TAG_BY_NAME = dict(((x[1].lower(), x[0]) for x in MUSIC_TAGS)) MUSIC_TAG_BY_NAME = dict(((x[1].lower(), x[0]) for x in MUSIC_TAGS))
class MusicTag(namedtuple("MusicTag", ("name", "variant"), defaults=[None])): def slug(s):
"""
>>> slug("This is a test")
'This_is_a_test'
"""
return str(s).replace(" ", "_")
def deslug(s):
return s.replace("_", " ")
@dataclass
class MusicTag:
name: str
number: int | None = None
prefix: str = ""
@classmethod @classmethod
def from_tag(cls, tag): def from_tag(cls, tag):
""" """
>>> MusicTag.from_tag('vn-1') >>> MusicTag.from_tag('vn-1')
MusicTag(name='Violin', variant='1') MusicTag(name='Violin', number=1, prefix='')
>>> MusicTag.from_tag('db') >>> MusicTag.from_tag('db')
MusicTag(name='Double Bass', variant=None) MusicTag(name='Double Bass', number=None, prefix='')
>>> MusicTag.from_tag('Jaws Harp') >>> MusicTag.from_tag('Jaws Harp')
MusicTag(name='Jaws Harp', variant=None) MusicTag(name='Jaws Harp', number=None, prefix='')
>>> MusicTag.from_tag('mvmt-2') >>> MusicTag.from_tag('mvmt:Largo-2')
MusicTag(name='Movement', variant='2') MusicTag(name='Largo', number=2, prefix='mvmt')
>>> MusicTag.from_tag('pce-A2') >>> MusicTag.from_tag('name:A2')
MusicTag(name='Piece', variant='A2') MusicTag(name='A2', number=None, prefix='name')
>>> MusicTag.from_tag('name:Ode_to_Joy')
MusicTag(name='Ode to Joy', number=None, prefix='name')
>>> MusicTag.from_tag('no:-2')
MusicTag(name='', number=2, prefix='no')
""" """
abbr, _, variant = tag.partition("-")
name = MUSIC_NAME_BY_TAG.get(abbr.lower(), abbr)
if variant: match = MUSIC_TAG.fullmatch(tag)
return cls(name, variant)
return cls(name, None)
@property if match is None:
def tag(self): raise ValueError("Not a valid tag")
lc = self.name.lower()
return MUSIC_TAG_BY_NAME.get(lc, lc) result = match.groupdict()
if result["prefix"] is None:
result["name"] = MUSIC_NAME_BY_TAG.get(
result["name"].lower(), deslug(result["name"])
)
result["prefix"] = ""
else:
result["name"] = deslug(result["name"])
result["number"] = int(result["number"]) if result["number"] else None
return cls(**result)
@property @property
def is_general(self): def is_general(self):
""" """
>>> MusicTag('Piece', 'A3').is_general >>> MusicTag('A3', prefix="name").is_general
True True
>>> MusicTag('Violin', 2).is_general >>> MusicTag('Violin', 2).is_general
False False
""" """
return self.tag in GENERAL_TAGS return self.prefix in TAG_PREFIXES
def abbreviate(self): @property
def tag(self):
""" """
>>> MusicTag('Violin', 1).abbreviate() >>> MusicTag('Violin', 1).tag
'vn-1' 'vn-1'
>>> MusicTag('Double Bass').abbreviate() >>> MusicTag('Double Bass').tag
'db' 'db'
>>> MusicTag('left', prefix="page:").tag
'page:left'
>>> MusicTag("Ode to Joy", prefix="name:").tag
'name:Ode_to_Joy'
>>> MusicTag('Unknown Instrument').tag
'Unknown_Instrument'
""" """
tag = MUSIC_TAG_BY_NAME.get(self.name.lower()) parts = [self.prefix]
if self.variant: parts.append(MUSIC_TAG_BY_NAME.get(self.name.lower(), slug(self.name)))
tag = f"{tag}-{self.variant}" if self.number:
return tag parts.extend(["-", str(self.number)])
return "".join(parts)
def __str__(self): def __str__(self):
""" """
@ -232,15 +272,17 @@ class MusicTag(namedtuple("MusicTag", ("name", "variant"), defaults=[None])):
'Violin 1' 'Violin 1'
>>> str(MusicTag('Double Bass')) >>> str(MusicTag('Double Bass'))
'Double Bass' 'Double Bass'
>>> str(MusicTag('Unknown Instrument'))
'Unknown Instrument'
""" """
if self.variant: if self.number:
return f"{self.name} {self.variant}" return f"{self.name} {self.number}"
return self.name return self.name
PATTERNS = [ PATTERNS = [
re.compile(r"(?P<inst>[A-Za-z]+)[_\- ]*(?P<ord>\d+)"), re.compile(r"(?P<inst>[A-Za-z]+)[_\- ]*(?P<number>\d+)"),
re.compile(r"(?P<ord>\d+)(st|nd|rd|th)[_\- ]*(?P<inst>[A-Za-z]+)"), re.compile(r"(?P<number>\d+)(st|nd|rd|th)[_\- ]*(?P<inst>[A-Za-z]+)"),
re.compile(r"(?P<inst>[A-Za-z]+)()"), re.compile(r"(?P<inst>[A-Za-z]+)()"),
] ]
@ -248,34 +290,33 @@ PATTERNS = [
def auto_tag(filename): def auto_tag(filename):
""" """
>>> auto_tag('Ode to Joy - Violin 1.pdf') >>> auto_tag('Ode to Joy - Violin 1.pdf').tag
MusicTag(name='Violin', variant=1) 'vn-1'
>>> auto_tag('Ode to Joy_Cello.pdf') >>> auto_tag('Ode to Joy_Cello.pdf').tag
MusicTag(name='Cello', variant=None) 'vc'
>>> auto_tag('Ode to Joy violin - 1.pdf') >>> auto_tag('Ode to Joy violin - 1.pdf').tag
MusicTag(name='Violin', variant=1) 'vn-1'
>>> auto_tag('Ode to Joy - vla.pdf') >>> auto_tag('Ode to Joy - vla.pdf').tag
MusicTag(name='Viola', variant=None) 'va'
>>> auto_tag('Ode to Joy - fl-2 (piccolo).pdf') >>> auto_tag('Ode to Joy - fl-2 (piccolo).pdf').tag
MusicTag(name='Flute', variant=2) 'fl-2'
>>> auto_tag('1st Violin - Ode to Joy.pdf') >>> auto_tag('1st Violin - Ode to Joy.pdf').tag
MusicTag(name='Violin', variant=1) 'vn-1'
>>> auto_tag('Ode to Joy - 2nd Violin.pdf') >>> auto_tag('Ode to Joy - 2nd Violin.pdf').tag
MusicTag(name='Violin', variant=2) 'vn-2'
""" """
for pattern in PATTERNS: for pattern in PATTERNS:
for m in pattern.finditer(filename): for m in pattern.finditer(filename):
inst = m["inst"].lower() inst = m["inst"].lower()
try: try:
ordinal = int(m["ord"]) number = int(m["number"])
except IndexError: except IndexError:
ordinal = None number = None
if inst in MUSIC_TAG_BY_NAME: if inst in MUSIC_TAG_BY_NAME:
return MusicTag(inst.title(), ordinal) return MusicTag(inst.title(), number)
if inst in MUSIC_NAME_BY_TAG: if inst in MUSIC_NAME_BY_TAG:
return MusicTag(MUSIC_NAME_BY_TAG[inst], ordinal) return MusicTag(MUSIC_NAME_BY_TAG[inst], number)
if __name__ == "__main__": if __name__ == "__main__":

View File

@ -80,6 +80,7 @@
.tag-name { .tag-name {
flex: 1; flex: 1;
text-align: right; text-align: right;
cursor: pointer;
} }
.tag-handle-resize { .tag-handle-resize {
position: absolute; position: absolute;
@ -140,7 +141,7 @@
</ul> </ul>
<h3>Actions</h3> <h3>Actions</h3>
<div> <div>
<a class="button is-small is-primary" onclick="showAddModal()">New Tag</a> <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> <button class="button is-small is-primary" onclick="expandEntries()">Expand Tags</button>
</div> </div>
</div> </div>
@ -163,18 +164,19 @@
</div> </div>
</div> </div>
<div class="modal" id="add-modal"> <div class="modal" id="tag-modal">
<div class="modal-background" onclick="closeAddModal()"></div> <div class="modal-background" onclick="closeTagModal()"></div>
<div class="modal-card"> <div class="modal-card">
<header class="modal-card-head"> <header class="modal-card-head">
<p class="modal-card-title">Add Tag</p> <p class="modal-card-title">Tag Editor</p>
<button class="delete" aria-label="close" onclick="closeAddModal()"></button> <button class="delete" aria-label="close" onclick="closeTagModal()"></button>
</header> </header>
<section class="modal-card-body"> <section class="modal-card-body">
<form>
<div class="field has-addons is-justify-content-center"> <div class="field has-addons is-justify-content-center">
<span class="control"> <span class="control">
<span class="select"> <span class="select">
<select onChange="changeTagType()" id="tag-type-selection"> <select onChange="onChangeTagType()" id="tag-prefix">
<option value="inst">Instrument</option> <option value="inst">Instrument</option>
<option value="mvmt">Movement</option> <option value="mvmt">Movement</option>
<option value="sect">Section</option> <option value="sect">Section</option>
@ -183,32 +185,34 @@
</select> </select>
</span> </span>
</span> </span>
<span class="control add-tag-option enable-page"> <span class="control tag-option enable-page">
<span class="select"> <span class="select tag-name">
<select id="add-tag-page"> <select id="tag-name-page">
<option>Left</option> <option value="left">Left</option>
<option>Right</option> <option value="right">Right</option>
<option value="blank">Add Blank</option>
</select> </select>
</span> </span>
</span> </span>
<span class="control add-tag-option enable-sect"> <span class="control tag-option enable-sect enable-mvmt">
<input type="text" class="input" id="add-tag-name"/> <input type="text" class="input tag-name" id="tag-name" />
</span> </span>
<span class="control add-tag-option enable-inst"> <span class="control tag-option enable-inst">
<input type="text" class="input" list="instrument-list" id="add-tag-inst"/> <input type="text" class="input tag-name" list="instrument-list" id="tag-name-inst"/>
<datalist id="instrument-list"> <datalist id="instrument-list">
{% for inst in json_data.instruments.values %} {% for inst in json_data.instrumentNames %}
<option value="{{inst}}"/> <option value="{{inst}}"/>
{% endfor %} {% endfor %}
</datalist> </datalist>
</span> </span>
<span class="control add-tag-option enable-inst enable-mvmt enable-no"> <span class="control tag-option enable-inst enable-mvmt enable-no">
<input type="number" class="input" min="1" size="3" id="add-tag-ordinal"/> <input type="number" class="input tag-number" min="1" size="3" id="tag-number"/>
</span> </span>
<span class="control"> <span class="control">
<button class="button is-primary" onclick="createNewTag();">Add</button> <button type="submit" class="button is-primary" onclick="event.preventDefault(); confirmTagModal(); ">Save</button>
</span> </span>
</div> </div>
</form>
</section> </section>
</div> </div>
</div> </div>
@ -244,6 +248,7 @@
let data = JSON.parse(document.getElementById('data').textContent); let data = JSON.parse(document.getElementById('data').textContent);
let tagArea = document.getElementById('tag-area'); let tagArea = document.getElementById('tag-area');
var dirty = false; var dirty = false;
var tagModalCallback = null;
//document.getElementById('tag-list').onclick = (e) => setTag(e.target.dataset.tag); //document.getElementById('tag-list').onclick = (e) => setTag(e.target.dataset.tag);
@ -405,13 +410,25 @@
dirty = false; dirty = false;
}); });
function showAddModal() { function showTagModal(callback, tag) {
document.getElementById("add-modal").classList.add("is-active"); document.getElementById("tag-modal").classList.add("is-active");
changeTagType();
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 closeAddModal() { function closeTagModal() {
document.getElementById('add-modal').classList.remove('is-active'); document.getElementById('tag-modal').classList.remove('is-active');
} }
function addNumberedInstrument(tag, e) { function addNumberedInstrument(tag, e) {
@ -424,55 +441,48 @@
modal.classList.add('is-active'); modal.classList.add('is-active');
} }
function changeTagType() { function onChangeTagType() {
const tag_select = document.querySelector("#tag-type-selection"); const tag_prefix = document.querySelector("#tag-prefix");
const selected = tag_select.value; const selected = tag_prefix.value;
document.querySelectorAll(".add-tag-option").forEach((x) => {x.classList.add("is-hidden")}); document.querySelectorAll(".tag-option").forEach((x) => {x.classList.add("is-hidden")});
document.querySelectorAll(".add-tag-option input").forEach((x) => {x.value=""}); document.querySelectorAll(".tag-option input").forEach((x) => {x.value=""});
document.querySelectorAll(".enable-" + selected).forEach((x) => {x.classList.remove("is-hidden")}); document.querySelectorAll(".enable-" + selected).forEach((x) => {x.classList.remove("is-hidden")});
tag_select.focus(); tag_prefix.focus();
} }
function generateNewTag() { function confirmTagModal() {
const selected = document.querySelector("#tag-type-selection").value; const selected = document.querySelector("#tag-prefix").value;
const n = document.querySelector("#add-tag-ordinal").value; let name = document.querySelector("#tag-name").value;
const n = document.querySelector("#tag-number").value;
switch (selected) { switch (selected) {
case "mvmt":
case "no": case "no":
return selected + "-" + n; if (!n) throw new Error("Missing number");
case "sect": case "page":
return "sect-" + document.querySelector("#add-tag-name").value; name = document.querySelector("#tag-name-page").value;
case "inst": case "inst":
const inst_name = document.querySelector("#add-tag-inst").value; name = document.querySelector("#tag-name-inst").value;
var tag = null;
for (let key in data.instruments) { for (let key in data.instruments) {
if (data.instruments[key] == inst_name) { if (data.instruments[key] == name) {
tag = key; name = key;
break; break;
} }
} }
}
name = name.replace(" ", "_");
if (!tag) { const prefix = (selected == "inst") ? "" : selected + ":";
tag = inst_name;
}
if (n) { const tag = (n) ? prefix + name + "-" + n : prefix + name;
tag += "-" + n;
} closeTagModal();
return tag; tagModalCallback(tag);
case "page":
return "page-" + document.querySelector("#add-tag-page").value;
}
} }
function createNewTag() { function createNewTag(tag) {
const tag = generateNewTag()
addTag(tag, pageNum, pageNum); addTag(tag, pageNum, pageNum);
closeAddModal(); dirty = true;
} }
function assignTag(tag, el) { function assignTag(tag, el) {
@ -480,6 +490,16 @@
//el.remove(); //el.remove();
} }
function editTag(e) {
console.log("EDIT", e.target);
showTagModal((tag) => {
console.log("Updating to " + tag);
e.target.innerHTML = get_tag_name(tag);
e.target.parentNode.dataset["tag"] = tag;
dirty = true;
}, e.target.parentNode.dataset["tag"]);
}
function addTag(tag, start, end) { function addTag(tag, start, end) {
@ -489,13 +509,15 @@
const tagName = el.querySelector(".tag-name"); const tagName = el.querySelector(".tag-name");
tagName.innerHTML=get_tag_name(tag); tagName.innerHTML=get_tag_name(tag);
tagName.addEventListener('dblclick', editTag);
//el.className = 'grid-tag'; //el.className = 'grid-tag';
el.dataset.start = start; el.dataset.start = start;
el.dataset.end = end; el.dataset.end = end;
el.dataset.tag = tag; el.dataset.tag = tag;
const parts = tag.split("-") const parts = parseTag(tag)
el.classList.add("tag-type-" + parts[0]); el.classList.add("tag-type-" + parts.prefix);
const handle = el.querySelector('.tag-handle'); const handle = el.querySelector('.tag-handle');
@ -674,7 +696,7 @@
onPrevPage(); onPrevPage();
break; break;
case "n": case "n":
showAddModal(); showTagModal(createNewTag);
break; break;
default: default:
return; return;
@ -712,13 +734,43 @@
dirty = false; dirty = false;
} }
function get_tag_name(s) { function parseTag(tag) {
let parts = s.split('-'); const result = {
let instrument = data.instruments[parts[0]] || parts[0]; prefix: "inst",
name: "",
number: "",
};
let parts = tag.split(":");
if(parts.length == 2) { if(parts.length == 2) {
return instrument + " " + parts[1]; result.prefix = parts[0];
tag = parts[1];
} }
return instrument;
parts = tag.split('-');
if(parts.length == 2) {
result.number = parts[1];
}
result.name = parts[0];
if(result.prefix == "inst") {
result.name = data.instruments[result.name] || result.name;
}
result.name = result.name.replace("_", " ");
return result;
}
function get_tag_name(s) {
tag = parseTag(s);
console.log(tag);
let prefix = (tag.prefix == "inst") ? "" : data.prefixes[tag.prefix] + " ";
return (tag.number) ? prefix + tag.name + " " + tag.number : prefix + tag.name;
} }
function checkSaved(e) { function checkSaved(e) {

View File

@ -1,6 +1,6 @@
from doctest import DocTestSuite from doctest import DocTestSuite
from library import music_tags from polyphonic.library import music_tags
def load_tests(loader, tests, ignore): def load_tests(loader, tests, ignore):

View File

@ -20,7 +20,13 @@ import string
from polyphonic.interface.views import ProjectMixin, AuthorizedResourceMixin from polyphonic.interface.views import ProjectMixin, AuthorizedResourceMixin
from polyphonic.interface.utils import signed_url from polyphonic.interface.utils import signed_url
from polyphonic.library.models import Collection, Work, Document, Section from polyphonic.library.models import Collection, Work, Document, Section
from polyphonic.library.music_tags import MUSIC_TAGS, MusicTag from polyphonic.library.music_tags import (
MUSIC_TAGS,
MUSIC_NAME_BY_TAG,
TAG_PREFIXES,
TAG_ALIASES,
MusicTag,
)
from polyphonic.library import forms, models from polyphonic.library import forms, models
from polyphonic.library.pdf_utils import extract_pages, extract_and_concat from polyphonic.library.pdf_utils import extract_pages, extract_and_concat
from polyphonic.library.indexer import index_works, model_search from polyphonic.library.indexer import index_works, model_search
@ -85,7 +91,7 @@ class ProjectItemListView(ProjectMixin, ListView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
data = super(ProjectItemListView, self).get_context_data(**kwargs) data = super(ProjectItemListView, self).get_context_data(**kwargs)
data["instruments"] = MUSIC_TAGS data["instruments"] = TAG_ALIASES
data["instrument"] = self.request.session.get("instrument", "Score") data["instrument"] = self.request.session.get("instrument", "Score")
data["part"] = self.request.session.get("part", "0") data["part"] = self.request.session.get("part", "0")
data["running_time"] = self.get_queryset().aggregate(Sum("work__running_time"))[ data["running_time"] = self.get_queryset().aggregate(Sum("work__running_time"))[
@ -281,6 +287,26 @@ class CollectionWorkListView(WorkListView):
return data return data
class ProjectWorkListView(WorkListView, ProjectMixin):
def is_authorized(self):
return ProjectMixin.is_authorized(self)
def get_works(self):
collections = self.get_collections() or []
print(collections)
return Work.objects.filter(collection_id__in=collections)
def get_collections(self):
return self.project.ensemble.allowed_collections.values_list(
"collection_id", flat=True
)
def get_context_data(self, *args, **kwargs):
data = super(ProjectWorkListView, self).get_context_data(*args, **kwargs)
data["title"] = f"Library for {self.project.ensemble}"
return data
class WorkAddView(CollectionMixin, FormView): class WorkAddView(CollectionMixin, FormView):
template_name = "interface/default_form.html" template_name = "interface/default_form.html"
form_class = forms.WorkCreateForm form_class = forms.WorkCreateForm
@ -603,7 +629,12 @@ class DocumentAnnotateView(DocumentMixin, DetailView):
("sect", "success"), ("sect", "success"),
] ]
data["json_data"] = {"pageTags": pages, "instruments": dict(MUSIC_TAGS)} data["json_data"] = {
"pageTags": pages,
"instruments": MUSIC_NAME_BY_TAG,
"instrumentNames": list({x[1] for x in MUSIC_TAGS}),
"prefixes": TAG_PREFIXES,
}
return data return data