Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/optional-statements-in-groups.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Optional statements inside statement groups

**Status:** design draft — not implemented.
**Status:** implemented (template parsing, publish path, form UI, fill/unification);
end-to-end verification on a live instance with a published test template still pending.

## Goal

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ private void updateViewElements() {
if (isGrouped() && !first) {
viewElements.add(new HorizontalLine("statement"));
}
viewElements.addAll(r.getStatementParts());
viewElements.addAll(r.getShownStatementParts());
boolean isOnly = repetitionGroups.size() == 1;
boolean isLast = repetitionGroups.get(repetitionGroups.size() - 1) == r;
r.addRepetitionButton.setVisible(!context.isReadOnly() && isRepeatable() && isLast);
Expand Down Expand Up @@ -338,6 +338,9 @@ public class RepetitionGroup implements Serializable {
private List<StatementPartItem> statementParts;
private List<ValueItem> localItems = new ArrayList<>();
private boolean filled = false;
// Optional group members that were left unassigned by a successful fill();
// read-only rendering hides these rows:
private Set<IRI> unmatchedParts = new HashSet<>();

private List<ValueItem> items = new ArrayList<>();

Expand Down Expand Up @@ -386,13 +389,23 @@ public RepetitionGroup() {
// optionalMark.setVisible(false);
// }

boolean isMemberOptional = isGrouped() && getTemplate().isOptionalStatement(s);

if (!context.isReadOnly() && isOptional && isLastLine) {
optionalMark = new Label("label", "(optional)");
} else {
optionalMark = new Label("label", "");
optionalMark.setVisible(false);
}
statement.add(optionalMark);
// Member-level mark, rendered inline on the member's own line; unlike the
// group-level mark it holds per repetition, so it is never toggled off:
Label partOptionalMark = new Label("part-label", "(optional)");
partOptionalMark.setVisible(!context.isReadOnly() && isMemberOptional);
statement.add(partOptionalMark);
if (!context.isReadOnly() && isMemberOptional) {
statement.add(new AttributeAppender("class", " nanopub-optional-part"));
}
if (isLastLine) {
addRepetitionButton = new Label("add-repetition", "+");
statement.add(addRepetitionButton);
Expand Down Expand Up @@ -456,6 +469,25 @@ public List<StatementPartItem> getStatementParts() {
return statementParts;
}

/**
* Returns the statement parts to render: in read-only contexts, optional group
* members that were left unassigned by fill() are hidden; in editable contexts
* (fresh publish, derive, update) all parts are shown, with skipped members
* rendering as empty fields.
*
* @return a list of StatementPartItem objects to render
*/
private List<StatementPartItem> getShownStatementParts() {
if (!context.isReadOnly() || unmatchedParts.isEmpty()) return statementParts;
List<StatementPartItem> shown = new ArrayList<>();
for (int i = 0; i < statementParts.size(); i++) {
if (!unmatchedParts.contains(statementPartIds.get(i))) {
shown.add(statementParts.get(i));
}
}
return shown;
}

/**
* Returns the index of this repetition group in the list of repetition groups.
*
Expand Down Expand Up @@ -563,6 +595,19 @@ public boolean isOptional() {
return false;
}

/**
* Checks if the given member statement is effectively optional in this repetition group:
* either the whole group is optional or the member itself carries the optional flag.
* Unlike group-level optionality, the member-level flag holds per repetition, so it is
* not affected by the number of repetition groups.
*
* @param partId the IRI of the member statement to check
* @return true if the member statement is effectively optional, false otherwise
*/
public boolean isOptionalPart(IRI partId) {
return isOptional() || getTemplate().isOptionalStatement(partId);
}

private Value transform(Value value) {
if (!(value instanceof IRI)) {
return value;
Expand Down Expand Up @@ -592,6 +637,11 @@ public void addTriplesTo(NanopubCreator npCreator) throws NanopubAlreadyFinalize
IRI subj = context.processIri((IRI) transform(t.getSubject(s)));
IRI pred = context.processIri((IRI) transform(t.getPredicate(s)));
Value obj = context.processValue(transform(t.getObject(s)));
if (isGrouped() && t.isOptionalStatement(s) && (subj == null || pred == null || obj == null)) {
// Optional group member without all elements resolved: drop just this
// triple; the rest of the group is still emitted.
continue;
}
if (context.getType() == ContextType.ASSERTION) {
npCreator.addAssertionStatement(subj, pred, obj);
} else if (context.getType() == ContextType.PROVENANCE) {
Expand All @@ -618,6 +668,9 @@ public void addTriplesTo(NanopubCreator npCreator) throws NanopubAlreadyFinalize

private boolean hasEmptyElements() {
for (IRI s : statementPartIds) {
// Optional group members may stay empty; they are dropped at triple-creation
// time and must not block or drop the rest of the group:
if (isGrouped() && getTemplate().isOptionalStatement(s)) continue;
if (context.processIri((IRI) transform(getTemplate().getSubject(s))) == null) return true;
if (context.processIri((IRI) transform(getTemplate().getPredicate(s))) == null) return true;
if (context.processValue(transform(getTemplate().getObject(s))) == null) return true;
Expand Down Expand Up @@ -659,10 +712,17 @@ public boolean matches(List<Statement> statements) {
// We do exactly that (with backtracking) but on a copy and then restore the models, so
// matches() stays side-effect free.
Map<IRI, Object> snapshot = snapshotModels();
Set<IRI> unmatchedBefore = new HashSet<>(unmatchedParts);
try {
return assignParts(0, new ArrayList<>(statements));
List<Statement> copy = new ArrayList<>(statements);
// A match must consume at least one statement: with optional members, an
// assignment that skips every part would otherwise "match" without evidence,
// which would also keep the repetition loop in fill() spinning forever.
return assignParts(0, copy) && copy.size() < statements.size();
} finally {
restoreModels(snapshot);
unmatchedParts.clear();
unmatchedParts.addAll(unmatchedBefore);
}
}

Expand All @@ -678,7 +738,9 @@ public void fill(List<Statement> statements) throws UnificationException {
// that blocks a later part even though a consistent assignment exists. assignParts tries
// alternatives and rolls back the model bindings between attempts. On success the matched
// statements are removed from the list and the winning bindings are kept.
if (!assignParts(0, statements)) {
unmatchedParts.clear();
int sizeBefore = statements.size();
if (!assignParts(0, statements) || statements.size() == sizeBefore) {
throw new UnificationException("Unification seemed to work but then didn't");
}
filled = true;
Expand Down Expand Up @@ -710,6 +772,15 @@ && unifyPart(p.getObject(), s.getObject())) {
}
restoreModels(snapshot);
}
IRI partId = statementPartIds.get(partIndex);
if (isGrouped() && getTemplate().isOptionalStatement(partId)) {
// Optional group member with no consistent candidate: leave it unassigned and
// move on. Trying all candidates first (above) keeps fills maximal — a member
// is only skipped when no consistent assignment exists.
unmatchedParts.add(partId);
if (assignParts(partIndex + 1, available)) return true;
unmatchedParts.remove(partId);
}
return false;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
<span class="valueitem pred" wicket:id="pred">pred</span>
<span class="valueitem obj" wicket:id="obj">obj</span>
<span class="fullstop">.</span>
<span class="optional-label optional-part-label" wicket:id="part-label">label</span>
<span class="nanopub-statement-float">
<span wicket:id="add-repetition" class="smallbutton">+</span>
<span wicket:id="remove-repetition" class="smallbutton">-</span>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,11 @@ public ValueItem(String id, Value value, IRI statementPartId, RepetitionGroup rg
component = new IriItem("value", id, iri, id.equals("obj"), statementPartId, rg);
}
} else if (template.isRestrictedChoicePlaceholder(iri)) {
component = new RestrictedChoiceItem("value", id, iri, rg.isOptional(), this.context);
component = new RestrictedChoiceItem("value", id, iri, rg.isOptionalPart(statementPartId), this.context);
} else if (template.isAgentPlaceholder(iri)) {
component = new AgentChoiceItem("value", id, iri, rg.isOptional(), this.context);
component = new AgentChoiceItem("value", id, iri, rg.isOptionalPart(statementPartId), this.context);
} else if (template.isGuidedChoicePlaceholder(iri)) {
component = new GuidedChoiceItem("value", id, iri, rg.isOptional(), this.context);
component = new GuidedChoiceItem("value", id, iri, rg.isOptionalPart(statementPartId), this.context);
} else if (template.isIntroducedResource(iri) && (this.context.getFillMode() == FillMode.SUPERSEDE || this.context.getFillMode() == FillMode.OVERRIDE)
&& introducesAnything(this.context.getFillSource())) {
// An introduced resource keeps its IRI across versions, so it is pinned
Expand All @@ -64,20 +64,20 @@ && introducesAnything(this.context.getFillSource())) {
// yet) the field must stay fillable (issue #549).
component = new ReadonlyItem("value", id, iri, statementPartId, rg);
} else if (template.isUriPlaceholder(iri)) {
component = new IriTextfieldItem("value", id, iri, rg.isOptional(), this.context);
component = new IriTextfieldItem("value", id, iri, rg.isOptionalPart(statementPartId), this.context);
} else if (template.isLongLiteralPlaceholder(iri)) {
component = new LiteralTextareaItem("value", iri, rg.isOptional(), this.context);
component = new LiteralTextareaItem("value", iri, rg.isOptionalPart(statementPartId), this.context);
} else if (template.isLiteralPlaceholder(iri)) {
// TODO add all date time types
if (XSD.DATE.equals(template.getDatatype(iri))) {
component = new LiteralDateItem("value", iri, rg.isOptional(), this.context);
component = new LiteralDateItem("value", iri, rg.isOptionalPart(statementPartId), this.context);
} else if (XSD.DATETIME.equals(template.getDatatype(iri))) {
component = new LiteralDateTimeItem("value", iri, rg.isOptional(), this.context);
component = new LiteralDateTimeItem("value", iri, rg.isOptionalPart(statementPartId), this.context);
} else {
component = new LiteralTextfieldItem("value", iri, rg.isOptional(), this.context);
component = new LiteralTextfieldItem("value", iri, rg.isOptionalPart(statementPartId), this.context);
}
} else if (template.isPlaceholder(iri)) {
component = new ValueTextfieldItem("value", id, iri, rg.isOptional(), this.context);
component = new ValueTextfieldItem("value", id, iri, rg.isOptionalPart(statementPartId), this.context);
} else {
component = new IriItem("value", id, iri, id.equals("obj"), statementPartId, rg);
}
Expand Down
40 changes: 38 additions & 2 deletions src/main/java/com/knowledgepixels/nanodash/template/Template.java
Original file line number Diff line number Diff line change
Expand Up @@ -898,13 +898,49 @@ private void processNpTemplate(Nanopub templateNp) throws MalformedTemplateExcep
// !assertionTypes.contains(NTEMPLATE.PROVENANCE_TEMPLATE) && !assertionTypes.contains(PUBINFO_TEMPLATE))) {
// throw new MalformedTemplateException("Unknown template type");
// }
// Grouped IRI are added via group, so direct link from template is redundant:
List<IRI> groupIris = new ArrayList<>();
for (IRI iri : typeMap.keySet()) {
if (!typeMap.get(iri).contains(NTEMPLATE.GROUPED_STATEMENT)) continue;
if (typeMap.get(iri).contains(NTEMPLATE.GROUPED_STATEMENT)) groupIris.add(iri);
}
// Nested groups and repeatable members are out of scope (the group is the unit of
// repetition); ignore such member flags with a warning instead of half-rendering:
for (IRI iri : groupIris) {
if (!isGroupedStatement(iri)) continue;
List<IRI> memberIris = getStatementIris(iri);
if (memberIris == null) {
throw new MalformedTemplateException("Grouped statement has no member statements: " + iri);
}
for (IRI member : memberIris) {
List<IRI> memberTypes = typeMap.get(member);
if (memberTypes == null) continue;
if (memberTypes.remove(NTEMPLATE.GROUPED_STATEMENT)) {
logger.warn("Ignoring nested nt:GroupedStatement flag on member {} of group {}", member, iri);
}
if (memberTypes.remove(NTEMPLATE.REPEATABLE_STATEMENT)) {
logger.warn("Ignoring nt:RepeatableStatement flag on member {} of group {}", member, iri);
}
}
}
for (IRI iri : groupIris) {
if (!isGroupedStatement(iri)) continue;
List<IRI> memberIris = getStatementIris(iri);
// A group needs at least one required member: an all-optional group would match zero
// statements, making its presence meaningless and repetition detection non-terminating.
// Degrade to group-level optionality (today's semantics) instead of rejecting:
boolean hasRequiredMember = false;
for (IRI member : memberIris) {
if (!isOptionalStatement(member)) hasRequiredMember = true;
}
if (!hasRequiredMember) {
logger.warn("All members of group {} are optional; treating the group itself as optional instead", iri);
if (!isOptionalStatement(iri)) {
addType(iri, NTEMPLATE.OPTIONAL_STATEMENT);
}
for (IRI member : memberIris) {
typeMap.get(member).remove(NTEMPLATE.OPTIONAL_STATEMENT);
}
}
// Grouped IRI are added via group, so direct link from template is redundant:
for (IRI groupedIri : memberIris) {
statementMap.get(templateIri).remove(groupedIri);
}
Expand Down
8 changes: 8 additions & 0 deletions src/main/webapp/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -1803,6 +1803,14 @@ a[href=""] {
display: none;
}

/* Mark of an optional member statement inside a (required) group: rendered inline
at the end of that member's line, unlike the group-level mark, which is
absolutely positioned on the group box. */
.optional-part-label {
position: static;
margin-left: 6px;
}

.separate-statement {
border-top: 1px solid rgba(0, 0, 0, 0.3);
padding-top: 10px;
Expand Down
Loading