diff --git a/__tests__/lib/ontology/annotationCardinality.test.ts b/__tests__/lib/ontology/annotationCardinality.test.ts new file mode 100644 index 00000000..fcdaadd5 --- /dev/null +++ b/__tests__/lib/ontology/annotationCardinality.test.ts @@ -0,0 +1,139 @@ +import { describe, it, expect } from "vitest"; +import { ensureTrailingPlaceholder } from "@/lib/ontology/annotationCardinality"; +import { getAnnotationCardinality } from "@/lib/ontology/annotationProperties"; + +// ── ensureTrailingPlaceholder — "multiple" ──────────────────────────────── + +describe('ensureTrailingPlaceholder — "multiple"', () => { + it("appends empty row to empty array", () => { + expect(ensureTrailingPlaceholder([], "multiple")).toEqual([{ value: "", lang: "en" }]); + }); + + it("appends empty row when last item has a value", () => { + const result = ensureTrailingPlaceholder([{ value: "hello", lang: "en" }], "multiple"); + expect(result).toHaveLength(2); + expect(result[1]).toEqual({ value: "", lang: "en" }); + }); + + it("does not append when last item is already empty", () => { + const input = [{ value: "hello", lang: "en" }, { value: "", lang: "en" }]; + expect(ensureTrailingPlaceholder(input, "multiple")).toHaveLength(2); + }); + + it("does not append when last item is whitespace-only", () => { + expect(ensureTrailingPlaceholder([{ value: " ", lang: "en" }], "multiple")).toHaveLength(1); + }); +}); + +// ── ensureTrailingPlaceholder — "single-per-lang" ──────────────────────── + +describe('ensureTrailingPlaceholder — "single-per-lang"', () => { + it("adds an @en placeholder for an empty array", () => { + const result = ensureTrailingPlaceholder([], "single-per-lang"); + expect(result).toEqual([{ value: "", lang: "en" }]); + }); + + it("does NOT add a second @en placeholder when @en is already filled (SKOS S14)", () => { + const input = [{ value: "Foo", lang: "en" }]; + const result = ensureTrailingPlaceholder(input, "single-per-lang"); + // The placeholder lang must differ from "en" + expect(result).toHaveLength(2); + expect(result[1].value).toBe(""); + expect(result[1].lang).not.toBe("en"); + }); + + it("adds a placeholder with the next uncovered language when @en is filled", () => { + const result = ensureTrailingPlaceholder([{ value: "Foo", lang: "en" }], "single-per-lang"); + expect(result[1].lang).toBe("pt"); + }); + + it("keeps an existing trailing empty placeholder as-is", () => { + const input = [{ value: "Foo", lang: "en" }, { value: "", lang: "pt" }]; + expect(ensureTrailingPlaceholder(input, "single-per-lang")).toStrictEqual(input); + }); + + it("adds a blank-lang placeholder when all common languages are already filled", () => { + const input = [ + { value: "A", lang: "en" }, + { value: "B", lang: "pt" }, + { value: "C", lang: "es" }, + { value: "D", lang: "fr" }, + { value: "E", lang: "de" }, + { value: "F", lang: "it" }, + ]; + const result = ensureTrailingPlaceholder(input, "single-per-lang"); + expect(result).toHaveLength(7); + expect(result[6]).toEqual({ value: "", lang: "" }); + }); + + it("skips covered languages and finds the next available one", () => { + const input = [{ value: "Foo", lang: "en" }, { value: "Bar", lang: "pt" }]; + const result = ensureTrailingPlaceholder(input, "single-per-lang"); + expect(result).toHaveLength(3); + expect(result[2].lang).toBe("es"); + }); +}); + +// ── ensureTrailingPlaceholder — "single" ───────────────────────────────── + +describe('ensureTrailingPlaceholder — "single"', () => { + it("returns one empty placeholder for an empty array", () => { + expect(ensureTrailingPlaceholder([], "single")).toEqual([{ value: "", lang: "en" }]); + }); + + it("returns the filled value with no trailing empty once a value exists", () => { + const input = [{ value: "2024-01-01", lang: "en" }]; + expect(ensureTrailingPlaceholder(input, "single")).toStrictEqual(input); + }); + + it("strips any trailing empty when a value is present", () => { + const input = [{ value: "ABC", lang: "en" }, { value: "", lang: "en" }]; + const result = ensureTrailingPlaceholder(input, "single"); + expect(result).toEqual([{ value: "ABC", lang: "en" }]); + }); + + it("keeps the empty placeholder while the user is still typing (value is non-empty)", () => { + const input = [{ value: "2024", lang: "en" }]; + expect(ensureTrailingPlaceholder(input, "single")).toStrictEqual(input); + }); +}); + +// ── getAnnotationCardinality ────────────────────────────────────────────── + +describe("getAnnotationCardinality", () => { + it("returns single-per-lang for skos:prefLabel", () => { + expect(getAnnotationCardinality("http://www.w3.org/2004/02/skos/core#prefLabel")).toBe("single-per-lang"); + }); + + it("returns single-per-lang for skos:definition", () => { + expect(getAnnotationCardinality("http://www.w3.org/2004/02/skos/core#definition")).toBe("single-per-lang"); + }); + + it("returns single for skos:notation", () => { + expect(getAnnotationCardinality("http://www.w3.org/2004/02/skos/core#notation")).toBe("single"); + }); + + it("returns single for dcterms:created", () => { + expect(getAnnotationCardinality("http://purl.org/dc/terms/created")).toBe("single"); + }); + + it("returns single for dcterms:modified", () => { + expect(getAnnotationCardinality("http://purl.org/dc/terms/modified")).toBe("single"); + }); + + it("returns multiple for skos:altLabel", () => { + expect(getAnnotationCardinality("http://www.w3.org/2004/02/skos/core#altLabel")).toBe("multiple"); + }); + + it("returns multiple for rdfs:comment (COMMENT_IRI)", () => { + expect(getAnnotationCardinality("http://www.w3.org/2000/01/rdf-schema#comment")).toBe("multiple"); + }); + + it("returns single-per-lang for rdfs:label (LABEL_IRI)", () => { + expect(getAnnotationCardinality("http://www.w3.org/2000/01/rdf-schema#label")).toBe("single-per-lang"); + }); + + it("defaults to multiple for unknown IRIs", () => { + expect(getAnnotationCardinality("http://example.org/custom#prop")).toBe("multiple"); + }); +}); diff --git a/components/editor/ClassDetailPanel.tsx b/components/editor/ClassDetailPanel.tsx index 9119b552..99fd895e 100644 --- a/components/editor/ClassDetailPanel.tsx +++ b/components/editor/ClassDetailPanel.tsx @@ -37,7 +37,8 @@ import { ParentClassPicker } from "@/components/editor/ParentClassPicker"; import { AnnotationRow } from "@/components/editor/standard/AnnotationRow"; import { InlineAnnotationAdder } from "@/components/editor/standard/InlineAnnotationAdder"; import { RelationshipSection, type RelationshipGroup, type RelationshipTarget } from "@/components/editor/standard/RelationshipSection"; -import { LABEL_IRI, COMMENT_IRI, DEFINITION_IRI, RELATIONSHIP_PROPERTY_IRIS, SEE_ALSO_IRI, getAnnotationPropertyInfo } from "@/lib/ontology/annotationProperties"; +import { LABEL_IRI, COMMENT_IRI, DEFINITION_IRI, RELATIONSHIP_PROPERTY_IRIS, SEE_ALSO_IRI, getAnnotationPropertyInfo, getAnnotationCardinality } from "@/lib/ontology/annotationProperties"; +import { ensureTrailingPlaceholder } from "@/lib/ontology/annotationCardinality"; import { AutoSaveAffordanceBar } from "@/components/editor/AutoSaveAffordanceBar"; import { CrossReferencesPanel } from "@/components/editor/CrossReferencesPanel"; import { SimilarConceptsPanel } from "@/components/editor/SimilarConceptsPanel"; @@ -45,12 +46,9 @@ import { EntityHistoryTab } from "@/components/editor/EntityHistoryTab"; import { useAutoSave } from "@/lib/hooks/useAutoSave"; import { useToast } from "@/lib/context/ToastContext"; -/** Ensure an array of localized strings always ends with an empty placeholder row */ +/** @deprecated Use ensureTrailingPlaceholder from lib/ontology/annotationCardinality with an explicit cardinality. */ export function ensureTrailingEmpty(arr: LocalizedString[]): LocalizedString[] { - if (arr.length === 0 || arr[arr.length - 1].value.trim() !== "") { - return [...arr, { value: "", lang: "en" }]; - } - return arr; + return ensureTrailingPlaceholder(arr, "multiple"); } /** Minimal data from the tree node, used as fallback when the API has no data yet */ @@ -199,7 +197,7 @@ export function ClassDetailPanel({ } else { regularAnnotations.push({ property_iri: a.property_iri, - values: ensureTrailingEmpty(a.values.map((v) => ({ ...v }))), + values: ensureTrailingPlaceholder(a.values.map((v) => ({ ...v })), getAnnotationCardinality(a.property_iri)), }); } } @@ -426,7 +424,7 @@ export function ClassDetailPanel({ prev.map((a) => { if (a.property_iri !== propertyIri) return a; const updated = a.values.map((v, vi) => (vi === valueIdx ? { ...v, [field]: newVal } : v)); - return { ...a, values: ensureTrailingEmpty(updated) }; + return { ...a, values: ensureTrailingPlaceholder(updated, getAnnotationCardinality(propertyIri)) }; }) ); }, @@ -439,7 +437,7 @@ export function ClassDetailPanel({ prev.map((a) => { if (a.property_iri !== propertyIri) return a; const filtered = a.values.filter((_, vi) => vi !== valueIdx); - return { ...a, values: ensureTrailingEmpty(filtered) }; + return { ...a, values: ensureTrailingPlaceholder(filtered, getAnnotationCardinality(propertyIri)) }; }) ); requestAnimationFrame(() => triggerSave()); @@ -869,16 +867,17 @@ export function ClassDetailPanel({ onAdd={(propertyIri, value, lang) => { setEditAnnotations((prev) => { const existing = prev.find((a) => a.property_iri === propertyIri); + const cardinality = getAnnotationCardinality(propertyIri); if (existing) { return prev.map((a) => a.property_iri === propertyIri - ? { ...a, values: ensureTrailingEmpty([...a.values, { value, lang }]) } + ? { ...a, values: ensureTrailingPlaceholder([...a.values, { value, lang }], cardinality) } : a ); } return [ ...prev, - { property_iri: propertyIri, values: ensureTrailingEmpty([{ value, lang }]) }, + { property_iri: propertyIri, values: ensureTrailingPlaceholder([{ value, lang }], cardinality) }, ]; }); requestAnimationFrame(() => triggerSave()); diff --git a/components/editor/IndividualDetailPanel.tsx b/components/editor/IndividualDetailPanel.tsx index adcf78d0..d94604d6 100644 --- a/components/editor/IndividualDetailPanel.tsx +++ b/components/editor/IndividualDetailPanel.tsx @@ -25,7 +25,8 @@ import { AnnotationRow } from "@/components/editor/standard/AnnotationRow"; import { InlineAnnotationAdder } from "@/components/editor/standard/InlineAnnotationAdder"; import { RelationshipSection, type RelationshipGroup, type RelationshipTarget } from "@/components/editor/standard/RelationshipSection"; import { PropertyAssertionSection } from "@/components/editor/standard/PropertyAssertionSection"; -import { LABEL_IRI, COMMENT_IRI, DEFINITION_IRI, SEE_ALSO_IRI, getAnnotationPropertyInfo } from "@/lib/ontology/annotationProperties"; +import { LABEL_IRI, COMMENT_IRI, DEFINITION_IRI, SEE_ALSO_IRI, getAnnotationPropertyInfo, getAnnotationCardinality } from "@/lib/ontology/annotationProperties"; +import { ensureTrailingPlaceholder } from "@/lib/ontology/annotationCardinality"; import { AutoSaveAffordanceBar } from "@/components/editor/AutoSaveAffordanceBar"; import { useEntityAutoSave } from "@/lib/hooks/useEntityAutoSave"; import { useToast } from "@/lib/context/ToastContext"; @@ -37,12 +38,6 @@ import { import { type IndividualDraftEntry } from "@/lib/stores/draftStore"; import { useIriLabels } from "@/lib/hooks/useIriLabels"; -function ensureTrailingEmpty(arr: LocalizedString[]): LocalizedString[] { - if (arr.length === 0 || arr[arr.length - 1].value.trim() !== "") { - return [...arr, { value: "", lang: "en" }]; - } - return arr; -} interface IndividualDetailPanelProps { projectId: string; @@ -198,9 +193,9 @@ export function IndividualDetailPanel({ }); const initEditState = useCallback((d: ParsedIndividualDetail) => { - setEditLabels(ensureTrailingEmpty(d.labels.map((l) => ({ ...l })))); - setEditComments(ensureTrailingEmpty(d.comments.map((c) => ({ ...c })))); - setEditDefinitions(ensureTrailingEmpty(d.definitions.map((def) => ({ ...def })))); + setEditLabels(ensureTrailingPlaceholder(d.labels.map((l) => ({ ...l })), "single-per-lang")); + setEditComments(ensureTrailingPlaceholder(d.comments.map((c) => ({ ...c })), "multiple")); + setEditDefinitions(ensureTrailingPlaceholder(d.definitions.map((def) => ({ ...def })), "single-per-lang")); setEditTypeIris([...d.typeIris]); setEditSameAsIris([...d.sameAsIris]); setEditDifferentFromIris([...d.differentFromIris]); @@ -229,7 +224,7 @@ export function IndividualDetailPanel({ const regularAnnotations = d.annotations .filter((a) => a.property_iri !== DEFINITION_IRI) - .map((a) => ({ ...a, values: ensureTrailingEmpty(a.values.map((v) => ({ ...v }))) })); + .map((a) => ({ ...a, values: ensureTrailingPlaceholder(a.values.map((v) => ({ ...v })), getAnnotationCardinality(a.property_iri)) })); setEditAnnotations(regularAnnotations); }, []); @@ -283,8 +278,8 @@ export function IndividualDetailPanel({ const d = restoredDraft as IndividualDraftEntry; // eslint-disable-next-line react-hooks/set-state-in-effect -- restoring draft state from store; matches PropertyDetailPanel auto-enter pattern setEditLabels(d.labels); - setEditComments(ensureTrailingEmpty(d.comments)); - setEditDefinitions(ensureTrailingEmpty(d.definitions)); + setEditComments(ensureTrailingPlaceholder(d.comments, "multiple")); + setEditDefinitions(ensureTrailingPlaceholder(d.definitions, "single-per-lang")); setEditTypeIris(d.typeIris); setEditSameAsIris(d.sameAsIris); setEditDifferentFromIris(d.differentFromIris); @@ -303,7 +298,7 @@ export function IndividualDetailPanel({ // ── Edit helpers ── const updateLabel = useCallback((index: number, field: "value" | "lang", val: string) => { - setEditLabels((prev) => ensureTrailingEmpty(prev.map((l, i) => (i === index ? { ...l, [field]: val } : l)))); + setEditLabels((prev) => ensureTrailingPlaceholder(prev.map((l, i) => (i === index ? { ...l, [field]: val } : l)), "single-per-lang")); }, []); const removeLabel = useCallback((index: number) => { setEditLabels((prev) => prev.filter((_, i) => i !== index)); @@ -311,18 +306,18 @@ export function IndividualDetailPanel({ }, [triggerSave]); const updateComment = useCallback((index: number, field: "value" | "lang", val: string) => { - setEditComments((prev) => ensureTrailingEmpty(prev.map((c, i) => (i === index ? { ...c, [field]: val } : c)))); + setEditComments((prev) => ensureTrailingPlaceholder(prev.map((c, i) => (i === index ? { ...c, [field]: val } : c)), "multiple")); }, []); const removeComment = useCallback((index: number) => { - setEditComments((prev) => ensureTrailingEmpty(prev.filter((_, i) => i !== index))); + setEditComments((prev) => ensureTrailingPlaceholder(prev.filter((_, i) => i !== index), "multiple")); requestAnimationFrame(() => triggerSave()); }, [triggerSave]); const updateDefinition = useCallback((index: number, field: "value" | "lang", val: string) => { - setEditDefinitions((prev) => ensureTrailingEmpty(prev.map((d, i) => (i === index ? { ...d, [field]: val } : d)))); + setEditDefinitions((prev) => ensureTrailingPlaceholder(prev.map((d, i) => (i === index ? { ...d, [field]: val } : d)), "single-per-lang")); }, []); const removeDefinition = useCallback((index: number) => { - setEditDefinitions((prev) => ensureTrailingEmpty(prev.filter((_, i) => i !== index))); + setEditDefinitions((prev) => ensureTrailingPlaceholder(prev.filter((_, i) => i !== index), "single-per-lang")); requestAnimationFrame(() => triggerSave()); }, [triggerSave]); @@ -332,7 +327,7 @@ export function IndividualDetailPanel({ prev.map((a) => { if (a.property_iri !== propertyIri) return a; const updated = a.values.map((v, vi) => (vi === valueIdx ? { ...v, [field]: val } : v)); - return { ...a, values: ensureTrailingEmpty(updated) }; + return { ...a, values: ensureTrailingPlaceholder(updated, getAnnotationCardinality(propertyIri)) }; }) ); }, [] @@ -342,7 +337,7 @@ export function IndividualDetailPanel({ setEditAnnotations((prev) => prev.map((a) => { if (a.property_iri !== propertyIri) return a; - return { ...a, values: ensureTrailingEmpty(a.values.filter((_, vi) => vi !== valueIdx)) }; + return { ...a, values: ensureTrailingPlaceholder(a.values.filter((_, vi) => vi !== valueIdx), getAnnotationCardinality(propertyIri)) }; }) ); requestAnimationFrame(() => triggerSave()); diff --git a/components/editor/PropertyDetailPanel.tsx b/components/editor/PropertyDetailPanel.tsx index d67eabeb..75316e3f 100644 --- a/components/editor/PropertyDetailPanel.tsx +++ b/components/editor/PropertyDetailPanel.tsx @@ -25,7 +25,8 @@ import { LanguagePicker } from "@/components/editor/LanguagePicker"; import { AnnotationRow } from "@/components/editor/standard/AnnotationRow"; import { InlineAnnotationAdder } from "@/components/editor/standard/InlineAnnotationAdder"; import { RelationshipSection, type RelationshipGroup, type RelationshipTarget } from "@/components/editor/standard/RelationshipSection"; -import { LABEL_IRI, COMMENT_IRI, DEFINITION_IRI, SEE_ALSO_IRI, getAnnotationPropertyInfo } from "@/lib/ontology/annotationProperties"; +import { LABEL_IRI, COMMENT_IRI, DEFINITION_IRI, SEE_ALSO_IRI, getAnnotationPropertyInfo, getAnnotationCardinality } from "@/lib/ontology/annotationProperties"; +import { ensureTrailingPlaceholder } from "@/lib/ontology/annotationCardinality"; import { AutoSaveAffordanceBar } from "@/components/editor/AutoSaveAffordanceBar"; import { useEntityAutoSave } from "@/lib/hooks/useEntityAutoSave"; import { useToast } from "@/lib/context/ToastContext"; @@ -38,13 +39,6 @@ import { import { type PropertyDraftEntry } from "@/lib/stores/draftStore"; import { useIriLabels } from "@/lib/hooks/useIriLabels"; -/** Ensure an array of localized strings always ends with an empty placeholder row */ -function ensureTrailingEmpty(arr: LocalizedString[]): LocalizedString[] { - if (arr.length === 0 || arr[arr.length - 1].value.trim() !== "") { - return [...arr, { value: "", lang: "en" }]; - } - return arr; -} const PROPERTY_TYPE_LABELS: Record = { object: { label: "Object Property", letter: "O", color: "bg-emerald-100 border-emerald-300 text-emerald-700 dark:bg-emerald-900/30 dark:border-emerald-700 dark:text-emerald-400" }, @@ -209,8 +203,8 @@ export function PropertyDetailPanel({ const initEditState = useCallback((d: ParsedPropertyDetail) => { setEditPropertyType(d.propertyType); setEditLabels(d.labels.length > 0 ? d.labels.map((l) => ({ ...l })) : [{ value: "", lang: "en" }]); - setEditComments(ensureTrailingEmpty(d.comments.map((c) => ({ ...c })))); - setEditDefinitions(ensureTrailingEmpty(d.definitions.map((def) => ({ ...def })))); + setEditComments(ensureTrailingPlaceholder(d.comments.map((c) => ({ ...c })), "multiple")); + setEditDefinitions(ensureTrailingPlaceholder(d.definitions.map((def) => ({ ...def })), "single-per-lang")); setEditDomainIris([...d.domainIris]); setEditRangeIris([...d.rangeIris]); setEditParentIris([...d.parentIris]); @@ -240,7 +234,7 @@ export function PropertyDetailPanel({ // Annotations: filter out definition (shown in its own section) const regularAnnotations = d.annotations .filter((a) => a.property_iri !== DEFINITION_IRI) - .map((a) => ({ ...a, values: ensureTrailingEmpty(a.values.map((v) => ({ ...v }))) })); + .map((a) => ({ ...a, values: ensureTrailingPlaceholder(a.values.map((v) => ({ ...v })), getAnnotationCardinality(a.property_iri)) })); if (!regularAnnotations.find((a) => a.property_iri === DEFINITION_IRI)) { // Don't add definition here — it has its own section @@ -292,8 +286,8 @@ export function PropertyDetailPanel({ const d = restoredDraft as PropertyDraftEntry; setEditPropertyType(d.propertyType); setEditLabels(d.labels); - setEditComments(ensureTrailingEmpty(d.comments)); - setEditDefinitions(ensureTrailingEmpty(d.definitions)); + setEditComments(ensureTrailingPlaceholder(d.comments, "multiple")); + setEditDefinitions(ensureTrailingPlaceholder(d.definitions, "single-per-lang")); setEditDomainIris(d.domainIris); setEditRangeIris(d.rangeIris); setEditParentIris(d.parentIris); @@ -321,20 +315,20 @@ export function PropertyDetailPanel({ }, [triggerSave]); const updateComment = useCallback((index: number, field: "value" | "lang", val: string) => { - setEditComments((prev) => ensureTrailingEmpty(prev.map((c, i) => (i === index ? { ...c, [field]: val } : c)))); + setEditComments((prev) => ensureTrailingPlaceholder(prev.map((c, i) => (i === index ? { ...c, [field]: val } : c)), "multiple")); }, []); const removeComment = useCallback((index: number) => { - setEditComments((prev) => ensureTrailingEmpty(prev.filter((_, i) => i !== index))); + setEditComments((prev) => ensureTrailingPlaceholder(prev.filter((_, i) => i !== index), "multiple")); requestAnimationFrame(() => triggerSave()); }, [triggerSave]); const updateDefinition = useCallback((index: number, field: "value" | "lang", val: string) => { - setEditDefinitions((prev) => ensureTrailingEmpty(prev.map((d, i) => (i === index ? { ...d, [field]: val } : d)))); + setEditDefinitions((prev) => ensureTrailingPlaceholder(prev.map((d, i) => (i === index ? { ...d, [field]: val } : d)), "single-per-lang")); }, []); const removeDefinition = useCallback((index: number) => { - setEditDefinitions((prev) => ensureTrailingEmpty(prev.filter((_, i) => i !== index))); + setEditDefinitions((prev) => ensureTrailingPlaceholder(prev.filter((_, i) => i !== index), "single-per-lang")); requestAnimationFrame(() => triggerSave()); }, [triggerSave]); @@ -344,7 +338,7 @@ export function PropertyDetailPanel({ prev.map((a) => { if (a.property_iri !== propertyIri) return a; const updated = a.values.map((v, vi) => (vi === valueIdx ? { ...v, [field]: val } : v)); - return { ...a, values: ensureTrailingEmpty(updated) }; + return { ...a, values: ensureTrailingPlaceholder(updated, getAnnotationCardinality(propertyIri)) }; }) ); }, @@ -357,7 +351,7 @@ export function PropertyDetailPanel({ prev.map((a) => { if (a.property_iri !== propertyIri) return a; const filtered = a.values.filter((_, vi) => vi !== valueIdx); - return { ...a, values: ensureTrailingEmpty(filtered) }; + return { ...a, values: ensureTrailingPlaceholder(filtered, getAnnotationCardinality(propertyIri)) }; }) ); requestAnimationFrame(() => triggerSave()); diff --git a/lib/ontology/annotationCardinality.ts b/lib/ontology/annotationCardinality.ts new file mode 100644 index 00000000..24213920 --- /dev/null +++ b/lib/ontology/annotationCardinality.ts @@ -0,0 +1,56 @@ +import type { LocalizedString } from "@/lib/api/client"; +import type { AnnotationCardinality } from "./annotationProperties"; + +/** Language tags to try in order when choosing a default lang for a new placeholder row. */ +const COMMON_LANGS = ["en", "pt", "es", "fr", "de", "it"]; + +/** + * Ensures that an array of localized strings has an appropriate trailing + * placeholder row, respecting the annotation property's cardinality: + * + * - "multiple": always one trailing empty row (original behaviour). + * - "single-per-lang": placeholder lang is the first common language not yet + * covered by a filled value; no placeholder when all common langs are filled. + * - "single": no placeholder once any value is filled; one empty row when empty. + */ +export function ensureTrailingPlaceholder( + values: LocalizedString[], + cardinality: AnnotationCardinality, +): LocalizedString[] { + switch (cardinality) { + case "single": { + const hasFilled = values.some((v) => v.value.trim() !== ""); + if (hasFilled) return values.filter((v) => v.value.trim() !== ""); + return [{ value: "", lang: "en" }]; + } + + case "single-per-lang": { + // Keep any existing trailing empty placeholder as-is. + if (values.length > 0 && values[values.length - 1].value.trim() === "") return values; + // Append a placeholder whose lang is the first common language not yet + // covered by a filled value. This prevents offering a duplicate @en row + // when the annotation already has an English value (SKOS S14 / rdfs:label + // convention). If all common languages are already present, no placeholder. + const filledLangs = new Set( + values + .filter((v) => v.value.trim() !== "") + .map((v) => v.lang.trim().toLowerCase()) + .filter(Boolean), + ); + const nextLang = COMMON_LANGS.find((l) => !filledLangs.has(l)); + if (nextLang === undefined) { + // All common languages are filled — add a blank row for a custom locale. + return [...values, { value: "", lang: "" }]; + } + return [...values, { value: "", lang: nextLang }]; + } + + case "multiple": + default: { + if (values.length === 0 || values[values.length - 1].value.trim() !== "") { + return [...values, { value: "", lang: "en" }]; + } + return values; + } + } +} diff --git a/lib/ontology/annotationProperties.ts b/lib/ontology/annotationProperties.ts index 24006cc1..e3f10085 100644 --- a/lib/ontology/annotationProperties.ts +++ b/lib/ontology/annotationProperties.ts @@ -7,67 +7,75 @@ * - `curie`: prefixed name (e.g., "skos:prefLabel") — shown in tooltips * - `displayLabel`: plain-language name (e.g., "Preferred Label") — shown in UI * - `vocabulary`: grouping label for the picker + * - `cardinality`: how many values the property allows */ +/** How many values an annotation property accepts. */ +export type AnnotationCardinality = + | "single" // at most one value (e.g. dcterms:created) + | "single-per-lang" // at most one value per language tag (e.g. skos:prefLabel, SKOS S14) + | "multiple"; // unbounded (e.g. skos:altLabel, rdfs:comment) + export interface KnownAnnotationProperty { iri: string; curie: string; displayLabel: string; vocabulary: string; + cardinality: AnnotationCardinality; } export const ANNOTATION_PROPERTIES: KnownAnnotationProperty[] = [ - // ── DC Elements 1.1 ── - { iri: "http://purl.org/dc/elements/1.1/contributor", curie: "dc:contributor", displayLabel: "Contributor", vocabulary: "DC Elements" }, - { iri: "http://purl.org/dc/elements/1.1/coverage", curie: "dc:coverage", displayLabel: "Coverage", vocabulary: "DC Elements" }, - { iri: "http://purl.org/dc/elements/1.1/creator", curie: "dc:creator", displayLabel: "Creator", vocabulary: "DC Elements" }, - { iri: "http://purl.org/dc/elements/1.1/date", curie: "dc:date", displayLabel: "Date", vocabulary: "DC Elements" }, - { iri: "http://purl.org/dc/elements/1.1/description", curie: "dc:description", displayLabel: "Description", vocabulary: "DC Elements" }, - { iri: "http://purl.org/dc/elements/1.1/format", curie: "dc:format", displayLabel: "Format", vocabulary: "DC Elements" }, - { iri: "http://purl.org/dc/elements/1.1/identifier", curie: "dc:identifier", displayLabel: "Identifier", vocabulary: "DC Elements" }, - { iri: "http://purl.org/dc/elements/1.1/language", curie: "dc:language", displayLabel: "Language", vocabulary: "DC Elements" }, - { iri: "http://purl.org/dc/elements/1.1/publisher", curie: "dc:publisher", displayLabel: "Publisher", vocabulary: "DC Elements" }, - { iri: "http://purl.org/dc/elements/1.1/relation", curie: "dc:relation", displayLabel: "Relation", vocabulary: "DC Elements" }, - { iri: "http://purl.org/dc/elements/1.1/rights", curie: "dc:rights", displayLabel: "Rights", vocabulary: "DC Elements" }, - { iri: "http://purl.org/dc/elements/1.1/source", curie: "dc:source", displayLabel: "Source", vocabulary: "DC Elements" }, - { iri: "http://purl.org/dc/elements/1.1/subject", curie: "dc:subject", displayLabel: "Subject", vocabulary: "DC Elements" }, - { iri: "http://purl.org/dc/elements/1.1/title", curie: "dc:title", displayLabel: "Title", vocabulary: "DC Elements" }, - { iri: "http://purl.org/dc/elements/1.1/type", curie: "dc:type", displayLabel: "Type", vocabulary: "DC Elements" }, + // ── DC Elements 1.1 — legacy, all multi-valued ── + { iri: "http://purl.org/dc/elements/1.1/contributor", curie: "dc:contributor", displayLabel: "Contributor", vocabulary: "DC Elements", cardinality: "multiple" }, + { iri: "http://purl.org/dc/elements/1.1/coverage", curie: "dc:coverage", displayLabel: "Coverage", vocabulary: "DC Elements", cardinality: "multiple" }, + { iri: "http://purl.org/dc/elements/1.1/creator", curie: "dc:creator", displayLabel: "Creator", vocabulary: "DC Elements", cardinality: "multiple" }, + { iri: "http://purl.org/dc/elements/1.1/date", curie: "dc:date", displayLabel: "Date", vocabulary: "DC Elements", cardinality: "multiple" }, + { iri: "http://purl.org/dc/elements/1.1/description", curie: "dc:description", displayLabel: "Description", vocabulary: "DC Elements", cardinality: "multiple" }, + { iri: "http://purl.org/dc/elements/1.1/format", curie: "dc:format", displayLabel: "Format", vocabulary: "DC Elements", cardinality: "multiple" }, + { iri: "http://purl.org/dc/elements/1.1/identifier", curie: "dc:identifier", displayLabel: "Identifier", vocabulary: "DC Elements", cardinality: "multiple" }, + { iri: "http://purl.org/dc/elements/1.1/language", curie: "dc:language", displayLabel: "Language", vocabulary: "DC Elements", cardinality: "multiple" }, + { iri: "http://purl.org/dc/elements/1.1/publisher", curie: "dc:publisher", displayLabel: "Publisher", vocabulary: "DC Elements", cardinality: "multiple" }, + { iri: "http://purl.org/dc/elements/1.1/relation", curie: "dc:relation", displayLabel: "Relation", vocabulary: "DC Elements", cardinality: "multiple" }, + { iri: "http://purl.org/dc/elements/1.1/rights", curie: "dc:rights", displayLabel: "Rights", vocabulary: "DC Elements", cardinality: "multiple" }, + { iri: "http://purl.org/dc/elements/1.1/source", curie: "dc:source", displayLabel: "Source", vocabulary: "DC Elements", cardinality: "multiple" }, + { iri: "http://purl.org/dc/elements/1.1/subject", curie: "dc:subject", displayLabel: "Subject", vocabulary: "DC Elements", cardinality: "multiple" }, + { iri: "http://purl.org/dc/elements/1.1/title", curie: "dc:title", displayLabel: "Title", vocabulary: "DC Elements", cardinality: "multiple" }, + { iri: "http://purl.org/dc/elements/1.1/type", curie: "dc:type", displayLabel: "Type", vocabulary: "DC Elements", cardinality: "multiple" }, // ── DC Terms ── - { iri: "http://purl.org/dc/terms/contributor", curie: "dcterms:contributor", displayLabel: "Contributor", vocabulary: "DC Terms" }, - { iri: "http://purl.org/dc/terms/created", curie: "dcterms:created", displayLabel: "Date Created", vocabulary: "DC Terms" }, - { iri: "http://purl.org/dc/terms/creator", curie: "dcterms:creator", displayLabel: "Creator", vocabulary: "DC Terms" }, - { iri: "http://purl.org/dc/terms/date", curie: "dcterms:date", displayLabel: "Date", vocabulary: "DC Terms" }, - { iri: "http://purl.org/dc/terms/description", curie: "dcterms:description", displayLabel: "Description", vocabulary: "DC Terms" }, - { iri: "http://purl.org/dc/terms/format", curie: "dcterms:format", displayLabel: "Format", vocabulary: "DC Terms" }, - { iri: "http://purl.org/dc/terms/identifier", curie: "dcterms:identifier", displayLabel: "Identifier", vocabulary: "DC Terms" }, - { iri: "http://purl.org/dc/terms/language", curie: "dcterms:language", displayLabel: "Language", vocabulary: "DC Terms" }, - { iri: "http://purl.org/dc/terms/license", curie: "dcterms:license", displayLabel: "License", vocabulary: "DC Terms" }, - { iri: "http://purl.org/dc/terms/modified", curie: "dcterms:modified", displayLabel: "Date Modified", vocabulary: "DC Terms" }, - { iri: "http://purl.org/dc/terms/publisher", curie: "dcterms:publisher", displayLabel: "Publisher", vocabulary: "DC Terms" }, - { iri: "http://purl.org/dc/terms/rights", curie: "dcterms:rights", displayLabel: "Rights", vocabulary: "DC Terms" }, - { iri: "http://purl.org/dc/terms/source", curie: "dcterms:source", displayLabel: "Source", vocabulary: "DC Terms" }, - { iri: "http://purl.org/dc/terms/subject", curie: "dcterms:subject", displayLabel: "Subject", vocabulary: "DC Terms" }, - { iri: "http://purl.org/dc/terms/title", curie: "dcterms:title", displayLabel: "Title", vocabulary: "DC Terms" }, - { iri: "http://purl.org/dc/terms/type", curie: "dcterms:type", displayLabel: "Type", vocabulary: "DC Terms" }, - { iri: "http://purl.org/dc/terms/abstract", curie: "dcterms:abstract", displayLabel: "Abstract", vocabulary: "DC Terms" }, + { iri: "http://purl.org/dc/terms/contributor", curie: "dcterms:contributor", displayLabel: "Contributor", vocabulary: "DC Terms", cardinality: "multiple" }, + { iri: "http://purl.org/dc/terms/created", curie: "dcterms:created", displayLabel: "Date Created", vocabulary: "DC Terms", cardinality: "single" }, + { iri: "http://purl.org/dc/terms/creator", curie: "dcterms:creator", displayLabel: "Creator", vocabulary: "DC Terms", cardinality: "multiple" }, + { iri: "http://purl.org/dc/terms/date", curie: "dcterms:date", displayLabel: "Date", vocabulary: "DC Terms", cardinality: "multiple" }, + { iri: "http://purl.org/dc/terms/description", curie: "dcterms:description", displayLabel: "Description", vocabulary: "DC Terms", cardinality: "multiple" }, + { iri: "http://purl.org/dc/terms/format", curie: "dcterms:format", displayLabel: "Format", vocabulary: "DC Terms", cardinality: "multiple" }, + { iri: "http://purl.org/dc/terms/identifier", curie: "dcterms:identifier", displayLabel: "Identifier", vocabulary: "DC Terms", cardinality: "single" }, + { iri: "http://purl.org/dc/terms/language", curie: "dcterms:language", displayLabel: "Language", vocabulary: "DC Terms", cardinality: "multiple" }, + { iri: "http://purl.org/dc/terms/license", curie: "dcterms:license", displayLabel: "License", vocabulary: "DC Terms", cardinality: "single" }, + { iri: "http://purl.org/dc/terms/modified", curie: "dcterms:modified", displayLabel: "Date Modified", vocabulary: "DC Terms", cardinality: "single" }, + { iri: "http://purl.org/dc/terms/publisher", curie: "dcterms:publisher", displayLabel: "Publisher", vocabulary: "DC Terms", cardinality: "multiple" }, + { iri: "http://purl.org/dc/terms/rights", curie: "dcterms:rights", displayLabel: "Rights", vocabulary: "DC Terms", cardinality: "multiple" }, + { iri: "http://purl.org/dc/terms/source", curie: "dcterms:source", displayLabel: "Source", vocabulary: "DC Terms", cardinality: "multiple" }, + { iri: "http://purl.org/dc/terms/subject", curie: "dcterms:subject", displayLabel: "Subject", vocabulary: "DC Terms", cardinality: "multiple" }, + { iri: "http://purl.org/dc/terms/title", curie: "dcterms:title", displayLabel: "Title", vocabulary: "DC Terms", cardinality: "single" }, + { iri: "http://purl.org/dc/terms/type", curie: "dcterms:type", displayLabel: "Type", vocabulary: "DC Terms", cardinality: "multiple" }, + { iri: "http://purl.org/dc/terms/abstract", curie: "dcterms:abstract", displayLabel: "Abstract", vocabulary: "DC Terms", cardinality: "multiple" }, // ── SKOS ── - { iri: "http://www.w3.org/2004/02/skos/core#prefLabel", curie: "skos:prefLabel", displayLabel: "Preferred Label", vocabulary: "SKOS" }, - { iri: "http://www.w3.org/2004/02/skos/core#altLabel", curie: "skos:altLabel", displayLabel: "Synonym(s)", vocabulary: "SKOS" }, - { iri: "http://www.w3.org/2004/02/skos/core#hiddenLabel", curie: "skos:hiddenLabel", displayLabel: "Hidden Label", vocabulary: "SKOS" }, - { iri: "http://www.w3.org/2004/02/skos/core#definition", curie: "skos:definition", displayLabel: "Definition", vocabulary: "SKOS" }, - { iri: "http://www.w3.org/2004/02/skos/core#example", curie: "skos:example", displayLabel: "Example(s)", vocabulary: "SKOS" }, - { iri: "http://www.w3.org/2004/02/skos/core#scopeNote", curie: "skos:scopeNote", displayLabel: "Scope Note", vocabulary: "SKOS" }, - { iri: "http://www.w3.org/2004/02/skos/core#editorialNote", curie: "skos:editorialNote", displayLabel: "Editorial Note", vocabulary: "SKOS" }, - { iri: "http://www.w3.org/2004/02/skos/core#historyNote", curie: "skos:historyNote", displayLabel: "History Note", vocabulary: "SKOS" }, - { iri: "http://www.w3.org/2004/02/skos/core#changeNote", curie: "skos:changeNote", displayLabel: "Change Note", vocabulary: "SKOS" }, - { iri: "http://www.w3.org/2004/02/skos/core#notation", curie: "skos:notation", displayLabel: "Notation", vocabulary: "SKOS" }, + { iri: "http://www.w3.org/2004/02/skos/core#prefLabel", curie: "skos:prefLabel", displayLabel: "Preferred Label", vocabulary: "SKOS", cardinality: "single-per-lang" }, + { iri: "http://www.w3.org/2004/02/skos/core#altLabel", curie: "skos:altLabel", displayLabel: "Synonym(s)", vocabulary: "SKOS", cardinality: "multiple" }, + { iri: "http://www.w3.org/2004/02/skos/core#hiddenLabel", curie: "skos:hiddenLabel", displayLabel: "Hidden Label", vocabulary: "SKOS", cardinality: "multiple" }, + { iri: "http://www.w3.org/2004/02/skos/core#definition", curie: "skos:definition", displayLabel: "Definition", vocabulary: "SKOS", cardinality: "single-per-lang" }, + { iri: "http://www.w3.org/2004/02/skos/core#example", curie: "skos:example", displayLabel: "Example(s)", vocabulary: "SKOS", cardinality: "multiple" }, + { iri: "http://www.w3.org/2004/02/skos/core#scopeNote", curie: "skos:scopeNote", displayLabel: "Scope Note", vocabulary: "SKOS", cardinality: "multiple" }, + { iri: "http://www.w3.org/2004/02/skos/core#editorialNote", curie: "skos:editorialNote", displayLabel: "Editorial Note", vocabulary: "SKOS", cardinality: "multiple" }, + { iri: "http://www.w3.org/2004/02/skos/core#historyNote", curie: "skos:historyNote", displayLabel: "History Note", vocabulary: "SKOS", cardinality: "multiple" }, + { iri: "http://www.w3.org/2004/02/skos/core#changeNote", curie: "skos:changeNote", displayLabel: "Change Note", vocabulary: "SKOS", cardinality: "multiple" }, + { iri: "http://www.w3.org/2004/02/skos/core#notation", curie: "skos:notation", displayLabel: "Notation", vocabulary: "SKOS", cardinality: "single" }, // ── RDFS / OWL ── - { iri: "http://www.w3.org/2000/01/rdf-schema#seeAlso", curie: "rdfs:seeAlso", displayLabel: "See Also", vocabulary: "RDFS" }, - { iri: "http://www.w3.org/2000/01/rdf-schema#isDefinedBy", curie: "rdfs:isDefinedBy", displayLabel: "Defined By", vocabulary: "RDFS" }, + { iri: "http://www.w3.org/2000/01/rdf-schema#seeAlso", curie: "rdfs:seeAlso", displayLabel: "See Also", vocabulary: "RDFS", cardinality: "multiple" }, + { iri: "http://www.w3.org/2000/01/rdf-schema#isDefinedBy", curie: "rdfs:isDefinedBy", displayLabel: "Defined By", vocabulary: "RDFS", cardinality: "multiple" }, ]; /** Well-known IRIs excluded from the general "Annotations" section (shown in their own sections) */ @@ -108,3 +116,20 @@ export function getAnnotationPropertiesByVocabulary(): Record = { + [LABEL_IRI]: "single-per-lang", + [COMMENT_IRI]: "multiple", +}; + +/** + * Returns the cardinality for an annotation property IRI. + * Falls back to "multiple" for unknown IRIs — the safe default that never + * artificially restricts user-defined annotation properties. + */ +export function getAnnotationCardinality(iri: string): AnnotationCardinality { + const found = ANNOTATION_PROPERTIES.find((p) => p.iri === iri); + if (found) return found.cardinality; + return EXTRA_CARDINALITIES[iri] ?? "multiple"; +}