diff --git a/manuscript/admin.py b/manuscript/admin.py index 0bfd7ad..7bdd237 100644 --- a/manuscript/admin.py +++ b/manuscript/admin.py @@ -516,9 +516,12 @@ def link_canvases_view(self, request, object_id): # keep what was entered so it can be corrected if isinstance(raw_rows, list): submitted = { - str(row.get("id")): str(row.get("folio") or "") + str(row.get("id")): [ + str(number) for number in row.get("folios") or [] + ] for row in raw_rows if isinstance(row, dict) + and isinstance(row.get("folios") or [], list) } else: if counts: diff --git a/manuscript/canvas_linking.py b/manuscript/canvas_linking.py index 92b8176..f94ea1e 100644 --- a/manuscript/canvas_linking.py +++ b/manuscript/canvas_linking.py @@ -12,10 +12,16 @@ CANVAS_ID_MAX_LENGTH = Folio._meta.get_field("canvas_id").max_length +def folio_key(number): + """Key for matching folio numbers, ignoring differences like "f. 12r" and + "12r\" """ + return normalize_folio_label(number) or (number or "").lower() + + def clean_canvas_rows(rows): """Validate the rows submitted from the canvas linking page. Each row is a canvas from the manifest: id, index, label, image_id, and the folio - number entered for it (blank for none).""" + numbers entered for it, as an image can show more than one folio.""" if not isinstance(rows, list): raise ValidationError("The submitted images could not be read.") @@ -26,24 +32,31 @@ def clean_canvas_rows(rows): index = int(row["index"]) label = str(row.get("label") or "").strip()[:255] image_id = str(row.get("image_id") or "").strip() or None - folio_number = str(row.get("folio") or "").strip() + if not isinstance(row.get("folios", []), list): + raise TypeError + numbers = [str(number).strip() for number in row.get("folios", [])] except (KeyError, TypeError, ValueError): raise ValidationError("The submitted images could not be read.") if not canvas_id or len(canvas_id) > CANVAS_ID_MAX_LENGTH or index < 0: raise ValidationError("The submitted images could not be read.") if image_id and len(image_id) > CANVAS_ID_MAX_LENGTH: image_id = None - if len(folio_number) > FOLIO_NUMBER_MAX_LENGTH: + if any(len(number) > FOLIO_NUMBER_MAX_LENGTH for number in numbers): raise ValidationError( f"The folio number entered for image {index + 1} is too long." ) + # one entry per folio, ignoring blanks and repeats within the row + folios_by_key = {} + for number in numbers: + if number: + folios_by_key.setdefault(folio_key(number), number) cleaned.append( { "id": canvas_id, "index": index, "label": label, "image_id": image_id, - "folio": folio_number, + "folios": list(folios_by_key.values()), } ) @@ -62,48 +75,45 @@ def link_canvases(manuscript, rows, can_create=True): page, where rows are cleaned by clean_canvas_rows. A folio number is matched to an existing folio of the manuscript (ignoring - differences like "f. 12r" and "12r"), or a new folio is created. Folios are - never renamed or deleted: a folio whose canvas is cleared or given to - another folio is only unlinked, so its toponyms and other data stay intact. + differences like "f. 12r" and "12r"), or a new folio is created. A canvas + can have several folios, but a folio has one canvas. Folios are never + renamed or deleted: a folio removed from a canvas is only unlinked, so its + toponyms and other data stay intact. Returns a Counter of linked, unlinked, and created folios, and a list of the folios created. Raises ValidationError, without saving anything, if the entries conflict.""" errors = [] - # folio number entered -> row, checking no folio is entered twice - rows_by_folio = {} - positions_by_folio = defaultdict(list) + # folio number key -> (row, number as entered), checking no folio is + # entered for two canvases + entries_by_folio = defaultdict(list) for row in rows: - if row["folio"]: - key = normalize_folio_label(row["folio"]) or row["folio"].lower() - rows_by_folio[key] = row - positions_by_folio[key].append(row) - for key, entered in positions_by_folio.items(): + for number in row["folios"]: + entries_by_folio[folio_key(number)].append((row, number)) + for entered in entries_by_folio.values(): if len(entered) > 1: - positions = ", ".join(str(row["index"] + 1) for row in entered) + positions = ", ".join(str(row["index"] + 1) for row, _ in entered) errors.append( - f'Folio "{entered[0]["folio"]}" is entered for more than one ' - f"image ({positions}). Each folio can be linked to one image." + f'Folio "{entered[0][1]}" is entered for more than one image ' + f"({positions}). Each folio can be linked to one image." ) + rows_by_folio = {key: entered[-1] for key, entered in entries_by_folio.items()} folios = list(manuscript.folio_set.all()) folios_by_key = defaultdict(list) for folio in folios: - key = ( - normalize_folio_label(folio.folio_number) - or (folio.folio_number or "").lower() - ) + key = folio_key(folio.folio_number) if key: folios_by_key[key].append(folio) targets = {} # folio number key -> existing folio, or None to create - for key, row in rows_by_folio.items(): + for key, (row, number) in rows_by_folio.items(): matches = folios_by_key.get(key, []) if len(matches) > 1: numbers = ", ".join(sorted(f'"{f.folio_number}"' for f in matches)) errors.append( - f'Folio "{row["folio"]}" matches several folio records in this ' + f'Folio "{number}" matches several folio records in this ' f"manuscript ({numbers}). Merge or renumber those folio records " "first." ) @@ -111,7 +121,7 @@ def link_canvases(manuscript, rows, can_create=True): targets[key] = matches[0] elif not can_create: errors.append( - f'There is no folio record "{row["folio"]}" in this manuscript, ' + f'There is no folio record "{number}" in this manuscript, ' "and you don't have permission to add folio records." ) else: @@ -124,13 +134,11 @@ def link_canvases(manuscript, rows, can_create=True): created = [] submitted_ids = {row["id"] for row in rows} linked_folio_ids = { - folio.pk: rows_by_folio[key]["id"] + folio.pk: rows_by_folio[key][0]["id"] for key, folio in targets.items() if folio is not None } with transaction.atomic(): - # unlink first, so moving a folio to another canvas can't collide - # with the one-folio-per-canvas constraint for folio in folios: if folio.canvas_id not in submitted_ids: continue @@ -147,9 +155,9 @@ def link_canvases(manuscript, rows, can_create=True): ) for key, folio in targets.items(): - row = rows_by_folio[key] + row, number = rows_by_folio[key] if folio is None: - folio = Folio(manuscript=manuscript, folio_number=row["folio"]) + folio = Folio(manuscript=manuscript, folio_number=number) created.append(folio) counts["created"] += 1 elif ( diff --git a/manuscript/management/commands/verify_canvas_links.py b/manuscript/management/commands/verify_canvas_links.py index 3815a88..fbdc619 100644 --- a/manuscript/management/commands/verify_canvas_links.py +++ b/manuscript/management/commands/verify_canvas_links.py @@ -78,10 +78,6 @@ def verify_manuscript(self, manuscript, dry_run): canvases_by_image[canvas["image_id"]].append(canvas) folios = list(manuscript.folio_set.exclude(canvas_id__isnull=True)) - # canvases held by a valid link, which a re-linked folio can't take - used_canvas_ids = { - folio.canvas_id for folio in folios if folio.canvas_id in canvases_by_id - } notes = defaultdict(list) with transaction.atomic(): @@ -102,16 +98,10 @@ def verify_manuscript(self, manuscript, dry_run): canvas, method = self.find_moved_canvas( folio, canvases, canvases_by_image ) - if canvas and canvas["id"] not in used_canvas_ids: + if canvas: notes[f"re-linked by {method}"].append(self.describe(folio, canvas)) self.apply_canvas(folio, canvas, dry_run) - used_canvas_ids.add(canvas["id"]) counts["relinked"] += 1 - elif canvas: - notes[ - "canvas gone, and its replacement is linked to another folio" - ].append(folio.folio_number or f"#{folio.pk}") - counts["broken"] += 1 else: notes["canvas gone, and no replacement found"].append( folio.folio_number or f"#{folio.pk}" diff --git a/manuscript/migrations/0130_remove_folio_unique_folio_canvas_per_manuscript.py b/manuscript/migrations/0130_remove_folio_unique_folio_canvas_per_manuscript.py new file mode 100644 index 0000000..1168caf --- /dev/null +++ b/manuscript/migrations/0130_remove_folio_unique_folio_canvas_per_manuscript.py @@ -0,0 +1,17 @@ +# Generated by Django 5.1.15 on 2026-11-03 19:30 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("manuscript", "0129_add_public_notes"), + ] + + operations = [ + migrations.RemoveConstraint( + model_name="folio", + name="unique_folio_canvas_per_manuscript", + ), + ] diff --git a/manuscript/models.py b/manuscript/models.py index ba478aa..d86cf82 100644 --- a/manuscript/models.py +++ b/manuscript/models.py @@ -691,14 +691,6 @@ def get_canvas_id(self): class Meta: ordering = ["folio_number"] - constraints = [ - models.UniqueConstraint( - fields=["manuscript", "canvas_id"], - condition=Q(canvas_id__isnull=False), - name="unique_folio_canvas_per_manuscript", - violation_error_message="Another folio in this manuscript is already linked to this IIIF canvas.", - ) - ] class SingleManuscript(models.Model): diff --git a/manuscript/tests.py b/manuscript/tests.py index c3c6f83..276326c 100644 --- a/manuscript/tests.py +++ b/manuscript/tests.py @@ -6,7 +6,6 @@ from django.contrib.auth.models import Permission from django.core.exceptions import ValidationError from django.core.management import call_command -from django.db import IntegrityError from django.forms import inlineformset_factory from django.test import SimpleTestCase, TestCase from django.urls import reverse @@ -237,20 +236,24 @@ def test_dry_run_does_not_save(self): folio.refresh_from_db() self.assertIsNone(folio.canvas_id) - def test_canvas_unique_per_manuscript(self): + def test_folios_can_share_a_canvas(self): canvas_id = "https://example.org/iiif/ms/canvas/p0001" - Folio.objects.create(manuscript=self.manuscript, canvas_id=canvas_id) - with self.assertRaises(IntegrityError): - Folio.objects.create(manuscript=self.manuscript, canvas_id=canvas_id) + for number in ("13v", "14r"): + Folio.objects.create( + manuscript=self.manuscript, folio_number=number, canvas_id=canvas_id + ) + self.assertEqual( + self.manuscript.folio_set.filter(canvas_id=canvas_id).count(), 2 + ) -def canvas_row(number, folio="", label=None): +def canvas_row(number, *folios, label=None): return { "id": f"https://example.org/iiif/ms/canvas/p{number:04d}", "index": number - 1, "label": label if label is not None else f"label {number}", "image_id": f"https://example.org/image/{number}", - "folio": folio, + "folios": list(folios), } @@ -335,6 +338,39 @@ def test_leaves_links_to_canvases_not_submitted(self): folio.refresh_from_db() self.assertEqual(folio.canvas_id, canvas_row(9)["id"]) + def test_links_several_folios_to_a_canvas(self): + existing = self.add_folio("13v") + counts = self.link([canvas_row(1, "13v", "14r")]) + + self.assertEqual(counts["linked"], 2) + self.assertEqual([folio.folio_number for folio in self.created], ["14r"]) + existing.refresh_from_db() + self.assertEqual(existing.canvas_id, canvas_row(1)["id"]) + self.assertEqual( + Folio.objects.get(folio_number="14r").canvas_id, canvas_row(1)["id"] + ) + + def test_removing_one_of_a_canvas_s_folios_only_unlinks_that_one(self): + kept = self.add_folio("13v", canvas=1) + removed = self.add_folio("14r", canvas=1) + counts = self.link([canvas_row(1, "13v")]) + + self.assertEqual(counts["unlinked"], 1) + kept.refresh_from_db() + removed.refresh_from_db() + self.assertEqual(kept.canvas_id, canvas_row(1)["id"]) + self.assertIsNone(removed.canvas_id) + + def test_same_folio_twice_on_a_canvas_counts_once(self): + folio = self.add_folio("13v") + rows = clean_canvas_rows([canvas_row(1, "13v", "f. 13v", " ")]) + + self.assertEqual(rows[0]["folios"], ["13v"]) + counts, _ = link_canvases(self.manuscript, rows) + self.assertEqual(counts["linked"], 1) + folio.refresh_from_db() + self.assertEqual(folio.canvas_id, canvas_row(1)["id"]) + def test_same_folio_on_two_canvases_is_rejected(self): folio = self.add_folio("1r", canvas=1) with self.assertRaisesMessage(ValidationError, "more than one image (1, 2)"): @@ -495,7 +531,7 @@ def test_errors_keep_entries(self): self.assertEqual(response.status_code, 200) self.assertContains(response, "more than one image") - self.assertEqual(response.context["submitted"][canvas_row(2)["id"]], "1r") + self.assertEqual(response.context["submitted"][canvas_row(2)["id"]], ["1r"]) self.assertFalse(Folio.objects.exists()) def test_needs_folio_permission(self): @@ -601,14 +637,26 @@ def test_reports_links_it_cannot_fix(self): self.assertIn("canvas gone, and no replacement found: 1r", out) self.assertIn("1 could not be re-linked", out) - def test_does_not_take_a_canvas_linked_to_another_folio(self): - self.add_folio("1v", "b", 1, "1v", "img-b") - folio = self.add_folio("1r", "a", 0, "1r", "img-b") - out = self.run_command([("b", "1v", "img-b")]) + def test_folios_sharing_a_canvas_are_re_linked_together(self): + folios = [ + self.add_folio("13v", "a", 0, "13v-14r", "img-a"), + self.add_folio("14r", "a", 0, "13v-14r", "img-a"), + ] + out = self.run_command([("new-a", "13v-14r", "img-a")]) - folio.refresh_from_db() - self.assertEqual(folio.canvas_id, "https://example.org/iiif/ms/canvas/a") - self.assertIn("its replacement is linked to another folio: 1r", out) + for folio in folios: + folio.refresh_from_db() + self.assertEqual( + folio.canvas_id, "https://example.org/iiif/ms/canvas/new-a" + ) + self.assertIn("2 re-linked", out) + + def test_folios_sharing_a_canvas_are_left_alone_when_it_is_still_there(self): + for number in ("13v", "14r"): + self.add_folio(number, "a", 0, "13v-14r", "img-a") + out = self.run_command([("a", "13v-14r", "img-a")]) + + self.assertIn("2 unchanged", out) def test_dry_run_does_not_save(self): folio = self.add_folio("1r", "a", 0, "1r", "img-a") diff --git a/static/js/canvas_linking.js b/static/js/canvas_linking.js index 649658a..c01853d 100644 --- a/static/js/canvas_linking.js +++ b/static/js/canvas_linking.js @@ -23,7 +23,6 @@ // Same normalization as manuscript.utils.normalize_folio_label, so that // e.g. "Carta: 12 recto", "f. 12r" and "12r" are treated as the same folio const FOLIO_LABEL_PREFIX = /^(?:carta|folio|fol|ff|f|c)\s*[.:]?\s*(?=\d)/; - const RECTO_VERSO = /^(\d+|[ivxlc]+)[rv]$/; function normalizeFolioLabel(label) { if (!label) return ''; let value = label.trim().toLowerCase(); @@ -43,6 +42,18 @@ return null; } + // The folio numbers in a manifest label: one as above, or both from a label + // for an image of two folios, e.g. "13v" and "14r" from "13v-14r", unless a + // single folio record has that whole number + function folioNumbersFromLabel(label) { + const number = folioNumberFromLabel(label); + if (number) return [number]; + const key = normalizeFolioLabel(label); + const pair = key.match(/^(\d+[rv])-(\d+[rv])$/); + if (!pair || foliosByKey.has(key)) return []; + return [folioNumberFromLabel(pair[1]), folioNumberFromLabel(pair[2])]; + } + // Flatten a IIIF v2 or v3 label into a plain string function labelToString(label) { if (!label) return ''; @@ -98,13 +109,22 @@ const foliosByCanvas = new Map(); const foliosByKey = new Map(); for (const folio of folios) { - if (folio.canvas_id) foliosByCanvas.set(folio.canvas_id, folio); + if (folio.canvas_id) { + if (!foliosByCanvas.has(folio.canvas_id)) foliosByCanvas.set(folio.canvas_id, []); + foliosByCanvas.get(folio.canvas_id).push(folio); + } if (!folio.number) continue; const key = folioKey(folio.number); if (!foliosByKey.has(key)) foliosByKey.set(key, []); foliosByKey.get(key).push(folio); } + // an image's folios in order, e.g. 99v before 100r + for (const linked of foliosByCanvas.values()) { + linked.sort((a, b) => a.number.localeCompare(b.number, undefined, { numeric: true })); + } + const linkedFolios = (canvasId) => foliosByCanvas.get(canvasId) || []; + let rows = []; let canvasPositions = new Map(); let dirty = false; @@ -133,64 +153,77 @@ row.notesEl.appendChild(note); } + // The folios entered for a row, leaving out blanks and repeats + function rowEntries(row) { + const entries = new Map(); + for (const input of row.inputs) { + const value = input.value.trim(); + if (value && !entries.has(folioKey(value))) entries.set(folioKey(value), value); + } + return [...entries].map(([key, value]) => ({ key, value })); + } + + function setRowFolios(row, numbers) { + numbers.forEach((number, i) => { + if (i >= row.inputs.length) addFolioInput(row); + row.inputs[i].value = number; + }); + } + // Work out what saving would do for each row, and flag conflicts function update() { const entriesByKey = new Map(); for (const row of rows) { - const value = row.input.value.trim(); - if (!value) continue; - const key = folioKey(value); - if (!entriesByKey.has(key)) entriesByKey.set(key, []); - entriesByKey.get(key).push(row); + for (const { key } of rowEntries(row)) { + if (!entriesByKey.has(key)) entriesByKey.set(key, []); + entriesByKey.get(key).push(row); + } } let linked = 0; let changed = 0; let problems = 0; for (const row of rows) { - const value = row.input.value.trim(); - const current = foliosByCanvas.get(row.canvas.id); + const entries = rowEntries(row); + const current = linkedFolios(row.canvas.id); row.notesEl.replaceChildren(); - if (row.suggestion) row.suggestion.hidden = !!value; + if (row.suggestion) row.suggestion.hidden = entries.length > 0; + row.addButton.hidden = row.inputs.some((input) => !input.value.trim()); row.isChanged = false; row.hasProblem = false; - if (!value) { - if (current) { - row.isChanged = true; - addNote(row, `Will unlink folio ${current.number}.`); - } - } else { - const key = folioKey(value); + const entered = new Set(); + let isLinked = false; + for (const { key, value } of entries) { const matches = foliosByKey.get(key) || []; const sameEntry = entriesByKey.get(key).filter((other) => other !== row); if (sameEntry.length) { row.hasProblem = true; const images = sameEntry.map((other) => other.canvas.index + 1).join(', '); - addNote(row, `Also entered for image ${images}. Each folio can be linked to one image.`, 'problem'); + addNote(row, `${value}: also entered for image ${images}. Each folio can be linked to one image.`, 'problem'); } if (matches.length > 1) { row.hasProblem = true; const numbers = matches.map((folio) => `"${folio.number}"`).join(', '); - addNote(row, `Matches several folio records (${numbers}). Merge or renumber those folio records first.`, 'problem'); + addNote(row, `${value}: matches several folio records (${numbers}). Merge or renumber those folio records first.`, 'problem'); } else if (matches.length === 1) { const folio = matches[0]; - if (current && current.id === folio.id) { - linked += 1; + entered.add(folio.id); + if (folio.canvas_id === row.canvas.id) { + isLinked = true; addNote(row, `Linked to folio ${folio.number}.`, 'info'); } else { row.isChanged = true; - if (folio.canvas_id && folio.canvas_id !== row.canvas.id) { + if (folio.canvas_id) { addNote(row, `Will move folio ${folio.number} here from ${canvasName(folio.canvas_id)}.`); } else { addNote(row, `Will link folio ${folio.number}.`); } - if (current) addNote(row, `Will unlink folio ${current.number}.`); } const details = folioDetails(folio); if (details) addNote(row, details, 'info'); if (!hasLineRange(folio)) { - addNote(row, "This folio record has no line range.", 'warning'); + addNote(row, `Folio ${folio.number} has no line range.`, 'warning'); } } else if (!canCreate) { row.hasProblem = true; @@ -199,9 +232,15 @@ row.isChanged = true; addNote(row, `Will create a new folio record "${value}".`); addNote(row, "It will need a line range before the text can turn to this image.", 'warning'); - if (current) addNote(row, `Will unlink folio ${current.number}.`); } } + for (const folio of current) { + if (!entered.has(folio.id)) { + row.isChanged = true; + addNote(row, `Will unlink folio ${folio.number}.`); + } + } + if (isLinked) linked += 1; if (row.labelCount > 1) { addNote(row, `The manifest label "${row.canvas.label}" appears on ${row.labelCount} images.`, 'info'); @@ -224,7 +263,7 @@ function applyFilter() { const filter = filterEl.value; for (const row of rows) { - const isLinked = !!foliosByCanvas.get(row.canvas.id); + const isLinked = linkedFolios(row.canvas.id).length > 0; row.tr.hidden = (filter === 'linked' && !isLinked) || (filter === 'unlinked' && isLinked) || @@ -236,7 +275,7 @@ function buildRow(canvas, labelCounts) { const tr = document.createElement('tr'); tr.id = `image-${canvas.index + 1}`; - const current = foliosByCanvas.get(canvas.id); + const current = linkedFolios(canvas.id); const position = document.createElement('td'); position.className = 'canvas-position'; @@ -274,38 +313,12 @@ const folioCell = document.createElement('td'); folioCell.className = 'canvas-folio'; - const input = document.createElement('input'); - input.type = 'text'; - input.setAttribute('list', 'canvas-folio-numbers'); - input.setAttribute('aria-label', `Folio number for image ${canvas.index + 1}`); - const initial = current ? current.number : ''; - input.value = submitted && canvas.id in submitted ? submitted[canvas.id] : initial; - if (submitted && input.value !== initial) dirty = true; - folioCell.appendChild(input); - - // offer the folio number in the manifest label, when it has one - let suggestion = null; - const suggestedNumber = folioNumberFromLabel(canvas.label); - if (suggestedNumber) { - suggestion = document.createElement('button'); - suggestion.type = 'button'; - suggestion.className = 'canvas-suggestion'; - suggestion.textContent = `Use ${suggestedNumber}`; - suggestion.title = `Use the folio number from the manifest label "${canvas.label}"`; - suggestion.addEventListener('click', () => { - input.value = suggestedNumber; - input.dispatchEvent(new Event('input')); - input.focus(); - }); - folioCell.appendChild(suggestion); - } - if (current) { - const link = document.createElement('a'); - link.href = current.admin_url; - link.textContent = 'Edit folio record'; - link.style.marginLeft = '0.5em'; - folioCell.appendChild(link); - } + const entriesEl = document.createElement('div'); + const addButton = document.createElement('button'); + addButton.type = 'button'; + addButton.className = 'canvas-add-folio'; + addButton.textContent = 'Add another folio'; + folioCell.append(entriesEl, addButton); const notesEl = document.createElement('td'); @@ -313,18 +326,71 @@ const row = { canvas, tr, - input, - suggestion, + inputs: [], + entriesEl, + addButton, + suggestion: null, notesEl, labelCount: labelCounts.get(normalizeFolioLabel(canvas.label)) || 0, isChanged: false, hasProblem: false, }; + addButton.addEventListener('click', () => { + addFolioInput(row).focus(); + update(); + }); + + const initial = current.map((folio) => folio.number); + const values = submitted && canvas.id in submitted ? submitted[canvas.id] : initial; + if (submitted && values.join() !== initial.join()) dirty = true; + (values.length ? values : ['']).forEach((value) => { + const input = addFolioInput(row); + input.value = value; + const folio = current.find((f) => f.number === value); + if (folio) { + const link = document.createElement('a'); + link.href = folio.admin_url; + link.textContent = 'Edit folio record'; + link.style.marginLeft = '0.5em'; + input.parentElement.appendChild(link); + } + }); + + // offer the folio numbers in the manifest label, when it has any + const suggestedNumbers = folioNumbersFromLabel(canvas.label); + if (suggestedNumbers.length) { + const suggestion = document.createElement('button'); + suggestion.type = 'button'; + suggestion.className = 'canvas-suggestion'; + suggestion.textContent = `Use ${suggestedNumbers.join(', ')}`; + suggestion.title = `Use the folio numbers from the manifest label "${canvas.label}"`; + suggestion.addEventListener('click', () => { + setRowFolios(row, suggestedNumbers); + dirty = true; + update(); + row.inputs[0].focus(); + }); + row.inputs[0].parentElement.appendChild(suggestion); + row.suggestion = suggestion; + } + return row; + } + + function addFolioInput(row) { + const entry = document.createElement('div'); + entry.className = 'canvas-folio-entry'; + const input = document.createElement('input'); + input.type = 'text'; + input.setAttribute('list', 'canvas-folio-numbers'); + input.setAttribute('aria-label', `Folio number for image ${row.canvas.index + 1}`); input.addEventListener('input', () => { dirty = true; update(); }); - return row; + entry.appendChild(input); + row.entriesEl.appendChild(entry); + row.inputs.push(input); + return input; } function showNotices(canvases) { @@ -398,9 +464,10 @@ // from the manuscript's folio list), or #image- function showLinkedRow() { let row = null; + let folio = null; const folioMatch = location.hash.match(/^#folio-(\d+)$/); if (folioMatch) { - const folio = folios.find((f) => String(f.id) === folioMatch[1]); + folio = folios.find((f) => String(f.id) === folioMatch[1]); row = folio && rows.find((r) => r.canvas.id === folio.canvas_id); } else if (/^#image-\d+$/.test(location.hash)) { row = rows.find((r) => `#${r.tr.id}` === location.hash); @@ -408,18 +475,19 @@ if (!row) return; row.tr.classList.add('is-target'); row.tr.scrollIntoView({ block: 'center' }); - row.input.focus({ preventScroll: true }); + const input = folio && row.inputs.find((i) => i.value.trim() === folio.number); + (input || row.inputs[0]).focus({ preventScroll: true }); } filterEl.addEventListener('change', applyFilter); // Larger view of an image, in a dialog over the page function openViewer(canvas) { - const folio = foliosByCanvas.get(canvas.id); + const numbers = linkedFolios(canvas.id).map((folio) => folio.number).join(', '); viewerTitle.textContent = `Image ${canvas.index + 1}` + (canvas.label ? ` · manifest label "${canvas.label}"` : '') + - (folio ? ` · linked to folio ${folio.number}` : ''); + (numbers ? ` · linked to folio ${numbers}` : ''); viewerFrame.classList.add('is-loading'); viewerImage.removeAttribute('src'); viewerImage.alt = `Image ${canvas.index + 1}`; @@ -435,22 +503,23 @@ if (event.target === viewer) viewer.close(); }); - // Same rules as the link_folio_canvases command: fill in an empty image - // whose recto/verso label appears once in the manifest and matches exactly - // one folio that isn't linked to an image or entered elsewhere + // Fill in an empty image whose label appears once in the manifest, with each + // recto/verso number in it that matches exactly one folio that isn't linked + // to an image or entered elsewhere fillButton.addEventListener('click', () => { - const entered = new Set( - rows.map((row) => row.input.value.trim()).filter(Boolean).map(folioKey), - ); + const entered = new Set(rows.flatMap(rowEntries).map(({ key }) => key)); let filled = 0; for (const row of rows) { - if (row.input.value.trim() || row.labelCount !== 1) continue; - const key = normalizeFolioLabel(row.canvas.label); - if (!RECTO_VERSO.test(key) || entered.has(key)) continue; - const matches = foliosByKey.get(key) || []; - if (matches.length !== 1 || matches[0].canvas_id) continue; - row.input.value = matches[0].number; - entered.add(key); + if (rowEntries(row).length || row.labelCount !== 1) continue; + const numbers = []; + for (const key of folioNumbersFromLabel(row.canvas.label).map(folioKey)) { + const matches = foliosByKey.get(key) || []; + if (entered.has(key) || matches.length !== 1 || matches[0].canvas_id) continue; + numbers.push(matches[0].number); + entered.add(key); + } + if (!numbers.length) continue; + setRowFolios(row, numbers); filled += 1; } if (filled) { @@ -472,7 +541,7 @@ index: row.canvas.index, label: row.canvas.label, image_id: row.canvas.image_id, - folio: row.input.value.trim(), + folios: rowEntries(row).map(({ value }) => value), })), ); dirty = false; diff --git a/templates/admin/manuscript/singlemanuscript/link_canvases.html b/templates/admin/manuscript/singlemanuscript/link_canvases.html index c5fa67f..ea0b3ab 100644 --- a/templates/admin/manuscript/singlemanuscript/link_canvases.html +++ b/templates/admin/manuscript/singlemanuscript/link_canvases.html @@ -30,7 +30,9 @@ #canvas-viewer .canvas-viewer-image img { display: block; max-width: calc(92vw - 16px); max-height: calc(92vh - 100px); } #canvas-viewer footer { padding: 6px 12px 10px; font-size: 0.8125rem; } #canvas-table .canvas-folio input { width: 9em; } - #canvas-table .canvas-suggestion { margin-left: 0.5em; font-size: 0.8125rem; padding: 2px 6px; cursor: pointer; } + #canvas-table .canvas-suggestion, #canvas-table .canvas-add-folio { margin-left: 0.5em; font-size: 0.8125rem; padding: 2px 6px; cursor: pointer; } + #canvas-table .canvas-add-folio { margin-left: 0; } + #canvas-table .canvas-folio-entry { margin-bottom: 4px; } #canvas-table tr.is-changed td { background: var(--selected-row, #ffc); } #canvas-table tr.has-problem td { background: var(--message-error-bg, #ffefef); } #canvas-table tr.is-target { outline: 2px solid var(--link-fg, #447e9b); outline-offset: -2px; } @@ -63,11 +65,12 @@