diff --git a/app/public/cantusdata/admin/admin.py b/app/public/cantusdata/admin/admin.py
index 5392b15c..e5e64013 100644
--- a/app/public/cantusdata/admin/admin.py
+++ b/app/public/cantusdata/admin/admin.py
@@ -29,6 +29,7 @@ class ManuscriptAdmin(ModelAdmin): # type: ignore[type-arg]
actions = [reindex_in_solr, "load_chants"]
ordering = ["-public", "name"]
list_per_page = 200
+ change_form_template = "admin/manuscript_change_form.html"
fieldsets = [
(
"Metadata",
diff --git a/app/public/cantusdata/helpers/iiif_helpers.py b/app/public/cantusdata/helpers/iiif_helpers.py
new file mode 100644
index 00000000..b9fdd837
--- /dev/null
+++ b/app/public/cantusdata/helpers/iiif_helpers.py
@@ -0,0 +1,32 @@
+"""
+Functions for IIIF API boilerplate.
+"""
+
+
+def construct_image_api_url(image_uri: str, **kwargs: str | int) -> str:
+ """
+ Construct a IIIF image API request URL from an Image URI and
+ a set of apotion IIIF Image API parameters. Works with the Image
+ API v2 and v3.
+
+ Required parameters:
+ - image_uri: The URI of the image to request.
+
+ Optional parameters (see API docs, eg. https://iiif.io/api/image/2.1/):
+ - region: The region of the image to request. Region should be a string
+ that conforms to the IIIF Image API specification (see link above).
+ - size: The size of the image to request.
+ - rotation: The rotation of the image to request.
+ - quality: The quality of the image to request.
+ - format: The format of the image to request.
+
+
+ Returns:
+ The constructed IIIF image API request URL.
+ """
+ region = kwargs.get("region", "full") # Default to full image
+ size = kwargs.get("size", "pct:50") # Default to 50% of the original size
+ rotation = kwargs.get("rotation", "0") # Default to no rotation
+ quality = kwargs.get("quality", "default") # Default to default quality
+ img_format = kwargs.get("format", "jpg") # Default to JPEG format
+ return f"{image_uri}/{region}/{size}/{rotation}/{quality}.{img_format}"
diff --git a/app/public/cantusdata/management/commands/pick_neume_exemplars.py b/app/public/cantusdata/management/commands/pick_neume_exemplars.py
deleted file mode 100644
index 464c228b..00000000
--- a/app/public/cantusdata/management/commands/pick_neume_exemplars.py
+++ /dev/null
@@ -1,247 +0,0 @@
-import os
-import os.path
-from itertools import chain
-import re
-
-import pymei
-from django.core.management.base import BaseCommand, make_option
-
-from cantusdata.models.neume_exemplar import NeumeExemplar
-from cantusdata.models.manuscript import Manuscript
-from cantusdata.models.folio import Folio
-
-
-# It's pretty awful that we need to use tricks like this to work out what folio is what
-FOLIO_REGEX = re.compile(r"_([a-z0-9]+)\.\w+$", re.IGNORECASE)
-
-
-class DocumentManipulationException(Exception):
- pass
-
-
-class Command(BaseCommand):
- """Automatically select exemplars for all neumes found in a manuscript"""
-
- option_list = BaseCommand.option_list + (
- make_option(
- "--use-specific",
- nargs=2,
- metavar="NEUME_NAME INDEX",
- help="Use the ith instance of the neume in the specified file as an exemplar (only works when "
- "given an MEI file, not a directory; index begins at 0)",
- ),
- )
-
- args = "manuscript_id file_or_directory"
-
- def handle(self, *args, **options):
- (ms_id, target) = args
-
- try:
- ms_id = int(ms_id)
- except (ValueError, TypeError):
- raise ValueError("expected a manuscript id but got {!r}".format(ms_id))
-
- # Get the manuscript object, primarily to ensure that it actually exists
- self.manuscript = Manuscript.objects.get(id=ms_id)
-
- # Exemplar
- self.exemplars = {
- exemplar.name: exemplar
- for exemplar in NeumeExemplar.objects.filter(
- folio__manuscript=self.manuscript
- )
- }
-
- if options["use_specific"]:
- if not os.path.isfile(target):
- self.stderr.write(
- "--use-specific only works when given a specific MEI file"
- )
- return
-
- name, index = options["use_specific"]
-
- try:
- index = int(index)
- except (TypeError, ValueError):
- self.stderr.write("expected an integer but got {}".format(index))
- return
-
- self.use_specific_exemplar(target, name, index)
- return
-
- if os.path.isdir(target):
- files = chain.from_iterable(
- (os.path.join(p, f) for f in fs) for (p, _, fs) in os.walk(target)
- )
- for file_path in files:
- self.find_exemplars(file_path)
- else:
- self.find_exemplars(target)
-
- def find_exemplars(self, file_path):
- try:
- folio_number, folio, doc = self.load_document(file_path)
- except DocumentManipulationException as e:
- self.stderr.write(e.message)
- return
-
- for neume in doc.getElementsByName("neume"):
- name_attr = neume.getAttribute("name")
-
- if not name_attr:
- continue
-
- name = name_attr.value
-
- if name in self.exemplars:
- continue
-
- try:
- self.save_exemplar(neume, name, folio_number, folio, doc)
- except DocumentManipulationException as e:
- self.stderr.write(e.message)
- else:
- self.stdout.write(
- 'using the first instance on folio {} for "{}"'.format(
- folio_number, name
- )
- )
-
- def use_specific_exemplar(self, file_path, desired_name, index):
- try:
- folio_number, folio, doc = self.load_document(file_path)
- except DocumentManipulationException as e:
- self.stderr.write(e.message)
- return
-
- instances_found = 0
-
- for neume in doc.getElementsByName("neume"):
- name_attr = neume.getAttribute("name")
-
- if not name_attr:
- continue
-
- name = name_attr.value
-
- if name != desired_name:
- continue
-
- if instances_found == index:
- old_exemplar = self.exemplars.get(desired_name)
-
- try:
- self.save_exemplar(neume, name, folio_number, folio, doc)
- except DocumentManipulationException as e:
- self.stderr.write(e.message)
- else:
- if old_exemplar:
- old_exemplar.delete()
-
- return
- else:
- instances_found += 1
-
- self.stderr.write(
- "could not get instance {} of neume {}: only {} instances found".format(
- index, desired_name, instances_found
- )
- )
-
- def save_exemplar(self, neume, name, folio_number, folio, doc):
- try:
- zone = get_zone(doc, neume.getAttribute("facs").value)
- except DocumentManipulationException as e:
- raise DocumentManipulationException(
- "failed to get zone for neume {}, folio {}: {}".format(
- name, folio_number, e
- )
- )
-
- x = zone["ulx"]
- y = zone["uly"]
- width = zone["lrx"] - zone["ulx"]
- height = zone["lry"] - zone["uly"]
-
- exemplar = NeumeExemplar(
- name=name,
- folio=folio,
- x_coord=x,
- y_coord=y,
- width=width,
- height=height,
- )
- exemplar.save()
-
- self.exemplars[name] = exemplar
-
- def load_document(self, file_path):
- folio_number = get_folio(file_path)
-
- if folio_number is None:
- raise DocumentManipulationException(
- "could not identify folio for file {}".format(file_path)
- )
-
- try:
- folio = Folio.objects.get(number=folio_number, manuscript=self.manuscript)
- except Folio.DoesNotExist:
- raise DocumentManipulationException(
- "no folio with number {} in manuscript".format(folio_number)
- )
- except Folio.MultipleObjectsReturned:
- raise DocumentManipulationException(
- "multiple folios with number {} in manuscript...".format(folio_number)
- )
-
- doc = pymei.documentFromFile(file_path, False).getMeiDocument()
-
- return folio_number, folio, doc
-
-
-def get_folio(file_path):
- match = FOLIO_REGEX.search(file_path)
-
- if not match:
- return None
-
- return match.group(1)
-
-
-def get_zone(doc, zone_id):
- zone = doc.getElementById(zone_id)
-
- if zone is None:
- raise DocumentManipulationException(
- "no element with id {} (expected a zone)".format(zone_id)
- )
-
- if zone.name != "zone":
- raise DocumentManipulationException(
- "expected #{} to be a zone but got {}".format(zone_id, zone.name)
- )
-
- attrs = {}
-
- for attr_name in ("ulx", "uly", "lrx", "lry"):
- attr = zone.getAttribute(attr_name)
-
- if attr is None:
- raise DocumentManipulationException(
- "no attr {} found for zone #{}".format(attr_name, zone)
- )
-
- try:
- value = int(attr.value)
- except (TypeError, ValueError):
- raise DocumentManipulationException(
- "non-integer value {!r} for attribute {}, zone #{}".format(
- attr.value, attr_name, zone
- )
- )
-
- attrs[attr_name] = value
-
- return attrs
diff --git a/app/public/cantusdata/migrations/0004_alter_neumeexemplar_options_neumeexemplar_order.py b/app/public/cantusdata/migrations/0004_alter_neumeexemplar_options_neumeexemplar_order.py
new file mode 100644
index 00000000..37994ca9
--- /dev/null
+++ b/app/public/cantusdata/migrations/0004_alter_neumeexemplar_options_neumeexemplar_order.py
@@ -0,0 +1,25 @@
+# Generated by Django 5.0.7 on 2024-09-19 18:10
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("cantusdata", "0003_chant_differentiae_database"),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name="neumeexemplar",
+ options={"ordering": ["order"]},
+ ),
+ migrations.AddField(
+ model_name="neumeexemplar",
+ name="order",
+ field=models.IntegerField(
+ default=1,
+ help_text="A helper field used to order the exemplars.\n See helpers/neume_helpers.py for an explanation of how this is used.",
+ ),
+ ),
+ ]
diff --git a/app/public/cantusdata/models/manuscript.py b/app/public/cantusdata/models/manuscript.py
index 021cbfb4..c2d0ad09 100644
--- a/app/public/cantusdata/models/manuscript.py
+++ b/app/public/cantusdata/models/manuscript.py
@@ -1,4 +1,5 @@
from django.db import models
+from django.db.models.query import QuerySet
from django.core.management import call_command
from django.utils.text import slugify
import threading
@@ -82,7 +83,7 @@ def siglum_slug(self):
return slugify(self.siglum)
@property
- def neume_exemplars(self):
+ def neume_exemplars(self) -> QuerySet[NeumeExemplar]:
return NeumeExemplar.objects.filter(folio__manuscript=self)
def create_solr_record(self):
diff --git a/app/public/cantusdata/models/neume_exemplar.py b/app/public/cantusdata/models/neume_exemplar.py
index 7dd31516..78c1efac 100644
--- a/app/public/cantusdata/models/neume_exemplar.py
+++ b/app/public/cantusdata/models/neume_exemplar.py
@@ -1,17 +1,20 @@
+from typing import Any
+
from django.db import models
+from django.utils.html import format_html
+from django.contrib import admin
from cantusdata.models.folio import Folio
+from cantusdata.helpers.iiif_helpers import construct_image_api_url
+from cantusdata.helpers.neume_helpers import NEUME_NAMES
-
-ADMIN_IMAGE_TEMPLATE = (
- ''
-)
ADMIN_IMAGE_HEIGHT = 100
+EXEMPLAR_IMAGE_SIDE_LENGTH = 80
class NeumeExemplar(models.Model):
- """Store the coordinates of an exemplary instance of a neume of a particular type
+ """
+ Store the coordinates of an exemplary instance of a neume of a particular type
These are used in OMR search to give examples of the neumes available for some
manuscript
@@ -19,7 +22,7 @@ class NeumeExemplar(models.Model):
class Meta:
app_label = "cantusdata"
- ordering = ["name"]
+ ordering = ["order"]
name = models.CharField(max_length=255, blank=False, null=False)
folio = models.ForeignKey(Folio, on_delete=models.CASCADE)
@@ -28,33 +31,40 @@ class Meta:
y_coord = models.IntegerField()
width = models.IntegerField()
height = models.IntegerField()
+ order = models.IntegerField(
+ help_text="""A helper field used to order the exemplars.
+ See helpers/neume_helpers.py for an explanation of how this is used.""",
+ default=1,
+ )
- def admin_image(self):
- """Return HTML to display the page snippet for the exemplar
+ @admin.display(description="Image")
+ def admin_image(self) -> str:
+ """
+ Return HTML to display the page snippet for the exemplar
NOTE: This is intended for use in the admin interface, not the client
"""
- return ADMIN_IMAGE_TEMPLATE.format(
- base_url="https://images.simssa.ca/iiif/image/cdn-hsmu-m2149l4",
- siglum=self.folio.manuscript.siglum_slug,
- folio=self.folio.number,
- x=self.x_coord,
- y=self.y_coord,
- w=self.width,
- h=self.height,
- img_height=ADMIN_IMAGE_HEIGHT,
- name=self.name,
- )
-
- admin_image.allow_tags = True
-
- def __str__(self):
- return "{} - {}, {} at ({}, {}), {}x{}".format(
- self.name,
- self.folio.manuscript.siglum,
- self.folio.number,
- self.x_coord,
- self.y_coord,
- self.width,
- self.height,
- )
+ image_uri = self.folio.image_uri
+ # If a neume exemplar has been chosen, the associated folio should have an image,
+ # but in case the uri was removed after selection, we should check for it here
+ if image_uri:
+ image_url = construct_image_api_url(
+ image_uri,
+ region=f"{self.x_coord},{self.y_coord},{self.width},{self.height}",
+ size=f"{ADMIN_IMAGE_HEIGHT},",
+ )
+ return format_html(
+ "",
+ image_url,
+ self.name,
+ )
+ return ""
+
+ def save(self, *args: Any, **kwargs: Any) -> None:
+ """
+ Calculate the "order" field based on the neume name so that the exemplars
+ are ordered consistently.
+ """
+ # Make the value of order the 1-indexed position of the neume in the list of neumes
+ self.order = NEUME_NAMES.index(self.name) + 1
+ super().save(*args, **kwargs)
diff --git a/app/public/cantusdata/serializers/neume_exemplar.py b/app/public/cantusdata/serializers/neume_exemplar.py
index 94fdfc84..555dfec0 100644
--- a/app/public/cantusdata/serializers/neume_exemplar.py
+++ b/app/public/cantusdata/serializers/neume_exemplar.py
@@ -1,17 +1,45 @@
-from cantusdata.models.neume_exemplar import NeumeExemplar
+from typing import cast
+
from rest_framework import serializers
+from django.db.models.query import QuerySet
+from cantusdata.models import NeumeExemplar, Folio
+
+
+class NeumeExemplarListSerializer(serializers.ListSerializer[NeumeExemplar]):
+ def update( # type: ignore
+ self,
+ instance: QuerySet[NeumeExemplar],
+ validated_data: list[dict[str, str | int]],
+ ) -> QuerySet[NeumeExemplar]:
+ instance_mapping = {
+ neume_exemplar.name: neume_exemplar for neume_exemplar in instance
+ }
+ for data in validated_data:
+ # We know the value of the "name" key is a string
+ neume_name = cast(str, data["name"])
+ neume_exemplar = instance_mapping.get(neume_name, None)
+ if neume_exemplar is not None:
+ self.child.update(neume_exemplar, data) # type: ignore[union-attr]
+ return instance.all()
-class NeumeExemplarSerializer(serializers.ModelSerializer):
+
+class NeumeExemplarSerializer(serializers.ModelSerializer[NeumeExemplar]):
class Meta:
model = NeumeExemplar
- fields = ("name", "siglum_slug", "p", "x", "y", "w", "h")
-
- siglum_slug = serializers.SlugField(source="folio.manuscript.siglum_slug")
+ fields = ["manuscript", "folio", "name", "p", "x", "y", "w", "h"]
+ list_serializer_class = NeumeExemplarListSerializer
+ manuscript: str = serializers.StringRelatedField(source="folio.manuscript.name") # type: ignore
+ folio: str = serializers.StringRelatedField(source="folio.number") # type: ignore
# These short forms match the values given in the boxes
# in Solr OMR results
- p = serializers.CharField(source="folio.image_uri")
+ p = serializers.SlugRelatedField(
+ slug_field="image_uri",
+ queryset=Folio.objects.all(),
+ source="folio",
+ style={"base_template": "input.html"},
+ )
x = serializers.IntegerField(source="x_coord")
y = serializers.IntegerField(source="y_coord")
w = serializers.IntegerField(source="width")
diff --git a/app/public/cantusdata/templates/admin/manuscript_change_form.html b/app/public/cantusdata/templates/admin/manuscript_change_form.html
new file mode 100644
index 00000000..4d919ded
--- /dev/null
+++ b/app/public/cantusdata/templates/admin/manuscript_change_form.html
@@ -0,0 +1,9 @@
+{% extends "admin/change_form.html" %}
+{% block after_related_objects %}
+
+{% endblock %}
diff --git a/app/public/cantusdata/templates/admin/pick_neume_exemplars.html b/app/public/cantusdata/templates/admin/pick_neume_exemplars.html
new file mode 100644
index 00000000..7501365c
--- /dev/null
+++ b/app/public/cantusdata/templates/admin/pick_neume_exemplars.html
@@ -0,0 +1,175 @@
+{% extends "base.html" %}
+{% block head %}
+
+
+{% endblock %}
+{% block body %}
+
+
+
+
Neume Exemplars: {{ manuscript.name }}
+ {% if not ngrams_indexed %}
+
+ It looks like this manuscript does not have any indexed MEI. Please index MEI for this manuscript using the index_manuscript_mei command before choosing neume exemplars.
+
- Cantus Ultimus (v{{ APP_VERSION }})
{% block breadcrumb %}{% endblock %}
diff --git a/app/public/cantusdata/test/core/fake_generators.py b/app/public/cantusdata/test/core/fake_generators.py
new file mode 100644
index 00000000..795debd8
--- /dev/null
+++ b/app/public/cantusdata/test/core/fake_generators.py
@@ -0,0 +1,42 @@
+"""
+Module containing helper functions for generating fake objects
+for testing purposes.
+"""
+
+import random
+from typing import Optional
+from cantusdata.models import Folio, NeumeExemplar
+from cantusdata.helpers.neume_helpers import NEUME_NAMES
+from cantusdata.models.neume_exemplar import EXEMPLAR_IMAGE_SIDE_LENGTH
+
+
+def create_fake_neume_exemplar(
+ folio: Folio,
+ name: Optional[str] = None,
+ x_coord: Optional[int] = None,
+ y_coord: Optional[int] = None,
+ width: Optional[int] = None,
+ height: Optional[int] = None,
+) -> NeumeExemplar:
+ if name is None:
+ name = random.choice(NEUME_NAMES)
+ if folio is None:
+ raise ValueError("Folio must be provided")
+ # Coordinates and dimensions are optional
+ # and are set randomly to some generally reasonable values
+ if x_coord is None:
+ x_coord = random.randint(0, 2100)
+ if y_coord is None:
+ y_coord = random.randint(0, 2100)
+ if width is None:
+ width = EXEMPLAR_IMAGE_SIDE_LENGTH
+ if height is None:
+ height = EXEMPLAR_IMAGE_SIDE_LENGTH
+ return NeumeExemplar.objects.create(
+ name=name,
+ folio=folio,
+ x_coord=x_coord,
+ y_coord=y_coord,
+ width=width,
+ height=height,
+ )
diff --git a/app/public/cantusdata/test/core/models/test_neume_exemplar.py b/app/public/cantusdata/test/core/models/test_neume_exemplar.py
new file mode 100644
index 00000000..5b2690ad
--- /dev/null
+++ b/app/public/cantusdata/test/core/models/test_neume_exemplar.py
@@ -0,0 +1,41 @@
+from django.test import TestCase
+from cantusdata.models import NeumeExemplar, Manuscript, Folio
+
+from cantusdata.test.core.fake_generators import create_fake_neume_exemplar
+
+
+class NeumeExemplarModelTest(TestCase):
+ @classmethod
+ def setUpTestData(cls) -> None:
+ manuscript = Manuscript.objects.create(
+ id=1,
+ name="Manuscript 1",
+ )
+ f001r = Folio.objects.create(
+ manuscript=manuscript,
+ number="001r",
+ image_uri="https://example.com/f001r.jpg",
+ )
+ f001v = Folio.objects.create(
+ manuscript=manuscript,
+ number="001v",
+ image_uri="https://example.com/f001v.jpg",
+ )
+ for neume_name, folio in [
+ ("scandicus", f001v),
+ ("porrectus-flexus", f001r),
+ ("compound", f001v),
+ ("punctum", f001r),
+ ]:
+ create_fake_neume_exemplar(folio, name=neume_name)
+
+ def test_ordering(self) -> None:
+ """
+ Test that default ordering is correct (ie. that the custom
+ save method is working correctly).
+ """
+ exemplars = NeumeExemplar.objects.all()
+ self.assertEqual(
+ [exemplar.name for exemplar in exemplars],
+ ["punctum", "scandicus", "porrectus-flexus", "compound"],
+ )
diff --git a/app/public/cantusdata/test/core/views/test_neume_exemplars.py b/app/public/cantusdata/test/core/views/test_neume_exemplars.py
new file mode 100644
index 00000000..1865e1d8
--- /dev/null
+++ b/app/public/cantusdata/test/core/views/test_neume_exemplars.py
@@ -0,0 +1,351 @@
+from django.test import TestCase, Client
+from django.conf import settings
+from django.core.management import call_command
+from django.urls import reverse
+from django.http import HttpResponseRedirect
+from django.contrib.auth import get_user_model
+from rest_framework.test import APIRequestFactory, APIClient
+from cantusdata.models import Manuscript, Folio, NeumeExemplar
+from cantusdata.models.neume_exemplar import EXEMPLAR_IMAGE_SIDE_LENGTH
+from cantusdata.views.neume_exemplars import NeumeSetAPIView, PickNeumeExemplarsView
+from cantusdata.test.core.fake_generators import create_fake_neume_exemplar
+
+
+def set_up_neume_exemplar_test_data() -> None:
+ """
+ Create a Manuscript and two Folio objects for the three folios for which
+ we have test MEI data.
+ """
+ source = Manuscript.objects.create(id=123723, name="Test Manuscript", siglum="TEST")
+ Folio.objects.create(
+ manuscript=source,
+ number="001r",
+ image_uri="test_001r.jpg",
+ )
+ Folio.objects.create(
+ manuscript=source,
+ number="001v",
+ image_uri="test_001v.jpg",
+ )
+ Folio.objects.create(
+ manuscript=source,
+ number="999r",
+ image_uri="test_999r.jpg",
+ )
+ # Create a user
+ User = get_user_model()
+ User.objects.create_user(username="testuser", password="12345", is_staff=True)
+
+
+class TestNeumeSetView(TestCase):
+ neume_set_view = NeumeSetAPIView()
+
+ @classmethod
+ def setUpTestData(cls) -> None:
+ set_up_neume_exemplar_test_data()
+
+ @classmethod
+ def setUpClass(cls) -> None:
+ super().setUpClass()
+ call_command(
+ "index_manuscript_mei",
+ "123723",
+ "--min-ngram",
+ "1",
+ "--max-ngram",
+ "5",
+ "--mei-dir",
+ settings.TEST_MEI_FILES_PATH,
+ )
+
+ @classmethod
+ def tearDownClass(cls) -> None:
+ call_command("index_manuscript_mei", "123723", "--flush-index")
+ super().tearDownClass()
+
+ def test_fetch_potential_exemplars(self) -> None:
+ with self.subTest(neume_name="punctum"):
+ potential_exemplars = self.neume_set_view._fetch_potential_exemplars(
+ manuscript_id=123723, neume_name="punctum", start=0, rows=10
+ )
+ self.assertEqual(len(potential_exemplars), 10)
+ # There are more than 10 puncta on folio 001r, so all folios for
+ # the returned potential exemplars should be 001r
+ exemplar_folios_punctum = {
+ exemplar["folio"] for exemplar in potential_exemplars
+ }
+ self.assertEqual(exemplar_folios_punctum, {"001r"})
+ with self.subTest(neume_name="compound"):
+ # 1 compound neume exists on each of 001r, 001v, and 999r
+ potential_exemplars = self.neume_set_view._fetch_potential_exemplars(
+ manuscript_id=123723, neume_name="compound", start=0, rows=10
+ )
+ self.assertEqual(len(potential_exemplars), 3)
+ # Exemplars are sorted in folio order
+ exemplar_folios = [exemplar["folio"] for exemplar in potential_exemplars]
+ self.assertEqual(exemplar_folios, ["001r", "001v", "999r"])
+ # Check the image URL for the first exemplar (on 001r)
+ ulx1, uly1, width1, height1 = self.neume_set_view.calculate_exemplar_box(
+ 4520, 2008, 355, 201
+ )
+ expected_img_url1 = (
+ "test_001r.jpg/"
+ f"{ulx1},{uly1},{width1},{height1}/{EXEMPLAR_IMAGE_SIDE_LENGTH},"
+ "/0/default.jpg"
+ )
+ self.assertEqual(potential_exemplars[0]["image_url"], expected_img_url1)
+ # Check the image URL for the second exemplar (on 001v)
+ ulx2, uly2, width2, height2 = self.neume_set_view.calculate_exemplar_box(
+ 4130, 4769, 402, 188
+ )
+ expected_img_url2 = (
+ "test_001v.jpg/"
+ f"{ulx2},{uly2},{width2},{height2}/{EXEMPLAR_IMAGE_SIDE_LENGTH},"
+ "/0/default.jpg"
+ )
+ self.assertEqual(potential_exemplars[1]["image_url"], expected_img_url2)
+ with self.subTest(neume_name="porrectus-flexus"):
+ # 0 porrectus-flexus neumes exist on these pages
+ potential_exemplars = self.neume_set_view._fetch_potential_exemplars(
+ manuscript_id=123723, neume_name="porrectus-flexus", start=0, rows=10
+ )
+ self.assertEqual(len(potential_exemplars), 0)
+
+ def test_get(self) -> None:
+ factory = APIRequestFactory()
+ with self.subTest("Punctum: first 10 exemplars"):
+ request = factory.get(
+ reverse("neume-set-view", kwargs={"pk": 123723}),
+ {"neume_name": "punctum"},
+ )
+ response = NeumeSetAPIView.as_view()(
+ request, pk=123723, neume_name="punctum"
+ )
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.data["neume_name"], "punctum")
+ self.assertEqual(response.data["manuscript"], 123723)
+ self.assertEqual(response.data["start"], 0)
+ self.assertEqual(
+ response.data["exemplar_image_side_length"], EXEMPLAR_IMAGE_SIDE_LENGTH
+ )
+ potential_exemplars = self.neume_set_view._fetch_potential_exemplars(
+ manuscript_id=123723, neume_name="punctum", start=0, rows=10
+ )
+ self.assertEqual(response.data["neume_exemplars"], potential_exemplars)
+ with self.subTest("Punctum: exemplars 10 - 15"):
+ request = factory.get(
+ reverse("neume-set-view", kwargs={"pk": 123723}),
+ {"neume_name": "punctum", "start": "10", "rows": "5"},
+ )
+ response = NeumeSetAPIView.as_view()(request, pk=123723)
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.data["neume_name"], "punctum")
+ self.assertEqual(response.data["start"], "10")
+ potential_exemplars = self.neume_set_view._fetch_potential_exemplars(
+ manuscript_id=123723, neume_name="punctum", start=10, rows=5
+ )
+ self.assertEqual(response.data["neume_exemplars"], potential_exemplars)
+ with self.subTest("No neume name given"):
+ request = factory.get(reverse("neume-set-view", kwargs={"pk": 123723}))
+ response = NeumeSetAPIView.as_view()(request, pk=123723, neume_name="")
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.data["neume_name"], "")
+ # Check that no neume_names has an underscore (i.e. check
+ # that we have only returned single-neume exemplars)
+ for exemplar in response.data["neume_exemplars"]:
+ self.assertNotIn("_", exemplar["neume_name"])
+
+
+class TestPickNeumeExemplarsView(TestCase):
+ pick_neume_exemplars_view = PickNeumeExemplarsView()
+
+ @classmethod
+ def setUpTestData(cls) -> None:
+ set_up_neume_exemplar_test_data()
+ # Create three existing NeumeExemplar objects
+ # for manuscript 123723
+ folio_001r = Folio.objects.get(manuscript=123723, number="001r")
+ folio_001v = Folio.objects.get(manuscript=123723, number="001v")
+ create_fake_neume_exemplar(folio_001r, name="punctum")
+ create_fake_neume_exemplar(folio_001v, name="compound")
+ create_fake_neume_exemplar(folio_001v, name="scandicus")
+
+ @classmethod
+ def setUpClass(cls) -> None:
+ super().setUpClass()
+ call_command(
+ "index_manuscript_mei",
+ "123723",
+ "--min-ngram",
+ "1",
+ "--max-ngram",
+ "5",
+ "--mei-dir",
+ settings.TEST_MEI_FILES_PATH,
+ )
+
+ @classmethod
+ def tearDownClass(cls) -> None:
+ call_command("index_manuscript_mei", "123723", "--flush-index")
+ super().tearDownClass()
+
+ def test_check_indexed_mei(self) -> None:
+ manuscript_123723 = Manuscript.objects.get(id=123723)
+ self.assertTrue(
+ self.pick_neume_exemplars_view.check_indexed_mei(
+ manuscript=manuscript_123723
+ )
+ )
+ # Create an additional manuscript object that won't
+ # have any MEI data indexed
+ manuscript_123724 = Manuscript.objects.create(
+ id=123724, name="Test Manuscript 2", siglum="TEST2"
+ )
+ self.assertFalse(
+ self.pick_neume_exemplars_view.check_indexed_mei(
+ manuscript=manuscript_123724
+ )
+ )
+
+ def test_view(self) -> None:
+ client = Client()
+ response = client.get(
+ reverse("pick-neume-exemplars-view", kwargs={"pk": 123723})
+ )
+ # The response should be a redirect to the login page
+ self.assertIsInstance(response, HttpResponseRedirect)
+ self.assertEqual(response.status_code, 302)
+ self.assertTrue(response.url.startswith("/admin/login/")) # type: ignore[attr-defined]
+ # Log in as a staff member
+ client.login(username="testuser", password="12345")
+ response = client.get(
+ reverse("pick-neume-exemplars-view", kwargs={"pk": 123723})
+ )
+ self.assertEqual(response.status_code, 200)
+ self.assertTemplateUsed(response, "admin/pick_neume_exemplars.html")
+ with self.subTest("Check context"):
+ self.assertEqual(response.context["ngrams_indexed"], True)
+ self.assertEqual(response.context["manuscript"].id, 123723)
+ with self.subTest(
+ "Check that existing neume exemplars populate html form fields"
+ ):
+ decoded_html = response.content.decode()
+ for neume_name, neume_data_dict in response.context["neume_data"].items():
+ if neume_data_dict["current_neume_exemplar"] is not None:
+ expected_html_input_elem = (
+ f''
+ )
+ self.assertInHTML(expected_html_input_elem, decoded_html)
+ else:
+ expected_html_input_elem = (
+ f''
+ )
+ self.assertInHTML(expected_html_input_elem, decoded_html)
+
+ def test_existing_neume_exemplars(self) -> None:
+ manuscript_123723 = Manuscript.objects.get(id=123723)
+ existing_exemplar_links = (
+ self.pick_neume_exemplars_view.get_existing_exemplar_links(
+ manuscript=manuscript_123723
+ )
+ )
+ self.assertEqual(len(existing_exemplar_links), 3)
+ self.assertEqual(
+ set(existing_exemplar_links.keys()), {"punctum", "compound", "scandicus"}
+ )
+
+
+class TestNeumeExemplarsAPIView(TestCase):
+ fake_nes: list[NeumeExemplar] = []
+
+ @classmethod
+ def setUpTestData(cls) -> None:
+ set_up_neume_exemplar_test_data()
+ # Create three existing NeumeExemplar objects
+ # for manuscript 123723
+ folio_001r = Folio.objects.get(manuscript=123723, number="001r")
+ folio_001v = Folio.objects.get(manuscript=123723, number="001v")
+ punctum_ne = create_fake_neume_exemplar(folio_001r, name="punctum")
+ compound_ne = create_fake_neume_exemplar(folio_001v, name="compound")
+ scandicus_ne = create_fake_neume_exemplar(folio_001v, name="scandicus")
+ cls.fake_nes = [punctum_ne, compound_ne, scandicus_ne]
+
+ @classmethod
+ def setUpClass(cls) -> None:
+ super().setUpClass()
+ call_command(
+ "index_manuscript_mei",
+ "123723",
+ "--min-ngram",
+ "1",
+ "--max-ngram",
+ "5",
+ "--mei-dir",
+ settings.TEST_MEI_FILES_PATH,
+ )
+
+ @classmethod
+ def tearDownClass(cls) -> None:
+ call_command("index_manuscript_mei", "123723", "--flush-index")
+ super().tearDownClass()
+
+ def test_get(self) -> None:
+ client = APIClient()
+ response = client.get(reverse("neume-exemplars-view", kwargs={"pk": 123723}))
+ self.assertEqual(response.status_code, 200)
+ neume_names = {neume_exemplar.name for neume_exemplar in self.fake_nes}
+ self.assertEqual(neume_names, {ne["name"] for ne in response.data})
+
+ def test_post(self) -> None:
+ client = APIClient()
+ with self.subTest("Test that a POST request is refused without authentication"):
+ response = client.post(
+ reverse("neume-exemplars-view", kwargs={"pk": 123723})
+ )
+ self.assertEqual(response.status_code, 403)
+ with self.subTest("Test that a POST request is accepted with authentication"):
+ client.login(username="testuser", password="12345")
+ response = client.post(
+ reverse("neume-exemplars-view", kwargs={"pk": 123723})
+ )
+ self.assertEqual(response.status_code, 302)
+ expected_redirect_url = reverse(
+ "admin:cantusdata_manuscript_change", args=[123723]
+ )
+ self.assertEqual(response.url, expected_redirect_url) # type: ignore[attr-defined]
+ with self.subTest(
+ "Test that the POST request adds/updates the neume exemplars"
+ ):
+ # Check that three neume exemplars are currently associated with the manuscript
+ manuscript_123723 = Manuscript.objects.get(id=123723)
+ self.assertEqual(
+ NeumeExemplar.objects.filter(
+ folio__manuscript=manuscript_123723
+ ).count(),
+ 3,
+ )
+ # Create a POST request with some neume exemplar data
+ post_data = {
+ "punctum": "test_001r.jpg/1,2,3,4/0/0/default.jpg",
+ "compound": "test_001r.jpg/2,3,4,5/0/0/default.jpg",
+ "scandicus": "test_001r.jpg/3,4,5,6/0/0/default.jpg",
+ }
+ response = client.post(
+ reverse("neume-exemplars-view", kwargs={"pk": 123723}), post_data
+ )
+ self.assertEqual(response.status_code, 302)
+ # Check that the neume exemplars have been updated
+ self.assertEqual(
+ NeumeExemplar.objects.filter(
+ folio__manuscript=manuscript_123723
+ ).count(),
+ 3,
+ )
+ for neume_name, _ in post_data.items():
+ exemplar = NeumeExemplar.objects.get(name=neume_name)
+ self.assertEqual(exemplar.folio.number, "001r")
diff --git a/app/public/cantusdata/urls.py b/app/public/cantusdata/urls.py
index 6e7ee669..02ee80f8 100644
--- a/app/public/cantusdata/urls.py
+++ b/app/public/cantusdata/urls.py
@@ -19,6 +19,11 @@
from cantusdata.views.map_folios import MapFoliosView
from cantusdata.views.load_chants import LoadChantsView
from cantusdata.views.manifest_proxy import ManifestProxyView
+from cantusdata.views.neume_exemplars import (
+ NeumeSetAPIView,
+ PickNeumeExemplarsView,
+ NeumeExemplarsAPIView,
+)
from cantusdata.views import staticpages
from django.contrib.admin.views.decorators import staff_member_required
@@ -36,6 +41,11 @@
staff_member_required(LoadChantsView.as_view()),
name="load-chants-view",
),
+ path(
+ "admin/cantusdata/manuscript//pick_neume_exemplars/",
+ staff_member_required(PickNeumeExemplarsView.as_view()),
+ name="pick-neume-exemplars-view",
+ ),
path("admin/", admin.site.urls),
# Static pages
path("", staticpages.homepage, name="homepage"),
@@ -91,6 +101,17 @@
ManuscriptFolioSetView.as_view(),
name="manuscript-folio-set-view-index",
),
+ # Query neume images by manuscript and neume name
+ path(
+ "manuscript//neume-set/",
+ NeumeSetAPIView.as_view(),
+ name="neume-set-view",
+ ),
+ path(
+ "manuscript//neume-exemplars/",
+ NeumeExemplarsAPIView.as_view(),
+ name="neume-exemplars-view",
+ ),
# Search
path("search/", SearchView.as_view(), name="search-view"),
path("suggest/", SuggestionView.as_view(), name="suggestion-view"),
diff --git a/app/public/cantusdata/views/neume_exemplars.py b/app/public/cantusdata/views/neume_exemplars.py
new file mode 100644
index 00000000..63e81862
--- /dev/null
+++ b/app/public/cantusdata/views/neume_exemplars.py
@@ -0,0 +1,327 @@
+from typing import Any, Tuple, List, TypedDict, Optional, Unpack, cast
+
+from rest_framework.views import APIView
+from rest_framework.response import Response
+from rest_framework.request import Request
+from rest_framework.generics import GenericAPIView
+from rest_framework.mixins import ListModelMixin
+from rest_framework.permissions import IsAuthenticatedOrReadOnly
+from django.conf import settings
+from django.views.generic import TemplateView
+from django.db.models.query import QuerySet
+from django.http import HttpResponse
+from django.urls import reverse
+from django.contrib import messages
+from django.shortcuts import redirect
+import requests
+from requests import Response as RequestsResponse
+
+from cantusdata.models import Manuscript, NeumeExemplar
+from cantusdata.models.neume_exemplar import EXEMPLAR_IMAGE_SIDE_LENGTH
+from cantusdata.helpers.iiif_helpers import construct_image_api_url
+from cantusdata.serializers.neume_exemplar import NeumeExemplarSerializer
+
+
+class NeumeExemplarAPIKwargs(TypedDict):
+ pk: int
+ neume_name: str
+
+
+class NeumeData(TypedDict):
+ neume_count: int
+ current_neume_exemplar: Optional[str]
+
+
+class NeumeSetItem(TypedDict):
+ neume_name: str
+ folio: str
+ image_url: str
+
+
+class NeumeSetAPIView(APIView):
+ """
+ An API that accepts GET requests to fetch potential neume exemplars in
+ a manuscript.
+
+ The API accepts a `neume_name` parameter which determines the type of
+ neume to fetch exemplars for. If no `neume_name` is provided, all neumes are
+ returned. It also accepts a `start` parameter which
+ is passed on to the Solr query to determine the index of the first result
+ to return and a `rows` parameter which determines the number of results
+ to return. The `start` and `rows` parameters are optional and default to
+ 0 and 10 respectively.
+ """
+
+ # Settings for padding around the neume exemplar bounding box (used in the
+ # calculate_exemplar_box method).
+ PADDING_TOP = 60
+ PADDING_BOTTOM = 45
+ PADDING_LEFT = 45
+ PADDING_RIGHT = 45
+
+ def get(
+ self, request: Any, *args: Any, **kwargs: Unpack[NeumeExemplarAPIKwargs]
+ ) -> Response:
+ """
+ Handles a get request to the pick neume exemplars view.
+ """
+ response_dict: dict[str, Any] = {}
+ q_manuscript_id = kwargs["pk"]
+ q_neume_name = request.GET.get("neume_name", "")
+ q_start = request.GET.get("start", 0)
+ q_rows = request.GET.get("rows", 10)
+ try:
+ exemplars = self._fetch_potential_exemplars(
+ q_manuscript_id, q_neume_name, start=q_start, rows=q_rows
+ )
+ except requests.exceptions.RequestException as e:
+ response_dict["solr_request_error"] = str(e)
+ return Response(response_dict, status=500)
+ response_dict["neume_name"] = q_neume_name
+ response_dict["manuscript"] = q_manuscript_id
+ response_dict["start"] = q_start
+ response_dict["neume_exemplars"] = exemplars
+ response_dict["exemplar_image_side_length"] = EXEMPLAR_IMAGE_SIDE_LENGTH
+ return Response(response_dict)
+
+ def _fetch_potential_exemplars(
+ self,
+ manuscript_id: int,
+ neume_name: str,
+ start: int,
+ rows: int,
+ ) -> List[NeumeSetItem]:
+ """
+ Get potential exemplars of a neume type in a manuscript.
+
+ Args:
+ manuscript: The manuscript object for which exemplars are being fetched.
+ neume_name: The name of the neume type for which exemplars are being fetched.
+ start: The index of the first result to return.
+ rows: The number of results to return.
+
+ Returns:
+ A list of tuples containing the neume name, the folio, and image URL of each
+ potential exemplar.
+ """
+ params: dict[str, str | int] = {
+ "q": "*:*",
+ # If no neume names are provided, filter by "neume_names:*
+ # AND NOT neume_names:*_* to get ngrams where the neume_names
+ # field exists but is a single-neume ngram.
+ "fq": f"manuscript_id:{manuscript_id} AND type:omr_ngram"
+ + (
+ f" AND neume_names:{neume_name}"
+ if neume_name
+ else " AND neume_names:* AND NOT neume_names:*_*"
+ ),
+ "rows": rows,
+ "sort": "folio asc",
+ "start": start,
+ "fl": "*",
+ }
+ response: RequestsResponse = requests.get(
+ f"{settings.SOLR_SERVER}/select", timeout=10, params=params
+ )
+ response.raise_for_status()
+ results_json = response.json()["response"]
+ if results_json["numFound"] > 0:
+ exemplars: List[NeumeSetItem] = []
+ for result in results_json["docs"]:
+ # Since we're dealing with single-neume ngrams in this
+ # result, we know each location will be a list with only
+ # a single bounding box (single neumes don't span multiple
+ # systems).
+ location = result["location_json"][0]
+ ulx, uly, width, height = self.calculate_exemplar_box(
+ location["ulx"],
+ location["uly"],
+ location["width"],
+ location["height"],
+ )
+ image_url = construct_image_api_url(
+ result["image_uri"],
+ region=(f"{ulx},{uly},{width},{height}"),
+ size=f"{EXEMPLAR_IMAGE_SIDE_LENGTH},",
+ )
+ exemplars.append(
+ {
+ "neume_name": neume_name,
+ "folio": result["folio"],
+ "image_url": image_url,
+ }
+ )
+ return exemplars
+ return []
+
+ def calculate_exemplar_box(
+ self, ulx: int, uly: int, width: int, height: int
+ ) -> Tuple[int, int, int, int]:
+ """
+ Given the bounding box coordinates of a neume in the MEI file, calculate
+ the coordinates of a box that will contain the neume and some extra
+ space around it (for the neume exemplar image) and be a square.
+ """
+ width_with_padding = width + self.PADDING_LEFT + self.PADDING_RIGHT
+ height_with_padding = height + self.PADDING_TOP + self.PADDING_BOTTOM
+ ulx -= self.PADDING_LEFT
+ uly -= self.PADDING_TOP
+ # If the width is greater than the height, we'll make the box square
+ # by adding padding to the top and bottom.
+ if width_with_padding > height_with_padding:
+ height_padding_for_square = (width_with_padding - height_with_padding) // 2
+ uly -= height_padding_for_square
+ height_with_padding = width_with_padding
+ # If the height is greater than the width, we'll make the box square
+ # by adding padding to the left and right.
+ elif height_with_padding > width_with_padding:
+ width_padding_for_square = (height_with_padding - width_with_padding) // 2
+ ulx -= width_padding_for_square
+ width_with_padding = height_with_padding
+ return ulx, uly, width_with_padding, height_with_padding
+
+
+class NeumeExemplarsAPIView(ListModelMixin, GenericAPIView): # type: ignore[type-arg]
+ permission_classes = [IsAuthenticatedOrReadOnly]
+ serializer_class = NeumeExemplarSerializer
+
+ def get_queryset(self) -> QuerySet[NeumeExemplar]:
+ manuscript_id = self.kwargs["pk"]
+ return (
+ Manuscript.objects.get(pk=manuscript_id)
+ .neume_exemplars.select_related("folio", "folio__manuscript")
+ .all()
+ )
+
+ def get(self, request: Request, *args: Any, **kwargs: Any) -> Response:
+ return self.list(request, *args, **kwargs)
+
+ def post(self, request: Request, *args: Any, **kwargs: Any) -> HttpResponse:
+ """
+ Create a neume exemplar from a POST request to the API.
+ """
+ data = request.data.copy()
+ # Remove the CSRF token from the request data
+ data.pop("csrfmiddlewaretoken", None)
+ exemplars_to_save = []
+ for neume_name, iiif_url in data.items():
+ parsed_exemplar_dict = self._parse_iiif_url(iiif_url)
+ parsed_exemplar_dict["name"] = neume_name
+ exemplars_to_save.append(parsed_exemplar_dict)
+ existing_exemplars = self.get_queryset()
+ if existing_exemplars.exists():
+ serializer = self.get_serializer(
+ instance=existing_exemplars, data=exemplars_to_save, many=True
+ )
+ else:
+ serializer = self.get_serializer(data=exemplars_to_save, many=True)
+ serializer.is_valid(raise_exception=True)
+ serializer.save()
+ messages.success(request, "Neume exemplars saved successfully.")
+ return redirect(
+ reverse("admin:cantusdata_manuscript_change", args=[self.kwargs["pk"]])
+ )
+
+ def _parse_iiif_url(self, iiif_url: str) -> dict[str, str | int]:
+ split_url = iiif_url.split("/")
+ region_param = split_url[-4]
+ region = region_param.split(",")
+ x, y, w, h = map(int, region)
+ p = "/".join(split_url[:-4])
+ return {"p": p, "x": x, "y": y, "w": w, "h": h}
+
+
+class PickNeumeExemplarsView(TemplateView):
+ template_name = "admin/pick_neume_exemplars.html"
+
+ def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
+ context = super().get_context_data(**kwargs)
+ context["manuscript"] = Manuscript.objects.get(pk=kwargs["pk"])
+ ngrams_indexed = self.check_indexed_mei(context["manuscript"])
+ if not ngrams_indexed:
+ context["ngrams_indexed"] = False
+ return context
+ existing_exemplars = self.get_existing_exemplar_links(context["manuscript"])
+ neume_name_counts = self.get_neume_name_counts(context["manuscript"])
+ neume_data: dict[str, NeumeData] = {}
+ for neume_name, neume_count in neume_name_counts.items():
+ neume_data[neume_name] = {
+ "neume_count": neume_count,
+ "current_neume_exemplar": existing_exemplars.get(neume_name),
+ }
+ context["ngrams_indexed"] = ngrams_indexed
+ context["neume_data"] = neume_data
+ return context
+
+ def get_existing_exemplar_links(self, manuscript: Manuscript) -> dict[str, str]:
+ """
+ Gets the existing neume examplars for a manuscript, if any,
+ and returns a dictionary with the neume name as the key
+ and the examplar image URL as the value.
+ """
+ exemplar_image_dict: dict[str, str] = {}
+ for exemplar in manuscript.neume_exemplars.select_related("folio").all():
+ neume_name = exemplar.name
+ image_uri = exemplar.folio.image_uri
+ assert (
+ image_uri is not None
+ ) # we know that the image_uri of a folio with a neume exemplar is not None
+ exemplar_image_url = construct_image_api_url(
+ image_uri,
+ region=f"{exemplar.x_coord},{exemplar.y_coord},{exemplar.width},{exemplar.height}",
+ size=f"{EXEMPLAR_IMAGE_SIDE_LENGTH},",
+ )
+ exemplar_image_dict[neume_name] = exemplar_image_url
+ return exemplar_image_dict
+
+ def check_indexed_mei(self, manuscript: Manuscript) -> bool:
+ """
+ Check if the manuscript has indexed MEI.
+ """
+ params: dict[str, str | int] = {
+ "q": f"manuscript_id:{manuscript.pk} AND type:omr_ngram",
+ "rows": 1,
+ }
+ omr_ngram_request = requests.get(
+ f"{settings.SOLR_SERVER}/select",
+ params=params,
+ timeout=10,
+ )
+ omr_ngram_request.raise_for_status()
+ num_omr_ngrams_found: int = omr_ngram_request.json()["response"]["numFound"]
+ return num_omr_ngrams_found > 0
+
+ def get_neume_name_counts(self, manuscript: Manuscript) -> dict[str, int]:
+ """
+ Get the neume names that exist in a manuscript and their counts.
+
+ Args:
+ manuscript: The manuscript for which neume names are being fetched.
+
+ Returns:
+ A dictionary of neume names and their counts.
+ """
+ params: dict[str, str | int] = {
+ "q": "*:*",
+ "fq": f"manuscript_id:{manuscript.pk} AND type:omr_ngram AND -neume_names:*_*",
+ "rows": 0,
+ "facet": "true",
+ "facet.field": "neume_names",
+ "facet.mincount": 1,
+ }
+ omr_ngram_request = requests.get(
+ f"{settings.SOLR_SERVER}/select",
+ params=params,
+ timeout=10,
+ )
+ omr_ngram_request.raise_for_status()
+ # Facets are returned as a list of alternating neume names and counts.
+ neume_names: list[str | int] = omr_ngram_request.json()["facet_counts"][
+ "facet_fields"
+ ]["neume_names"]
+ neume_name_counts: dict[str, int] = {}
+ for i in range(0, len(neume_names), 2):
+ neume_name = cast(str, neume_names[i])
+ count = cast(int, neume_names[i + 1])
+ neume_name_counts[neume_name] = count
+ return neume_name_counts
diff --git a/nginx/public/node/frontend/public/css/styles.scss b/nginx/public/node/frontend/public/css/styles.scss
index ae5c13d8..35689ade 100644
--- a/nginx/public/node/frontend/public/css/styles.scss
+++ b/nginx/public/node/frontend/public/css/styles.scss
@@ -647,6 +647,12 @@ html.js {
background: #ccc;
}
+.neume-gallery-container {
+ display: flex;
+ flex-wrap: nowrap;
+ overflow: auto;
+}
+
.neume-gallery-entry {
transition: border-color 0.2s;
float: left;
diff --git a/nginx/public/node/frontend/public/js/app/search/omr-search/NeumeGalleryView.js b/nginx/public/node/frontend/public/js/app/search/omr-search/NeumeGalleryView.js
index 48364f2d..3ea4c40c 100644
--- a/nginx/public/node/frontend/public/js/app/search/omr-search/NeumeGalleryView.js
+++ b/nginx/public/node/frontend/public/js/app/search/omr-search/NeumeGalleryView.js
@@ -26,7 +26,7 @@ export default Marionette.CompositeView.extend({
exemplarUrl: function ()
{
// jscs:disable requireCamelCaseOrUpperCaseIdentifiers
- return pageSnippetUrl(this.siglum_slug, _.pick(this, ['p', 'x', 'y', 'w', 'h']), {height: 75});
+ return pageSnippetUrl(_.pick(this, ['p', 'x', 'y', 'w', 'h']), {height: 50});
// jscs enable
}
}
diff --git a/nginx/public/node/frontend/public/js/app/search/omr-search/OMRSearchProvider.js b/nginx/public/node/frontend/public/js/app/search/omr-search/OMRSearchProvider.js
index bd240d5b..b4dd0bd2 100644
--- a/nginx/public/node/frontend/public/js/app/search/omr-search/OMRSearchProvider.js
+++ b/nginx/public/node/frontend/public/js/app/search/omr-search/OMRSearchProvider.js
@@ -167,7 +167,7 @@ export default Marionette.Object.extend({
regions.searchInput.show(inputView);
// Neume gallery
- if (field.type === 'neumes' && this.neumeExemplars.length > 0)
+ if (field.type === 'neume_names' && this.neumeExemplars.length > 0)
{
var gallery = new NeumeGalleryView({
collection: this.neumeExemplars
diff --git a/nginx/public/node/frontend/public/js/app/search/omr-search/ResultItemView.js b/nginx/public/node/frontend/public/js/app/search/omr-search/ResultItemView.js
index 87452814..d09c14e6 100644
--- a/nginx/public/node/frontend/public/js/app/search/omr-search/ResultItemView.js
+++ b/nginx/public/node/frontend/public/js/app/search/omr-search/ResultItemView.js
@@ -35,7 +35,7 @@ export default Marionette.ItemView.extend({
return null;
}
- return pageSnippetUrl(this.siglumSlug, box, {height: this.neumeImageHeight});
+ return pageSnippetUrl(box, {height: this.neumeImageHeight});
},
templateHelpers: function ()
diff --git a/nginx/public/node/frontend/public/js/app/search/omr-search/neume-gallery-list.template.html b/nginx/public/node/frontend/public/js/app/search/omr-search/neume-gallery-list.template.html
index b8ef174d..0362d560 100644
--- a/nginx/public/node/frontend/public/js/app/search/omr-search/neume-gallery-list.template.html
+++ b/nginx/public/node/frontend/public/js/app/search/omr-search/neume-gallery-list.template.html
@@ -1,2 +1,2 @@
Available neumes
-
+
diff --git a/nginx/public/node/frontend/public/js/app/utils/pageSnippetUrl.js b/nginx/public/node/frontend/public/js/app/utils/pageSnippetUrl.js
index 922ae12d..52525ef5 100644
--- a/nginx/public/node/frontend/public/js/app/utils/pageSnippetUrl.js
+++ b/nginx/public/node/frontend/public/js/app/utils/pageSnippetUrl.js
@@ -3,7 +3,6 @@ import _ from 'underscore';
/**
* Generate a IIIF URL for a snippet of a page
*
- * @param {String} siglumSlug The siglum slug of the manuscript to search
* @param {Object} location The location of the manuscript to search, with attributes
*
* - p: folio number
@@ -13,7 +12,7 @@ import _ from 'underscore';
* @param {Object} dimensions The dimensions of the image to output, with attributes
* width and height (both optional).
*/
-export default function pageSnippetUrl(siglumSlug, loc, dimens)
+export default function pageSnippetUrl(loc, dimens)
{
var filename = loc.p;
var bounds = [loc.x, loc.y, loc.w, loc.h].join(',');