TextApiV2Resource.java
package info.textgrid.services.textapi;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.parameters.Parameter;
import org.eclipse.microprofile.openapi.annotations.media.Content;
import org.eclipse.microprofile.openapi.annotations.media.Schema;
import org.eclipse.microprofile.openapi.annotations.responses.APIResponse;
import info.textgrid.namespaces.metadata.agent._2010.AgentType;
import info.textgrid.namespaces.metadata.core._2010.EditionType;
import info.textgrid.namespaces.metadata.core._2010.ObjectType;
import info.textgrid.namespaces.middleware.tgsearch.ResultType;
import info.textgrid.services.textapi.apiv2.Agent;
import info.textgrid.services.textapi.apiv2.Collection;
import info.textgrid.services.textapi.apiv2.CollectionReference;
import info.textgrid.services.textapi.apiv2.Item;
import info.textgrid.services.textapi.FieldOverrideService;
import info.textgrid.services.textapi.apiv2.LicenseMapper;
import info.textgrid.services.textapi.apiv2.Manifest;
import info.textgrid.services.textapi.apiv2.SimpleReference;
import info.textgrid.services.textapi.services.SearchClientService;
import io.quarkus.runtime.LaunchMode;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.HEAD;
import jakarta.ws.rs.OPTIONS;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import javax.xml.datatype.XMLGregorianCalendar;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
@Path("/textapi/v2")
public class TextApiV2Resource {
/**
* Extract the project ID from the metadata.
* The project ID is typically in the generated/Project/Id field.
*/
@SuppressWarnings("unchecked")
private String extractProjectId(Object object) {
try {
var getGeneric = object.getClass().getMethod("getGeneric");
var generic = getGeneric.invoke(object);
var getGenerated = generic.getClass().getMethod("getGenerated");
var generated = getGenerated.invoke(generic);
var getProject = generated.getClass().getMethod("getProject");
var project = getProject.invoke(generated);
if (project != null) {
var getId = project.getClass().getMethod("getId");
return (String) getId.invoke(project);
}
} catch (Exception e) {
// Fall back to null
}
return null;
}
/**
* Apply field overrides to a manifest based on the project configuration.
*/
private void applyFieldOverrides(String projectId, Manifest manifest, String contextSlug) {
var config = fieldOverrideService.loadConfig(projectId);
if (config != null) {
fieldOverrideService.applyManifestOverrides(config, manifest, contextSlug);
}
}
/**
* Check if the given format is a valid aggregation format.
* Accepts: text/tg.aggregation+xml, text/tg.collection+tg.aggregation+xml, text/tg.edition+tg.aggregation+xml
*/
private boolean isAggregationFormat(String format) {
return format != null && (
format.equals("text/tg.aggregation+xml") ||
format.equals("text/tg.collection+tg.aggregation+xml") ||
format.equals("text/tg.edition+tg.aggregation+xml")
);
}
/**
* Take the "created" timestamp from the tgsearch response.
* It is read from the "created" child node of the "generated" element of
* the object's metadata. Falls back to the fallback value (current time)
* if the timestamp is not present.
*/
private String getCreatedTimestamp(ObjectType object, String fallback) {
if (object == null || object.getGeneric() == null
|| object.getGeneric().getGenerated() == null
|| object.getGeneric().getGenerated().getCreated() == null) {
return fallback;
}
return object.getGeneric().getGenerated().getCreated().toString();
}
/**
* Take the "modified" timestamp from the tgsearch response.
* It is read from the "lastModified" child node of the "generated" element
* of the object's metadata. Falls back to the fallback value (current time)
* if the timestamp is not present.
*/
private String getModifiedTimestamp(ObjectType object, String fallback) {
if (object == null || object.getGeneric() == null
|| object.getGeneric().getGenerated() == null
|| object.getGeneric().getGenerated().getLastModified() == null) {
return fallback;
}
return object.getGeneric().getGenerated().getLastModified().toString();
}
/**
* Take the project name from the tgsearch response.
* It is read from the text node of the "project" child node of the
* "generated" element of the object's metadata. Returns null if the
* project name is not present.
*/
private String getProjectName(ObjectType object) {
if (object == null || object.getGeneric() == null
|| object.getGeneric().getGenerated() == null
|| object.getGeneric().getGenerated().getProject() == null
|| object.getGeneric().getGenerated().getProject().getValue() == null
|| object.getGeneric().getGenerated().getProject().getValue().isBlank()) {
return null;
}
return object.getGeneric().getGenerated().getProject().getValue();
}
/**
* Resolve the short title for a referenced object from its TextGrid metadata.
* Prefers the title already contained in the search result and falls back to
* fetching the metadata for the referenced object. Returns null if no title
* can be resolved.
*/
private String resolveShortTitle(ResultType res) {
// Prefer the title already present in the search result
try {
var titles = res.getObject().getGeneric().getProvided().getTitle();
if (titles != null && !titles.isEmpty()) {
return titles.get(0);
}
} catch (Exception e) {
// fall through to metadata lookup
}
// Fall back to fetching the metadata of the referenced object
try {
var meta = searchClient.infoQuery().getMetadata(res.getTextgridUri());
if (meta != null && meta.getResult() != null && !meta.getResult().isEmpty()) {
var titles = meta.getResult().get(0).getObject().getGeneric().getProvided().getTitle();
if (titles != null && !titles.isEmpty()) {
return titles.get(0);
}
}
} catch (Exception e) {
// Metadata unavailable, shortTitle stays null
}
return null;
}
@Inject
SearchClientService searchClient;
@ConfigProperty(name = "textgrid.host")
String textgridHost;
@ConfigProperty(name = "service.url")
String serviceUrl;
@ConfigProperty(name = "validator.url")
String validatorUrl;
@ConfigProperty(name = "quarkus.application.version")
String version;
@Inject
LaunchMode launchMode;
@Inject
FieldOverrideService fieldOverrideService;
@GET
@Produces(MediaType.APPLICATION_JSON)
@Path("/manifests/{id}")
@Operation(summary = "Generate a TextAPI v2 manifest", description = "Use TextGridURI to list all items of an aggregation as TextAPI v2 Items")
@APIResponse(responseCode = "200", description = "The requested Manifest", content = @Content(schema = @Schema(implementation = Manifest.class)))
@APIResponse(responseCode = "404", description = "Not Found")
@APIResponse(responseCode = "500", description = "Internal Server Error")
public Manifest manifest(
@Parameter(description = "the TextGridURI to generate a manifest for", example = "textgrid:wp1j.0")
@PathParam("id") String id,
@Parameter(description = "embed full resources instead of references (default: false)", example = "false")
@QueryParam("embedded") Boolean embedded) {
var meta = searchClient.infoQuery().getMetadata(id);
if (meta.getResult().isEmpty()) {
throw new jakarta.ws.rs.WebApplicationException(
"Resource not found: " + id,
jakarta.ws.rs.core.Response.Status.INTERNAL_SERVER_ERROR
);
}
var label = meta.getResult().get(0).getObject().getGeneric().getProvided().getTitle().get(0);
var format = meta.getResult().get(0).getObject().getGeneric().getProvided().getFormat();
String manifestId = serviceUrl + "/v2/manifests/" + id;
String revision = "latest";
// fallback, only used when the tgsearch metadata has no generated timestamps
String timestamp = java.time.Instant.now().toString();
// Extract license from EditionType and map to SPDX
var object = meta.getResult().get(0).getObject();
String licenseSpdx = "CC0-1.0"; // Default public domain license
if (object.getEdition() != null && object.getEdition().getLicense() != null) {
licenseSpdx = LicenseMapper.mapToSpdx(object.getEdition().getLicense());
// Fall back to default if mapping returns null
if (licenseSpdx == null) {
licenseSpdx = "CC0-1.0";
}
}
var m = new Manifest()
.setContext("https://editions.sub.uni-goettingen.de/context.json")
.setTextapiVersion("2.0.0")
.setId(manifestId)
.setTextapiType("TextApiManifest")
.setRevision(revision)
.setCreated(getCreatedTimestamp(object, timestamp))
.setModified(getModifiedTimestamp(object, timestamp))
.setLicense(licenseSpdx)
.setTotal(0);
// Add title from metadata
m.getTitles().add(label);
// Add agents from metadata (EditionType or WorkType)
if (object.getEdition() != null) {
for (AgentType tgAgent : object.getEdition().getAgent()) {
var agent = new Agent()
.setName(tgAgent.getValue())
.addRole(tgAgent.getRole() != null ? tgAgent.getRole().value() : "unknown");
if (tgAgent.getId() != null) {
agent.setId(tgAgent.getId());
}
m.addAgent(agent);
}
} else if (object.getWork() != null) {
for (AgentType tgAgent : object.getWork().getAgent()) {
var agent = new Agent()
.setName(tgAgent.getValue())
.addRole(tgAgent.getRole() != null ? tgAgent.getRole().value() : "unknown");
if (tgAgent.getId() != null) {
agent.setId(tgAgent.getId());
}
m.addAgent(agent);
}
}
// For aggregations, add all items
if (isAggregationFormat(format)) {
for (ResultType res : searchClient.navigationQuery().listAggregation(id).getResult()) {
var itemId = serviceUrl + "/v2/items/" + res.getTextgridUri();
var itemLabel = res.getObject().getGeneric().getProvided().getTitle().get(0);
var item = new Item()
.setContext("https://editions.sub.uni-goettingen.de/context.json")
.setTextapiVersion("2.0.0")
.setId(itemId)
.setTextapiType("TextApiItem")
.setRevision(revision)
.setCreated(getCreatedTimestamp(res.getObject(), timestamp))
.setModified(getModifiedTimestamp(res.getObject(), timestamp))
.setTextItemType("section")
.setDivision("full text")
.addLanguage("und");
// Add title to item
item.getTitles().add(itemLabel);
// Add content pointing to aggregator
var content = new info.textgrid.services.textapi.apiv2.Content()
.setTexttextgridHost(textgridHost)
.setTextgridUri(res.getTextgridUri())
.setId(textgridHost + "/1.0/aggregator/html/" + res.getTextgridUri() + "?embedded=true")
.setContentType("text/html");
item.addContent(content);
m.addItem(item);
}
m.setTotal(m.getItems().size());
} else {
// Single item
var itemId = serviceUrl + "/v2/items/" + id;
var item = new Item()
.setContext("https://editions.sub.uni-goettingen.de/context.json")
.setTextapiVersion("2.0.0")
.setId(itemId)
.setTextapiType("TextApiItem")
.setRevision(revision)
.setCreated(getCreatedTimestamp(object, timestamp))
.setModified(getModifiedTimestamp(object, timestamp))
.setTextItemType("full")
.setDivision("full text")
.addLanguage("und");
// Add title to item
item.getTitles().add(label);
// Add content pointing to aggregator
var content = new info.textgrid.services.textapi.apiv2.Content()
.setTexttextgridHost(textgridHost)
.setTextgridUri(id)
.setId(textgridHost + "/1.0/aggregator/html/" + id + "?embedded=true")
.setContentType("text/html");
item.addContent(content);
m.addItem(item);
m.setTotal(1);
}
// Extract project ID and apply field overrides
String projectId = extractProjectId(object);
if (projectId != null) {
applyFieldOverrides(projectId, m, null);
// Apply include and exclude rules for items.
// 1) Include filter: Keep only items that appear in include targets.
// 2) Exclude filter: Remove items that appear in exclude targets.
var config = fieldOverrideService.loadConfig(projectId);
if (config != null && m.getItems() != null) {
if (config.getInclude() != null) {
m.getItems().removeIf(item -> {
String itemId = item.getId();
return itemId != null
&& !fieldOverrideService.isIncluded(config, manifestId, itemId);
});
}
if (config.getExclude() != null) {
m.getItems().removeIf(item -> {
String itemId = item.getId();
return itemId != null
&& fieldOverrideService.isExcluded(config, manifestId, itemId);
});
}
// Recompute total after filtering
m.setTotal(m.getItems().size());
}
}
return m;
}
@GET
@Produces(MediaType.APPLICATION_JSON)
@Path("/items/{id}")
@Operation(summary = "Generate a TextAPI v2 item", description = "Get aggregator URL for given TextGridUri for TEI -> HTML transformation")
@APIResponse(responseCode = "200", description = "The requested Item", content = @Content(schema = @Schema(implementation = Item.class)))
@APIResponse(responseCode = "404", description = "Not Found")
@APIResponse(responseCode = "500", description = "Internal Server Error")
public Item item(
@Parameter(description = "textgridUri of a single item", example = "textgrid:wmd3.0")
@PathParam("id") String id) {
var meta = searchClient.infoQuery().getMetadata(id);
if (meta.getResult().isEmpty()) {
throw new jakarta.ws.rs.WebApplicationException(
"Resource not found: " + id,
jakarta.ws.rs.core.Response.Status.NOT_FOUND
);
}
var label = meta.getResult().get(0).getObject().getGeneric().getProvided().getTitle().get(0);
String itemId = serviceUrl + "/v2/items/" + id;
String revision = "latest";
// fallback, only used when the tgsearch metadata has no generated timestamps
String timestamp = java.time.Instant.now().toString();
var content = new info.textgrid.services.textapi.apiv2.Content()
.setTexttextgridHost(textgridHost)
.setTextgridUri(id)
.setId(textgridHost + "/1.0/aggregator/html/" + id + "?embedded=true")
.setContentType("text/html");
var item = new Item()
.setContext("https://editions.sub.uni-goettingen.de/context.json")
.setTextapiVersion("2.0.0")
.setId(itemId)
.setTextapiType("TextApiItem")
.setRevision(revision)
.setCreated(getCreatedTimestamp(meta.getResult().get(0).getObject(), timestamp))
.setModified(getModifiedTimestamp(meta.getResult().get(0).getObject(), timestamp))
.setTextItemType("full")
.setDivision("full text")
.addLanguage("und")
.addContent(content);
// Add title to item
item.getTitles().add(label);
// Apply field overrides if available
try {
String projectId = extractProjectId(meta.getResult().get(0).getObject());
if (projectId != null) {
fieldOverrideService.loadAndApplyItemOverrides(projectId, item, null);
}
} catch (Exception e) {
// Field overrides are optional, so log and continue
// In production, consider proper logging
}
return item;
}
@HEAD
@Path("/manifests/{id}")
@Operation(summary = "Check if manifest exists")
public Response headManifest(
@Parameter(description = "the TextGridURI to check", example = "textgrid:wp1j.0")
@PathParam("id") String id) {
// Check if the resource exists by attempting to fetch metadata
try {
var meta = searchClient.infoQuery().getMetadata(id);
if (meta.getResult().isEmpty()) {
return Response.status(Response.Status.NOT_FOUND).build();
}
return Response.ok().build();
} catch (Exception e) {
return Response.status(Response.Status.NOT_FOUND).build();
}
}
@HEAD
@Path("/items/{id}")
@Operation(summary = "Check if item exists")
public Response headItem(
@Parameter(description = "textgridUri to check", example = "textgrid:wmd3.0")
@PathParam("id") String id) {
try {
var meta = searchClient.infoQuery().getMetadata(id);
if (meta.getResult().isEmpty()) {
return Response.status(Response.Status.NOT_FOUND).build();
}
return Response.ok().build();
} catch (Exception e) {
return Response.status(Response.Status.NOT_FOUND).build();
}
}
@OPTIONS
@Path("/manifests/{id}")
@Operation(summary = "Get options for manifest endpoint")
public Response optionsManifest() {
return Response.ok()
.header("Allow", "GET, HEAD, OPTIONS")
.build();
}
@OPTIONS
@Path("/items/{id}")
@Operation(summary = "Get options for item endpoint")
public Response optionsItem() {
return Response.ok()
.header("Allow", "GET, HEAD, OPTIONS")
.build();
}
@GET
@Produces(MediaType.APPLICATION_JSON)
@Path("/collections/{slug}/")
@Operation(summary = "Retrieve a Collection", description = "Retrieve a TextAPI v2 Collection from a TextGrid aggregation URI")
@APIResponse(responseCode = "200", description = "The requested Collection", content = @Content(schema = @Schema(implementation = Collection.class)))
@APIResponse(responseCode = "404", description = "Not Found")
@APIResponse(responseCode = "400", description = "Bad Request - URI is not an aggregation")
@APIResponse(responseCode = "500", description = "Internal Server Error")
public Collection collection(
@Parameter(description = "the TextGrid aggregation URI to generate a collection for", example = "textgrid:3r84g.0")
@PathParam("slug") String slug,
@Parameter(description = "embed full resources instead of references (default: false)", example = "false")
@QueryParam("embedded") Boolean embedded) {
var meta = searchClient.infoQuery().getMetadata(slug);
if (meta.getResult().isEmpty()) {
throw new jakarta.ws.rs.WebApplicationException(
"Resource not found: " + slug,
jakarta.ws.rs.core.Response.Status.NOT_FOUND
);
}
var label = meta.getResult().get(0).getObject().getGeneric().getProvided().getTitle().get(0);
var format = meta.getResult().get(0).getObject().getGeneric().getProvided().getFormat();
var project = meta.getResult().get(0).getObject().getGeneric().getGenerated().getProject();
var dataContributor = meta.getResult().get(0).getObject().getGeneric().getGenerated().getDataContributor();
var created = meta.getResult().get(0).getObject().getGeneric().getGenerated().getCreated();
var modified = meta.getResult().get(0).getObject().getGeneric().getGenerated().getLastModified();
// Verify it's an aggregation
if (!isAggregationFormat(format)) {
throw new jakarta.ws.rs.WebApplicationException(
"URI " + slug + " is not a valid aggregation format, format is: " + format,
jakarta.ws.rs.core.Response.Status.BAD_REQUEST
);
}
String collectionId = serviceUrl + "/v2/collections/" + slug + "/";
String revision = "latest";
// fallback, only used when the tgsearch metadata has no generated timestamps
String timestamp = java.time.Instant.now().toString();
var c = new Collection()
.setContext("https://editions.sub.uni-goettingen.de/context.json")
.setTextapiVersion("2.0.0")
.setId(collectionId)
.setTextapiType("TextApiCollection")
.setRevision(revision)
.setCreated(created != null ? created.toString() : timestamp)
.setModified(modified != null ? modified.toString() : timestamp);
// Add title from metadata
c.getTitles().add(label);
// Add collector (prefer project data, fallback to dataContributor)
// Decode URL-encoded characters in dataContributor (e.g., %C3%B6 -> รถ)
String decodedDataContributor = null;
if (dataContributor != null) {
try {
decodedDataContributor = URLDecoder.decode(dataContributor, StandardCharsets.UTF_8);
} catch (IllegalArgumentException e) {
// If decoding fails, use the original value
decodedDataContributor = dataContributor;
}
}
var collector = new Agent()
.setName(decodedDataContributor != null ? decodedDataContributor : project != null ? project.getId() : "Unknown")
.addRole("collector");
c.getCollectors().add(collector);
// Get list of aggregated URIs
var results = searchClient.navigationQuery().listAggregation(slug).getResult();
// Create manifest references or full manifests based on embedded parameter
if (Boolean.TRUE.equals(embedded)) {
// Embed full manifests
for (ResultType res : results) {
var manifestId = res.getTextgridUri();
var manifestObj = searchClient.infoQuery().getMetadata(manifestId);
var manifestLabel = manifestObj.getResult().get(0).getObject().getGeneric().getProvided().getTitle().get(0);
var manifestFormat = manifestObj.getResult().get(0).getObject().getGeneric().getProvided().getFormat();
String manifestUrl = serviceUrl + "/v2/manifests/" + manifestId;
// Extract license from EditionType and map to SPDX for embedded manifest
String embeddedLicenseSpdx = "CC0-1.0"; // Default public domain license
var manifestMeta = searchClient.infoQuery().getMetadata(manifestId);
var manifestObject = manifestMeta.getResult().get(0).getObject();
if (manifestObject.getEdition() != null && manifestObject.getEdition().getLicense() != null) {
embeddedLicenseSpdx = LicenseMapper.mapToSpdx(manifestObject.getEdition().getLicense());
if (embeddedLicenseSpdx == null) {
embeddedLicenseSpdx = "CC0-1.0";
}
}
var manifest = new Manifest()
.setContext("https://editions.sub.uni-goettingen.de/context.json")
.setTextapiVersion("2.0.0")
.setId(manifestUrl)
.setTextapiType("TextApiManifest")
.setRevision(revision)
.setCreated(getCreatedTimestamp(manifestObject, timestamp))
.setModified(getModifiedTimestamp(manifestObject, timestamp))
.setLicense(embeddedLicenseSpdx)
.setTotal(0);
// Add title to manifest
manifest.getTitles().add(manifestLabel);
// For aggregations, add all items
if (isAggregationFormat(manifestFormat)) {
for (ResultType itemRes : searchClient.navigationQuery().listAggregation(manifestId).getResult()) {
var itemId = serviceUrl + "/v2/items/" + itemRes.getTextgridUri();
var itemLabel = itemRes.getObject().getGeneric().getProvided().getTitle().get(0);
var item = new Item()
.setContext("https://editions.sub.uni-goettingen.de/context.json")
.setTextapiVersion("2.0.0")
.setId(itemId)
.setTextapiType("TextApiItem")
.setRevision(revision)
.setCreated(getCreatedTimestamp(itemRes.getObject(), timestamp))
.setModified(getModifiedTimestamp(itemRes.getObject(), timestamp))
.setTextItemType("section")
.setDivision("full text")
.addLanguage("und");
// Add title to item
item.getTitles().add(itemLabel);
// Add content pointing to aggregator
var content = new info.textgrid.services.textapi.apiv2.Content()
.setTexttextgridHost(textgridHost)
.setTextgridUri(itemRes.getTextgridUri())
.setId(textgridHost + "/1.0/aggregator/html/" + itemRes.getTextgridUri() + "?embedded=true")
.setContentType("text/html");
item.addContent(content);
manifest.addItem(item);
}
manifest.setTotal(manifest.getItems().size());
} else {
// Single item
var itemId = serviceUrl + "/v2/items/" + manifestId;
var item = new Item()
.setContext("https://editions.sub.uni-goettingen.de/context.json")
.setTextapiVersion("2.0.0")
.setId(itemId)
.setTextapiType("TextApiItem")
.setRevision(revision)
.setCreated(getCreatedTimestamp(manifestObject, timestamp))
.setModified(getModifiedTimestamp(manifestObject, timestamp))
.setTextItemType("full")
.setDivision("full text")
.addLanguage("und");
// Add title to item
item.getTitles().add(manifestLabel);
// Add content pointing to aggregator
var content = new info.textgrid.services.textapi.apiv2.Content()
.setTexttextgridHost(textgridHost)
.setTextgridUri(manifestId)
.setId(textgridHost + "/1.0/aggregator/html/" + manifestId + "?embedded=true")
.setContentType("text/html");
item.addContent(content);
manifest.addItem(item);
manifest.setTotal(1);
}
c.addManifest(manifest);
}
} else {
// Create manifest references (id + shortTitle) for each aggregated URI
for (ResultType res : results) {
var manifestRef = new SimpleReference()
.setId(serviceUrl + "/v2/manifests/" + res.getTextgridUri())
.setShortTitle(resolveShortTitle(res));
c.addManifest(manifestRef);
}
}
// Set total count
c.setTotal(c.getManifests().size());
// Apply field override config: annotationCollection, include and exclude rules.
// The config maps keys to annotation collection URLs: a TextGrid URI key
// applies to that specific collection; the "project" key applies only to the
// project collection endpoint.
if (project != null) {
// Get the Project ID string, which is the key used to locate the config file
String projectId = extractProjectId(meta.getResult().get(0).getObject());
if (projectId != null) {
var config = fieldOverrideService.loadConfig(projectId);
if (config != null) {
if (config.getAnnotationCollection() != null) {
// Only apply when this TextGrid URI (slug) is used as a key
String annotationCollectionUrl = config.getAnnotationCollection().get(slug);
if (annotationCollectionUrl != null && !annotationCollectionUrl.isEmpty()) {
c.setAnnotationCollection(annotationCollectionUrl);
}
}
// Apply include and exclude rules for manifests.
// 1) Include filter: Keep only manifests that appear in include targets.
// 2) Exclude filter: Remove manifests that appear in exclude targets.
if ((config.getInclude() != null || config.getExclude() != null) && c.getManifests() != null) {
if (config.getInclude() != null) {
c.getManifests().removeIf(entry -> {
String entryId;
if (entry instanceof Manifest embeddedManifest) {
entryId = embeddedManifest.getId();
} else if (entry instanceof SimpleReference manifestRef) {
entryId = manifestRef.getId();
} else {
entryId = String.valueOf(entry);
}
return entryId != null
&& !fieldOverrideService.isIncluded(config, collectionId, entryId);
});
}
if (config.getExclude() != null) {
c.getManifests().removeIf(entry -> {
String entryId;
if (entry instanceof Manifest embeddedManifest) {
entryId = embeddedManifest.getId();
} else if (entry instanceof SimpleReference manifestRef) {
entryId = manifestRef.getId();
} else {
entryId = String.valueOf(entry);
}
return entryId != null
&& fieldOverrideService.isExcluded(config, collectionId, entryId);
});
}
// Recompute total after filtering
c.setTotal(c.getManifests().size());
}
}
}
}
return c;
}
@HEAD
@Path("/collections/{slug}/")
@Operation(summary = "Check if collection exists")
public Response headCollection(
@Parameter(description = "the TextGrid aggregation URI to check", example = "textgrid:3r84g.0")
@PathParam("slug") String slug) {
try {
var meta = searchClient.infoQuery().getMetadata(slug);
var format = meta.getResult().get(0).getObject().getGeneric().getProvided().getFormat();
// Verify it's an aggregation
if (!isAggregationFormat(format)) {
return Response.status(Response.Status.BAD_REQUEST).build();
}
return Response.ok().build();
} catch (Exception e) {
return Response.status(Response.Status.NOT_FOUND).build();
}
}
@OPTIONS
@Path("/collections/{slug}/")
@Operation(summary = "Get options for collection endpoint")
public Response optionsCollection() {
return Response.ok()
.header("Allow", "GET, HEAD, OPTIONS")
.build();
}
/**
* Retrieve a collection of collections (and editions) for a specific project.
* Accepts a project ID starting with 'TGPR-' and returns all collection and
* edition aggregations belonging to that project as a TextAPI v2 Collection.
*/
@GET
@Produces(MediaType.APPLICATION_JSON)
@Path("/collections/project/{projectId}/")
@Operation(summary = "Retrieve a Project Collection", description = "Retrieve a TextAPI v2 Collection containing all collections and editions for a specific TextGrid project")
@APIResponse(responseCode = "200", description = "The requested Project Collection", content = @Content(schema = @Schema(implementation = Collection.class)))
@APIResponse(responseCode = "400", description = "Bad Request - Invalid project ID format")
@APIResponse(responseCode = "404", description = "Not Found - Project not found")
@APIResponse(responseCode = "500", description = "Internal Server Error")
public Collection projectCollection(
@Parameter(description = "the TextGrid project ID (must start with TGPR-)", example = "TGPR-d862e14d-4df7-052b-00fe-661cb242231c")
@PathParam("projectId") String projectId) {
// Validate project ID format
if (projectId == null || !projectId.startsWith("TGPR-")) {
throw new jakarta.ws.rs.WebApplicationException(
"Project ID must start with 'TGPR-'",
jakarta.ws.rs.core.Response.Status.BAD_REQUEST
);
}
// The project collection is virtual (assembled from all of a project's
// toplevel aggregations). Its "created" timestamp is the oldest "created"
// value and its "modified" timestamp the youngest "lastModified" value of
// the sibling resources; current time is used only as fallback.
String timestamp = java.time.Instant.now().toString();
String collectionId = serviceUrl + "/v2/collections/project/" + projectId + "/";
// Create the collection
var c = new Collection()
.setContext("https://editions.sub.uni-goettingen.de/context.json")
.setTextapiVersion("2.0.0")
.setId(collectionId)
.setTextapiType("TextApiCollection")
.setRevision("latest");
// Track the oldest "created" and the youngest "lastModified" value of the
// sibling resources; they provide the timestamps of the virtual collection
XMLGregorianCalendar oldestCreated = null;
XMLGregorianCalendar youngestLastModified = null;
// Get toplevel aggregations with pagination (1000 items per page should be enough)
int pageSize = 1000;
int start = 0;
boolean hasMore = true;
// The project title is read from the metadata of the first sibling
// resource: the text node of the "project" child node of the
// "generated" element (see below). "Project <projectId>" is only a
// fallback, used when the metadata does not provide a project name.
String projectTitle = "Project " + projectId;
// Add collector using project ID
c.getCollectors().add(new Agent().setName(projectId).addRole("collector"));
// Fetch all pages of toplevel aggregations
while (hasMore) {
try {
var toplevelResponse = searchClient.projectQuery().listToplevelAggregations(projectId, start, pageSize);
if (toplevelResponse == null || toplevelResponse.getResult() == null) {
break;
}
var results = toplevelResponse.getResult();
// The project title is read from the metadata of the first
// sibling resource: the text node of the "project" child node
// of the "generated" element. Only the first metadata is used.
if (!results.isEmpty()) {
String projectName = getProjectName(results.get(0).getObject());
if (projectName != null) {
projectTitle = projectName;
}
}
for (ResultType res : results) {
String uri = res.getTextgridUri();
String format = res.getObject().getGeneric().getProvided().getFormat();
if (format == null) {
continue;
}
// Track the oldest "created" and the youngest "lastModified" of
// the sibling resources ("generated" element) for the timestamps
var generated = res.getObject().getGeneric().getGenerated();
if (generated != null) {
if (generated.getCreated() != null && (oldestCreated == null
|| generated.getCreated().compare(oldestCreated) < 0)) {
oldestCreated = generated.getCreated();
}
if (generated.getLastModified() != null && (youngestLastModified == null
|| generated.getLastModified().compare(youngestLastModified) > 0)) {
youngestLastModified = generated.getLastModified();
}
}
// text/tg.collection+tg.aggregation+xml -> add as sub-collection
if (format.equals("text/tg.collection+tg.aggregation+xml")) {
var collectionRef = new CollectionReference()
.setId(serviceUrl + "/v2/collections/" + uri + "/")
.setShortTitle(resolveShortTitle(res));
c.addCollection(collectionRef);
}
// text/tg.edition+tg.aggregation+xml -> add as manifest
else if (format.equals("text/tg.edition+tg.aggregation+xml")) {
var manifestRef = new SimpleReference()
.setId(serviceUrl + "/v2/manifests/" + uri)
.setShortTitle(resolveShortTitle(res));
c.addManifest(manifestRef);
}
// All other formats are ignored as per requirements
}
// Check if we need another page
String hits = toplevelResponse.getHits();
int totalFound = hits != null ? Integer.parseInt(hits) : 0;
start += results.size();
hasMore = start < totalFound;
if (results.isEmpty()) {
hasMore = false;
}
} catch (Exception e) {
throw new jakarta.ws.rs.WebApplicationException(
"Project not found: " + projectId,
jakarta.ws.rs.core.Response.Status.NOT_FOUND
);
}
}
// Add the project title read from the metadata of the first sibling resource
c.getTitles().add(projectTitle);
// Set the collection's timestamps from the sibling resources:
// the oldest "created" and the youngest "lastModified" value
c.setCreated(oldestCreated != null ? oldestCreated.toString() : timestamp);
c.setModified(youngestLastModified != null ? youngestLastModified.toString() : timestamp);
// Set total count (collections + manifests)
int total = 0;
if (c.getCollections() != null) {
total += c.getCollections().size();
}
if (c.getManifests() != null) {
total += c.getManifests().size();
}
c.setTotal(total);
// Apply field override config: annotationCollection, include and exclude rules
var config = fieldOverrideService.loadConfig(projectId);
if (config != null) {
if (config.getAnnotationCollection() != null) {
String annotationCollectionUrl = config.getAnnotationCollection().get("project");
if (annotationCollectionUrl != null && !annotationCollectionUrl.isEmpty()) {
c.setAnnotationCollection(annotationCollectionUrl);
}
}
// Apply include rules for project collection: when present, replace the list
// with the exact URLs from the include target (in the order provided).
if (config.getInclude() != null) {
List<String> includeTargets = fieldOverrideService.getIncludeTargetUrls(config, collectionId);
if (includeTargets != null && !includeTargets.isEmpty()) {
// Clear existing collections and manifests
c.setCollections(new java.util.ArrayList<>());
c.setManifests(new java.util.ArrayList<>());
// Add entries based on the include target list
for (String targetUrl : includeTargets) {
if (targetUrl == null) {
continue;
}
try {
// Extract the TextGrid URI from the target URL
// Expected format: <base>/textapi/v2/collections/<uri>/ or <base>/textapi/v2/manifests/<uri>
String uri = null;
boolean isCollection = true;
String normalized = targetUrl.trim();
while (normalized.endsWith("/")) {
normalized = normalized.substring(0, normalized.length() - 1);
}
int slash = normalized.lastIndexOf('/');
if (slash >= 0) {
uri = normalized.substring(slash + 1);
// Check if this is a collection or manifest URL
if (normalized.contains("/v2/collections/")) {
isCollection = true;
} else if (normalized.contains("/v2/manifests/")) {
isCollection = false;
} else {
// Fallback: assume collection if not clearly specified
isCollection = true;
}
}
if (uri == null) {
continue;
}
// Fetch metadata for short title
String shortTitle = uri;
try {
var meta = searchClient.infoQuery().getMetadata(uri);
if (meta != null && meta.getResult() != null && !meta.getResult().isEmpty()) {
var res = meta.getResult().get(0);
shortTitle = resolveShortTitle(res);
}
} catch (Exception e) {
// Use URI as fallback title
}
if (isCollection) {
var collectionRef = new CollectionReference()
.setId(targetUrl)
.setShortTitle(shortTitle);
c.addCollection(collectionRef);
} else {
var manifestRef = new SimpleReference()
.setId(targetUrl)
.setShortTitle(shortTitle);
c.addManifest(manifestRef);
}
} catch (Exception e) {
// Skip problematic entries
}
}
}
}
// Apply exclude rules: remove target IDs from the lists if this
// collection's ID matches a rule's "from"
if (config.getExclude() != null) {
if (c.getCollections() != null) {
c.getCollections().removeIf(entry ->
entry.getId() != null
&& fieldOverrideService.isExcluded(config, collectionId, entry.getId()));
}
if (c.getManifests() != null) {
c.getManifests().removeIf(entry -> {
String entryId;
if (entry instanceof Manifest embeddedManifest) {
entryId = embeddedManifest.getId();
} else if (entry instanceof SimpleReference manifestRef) {
entryId = manifestRef.getId();
} else {
entryId = String.valueOf(entry);
}
return entryId != null
&& fieldOverrideService.isExcluded(config, collectionId, entryId);
});
}
// Recompute total after removals
int adjustedTotal = 0;
if (c.getCollections() != null) {
adjustedTotal += c.getCollections().size();
}
if (c.getManifests() != null) {
adjustedTotal += c.getManifests().size();
}
c.setTotal(adjustedTotal);
}
}
return c;
}
@HEAD
@Path("/collections/project/{projectId}/")
@Operation(summary = "Check if project collection exists")
public Response headProjectCollection(
@Parameter(description = "the TextGrid project ID (must start with TGPR-)", example = "TGPR-d862e14d-4df7-052b-00fe-661cb242231c")
@PathParam("projectId") String projectId) {
// Validate project ID format
if (projectId == null || !projectId.startsWith("TGPR-")) {
return Response.status(Response.Status.BAD_REQUEST).build();
}
try {
var toplevelResponse = searchClient.projectQuery().listToplevelAggregations(projectId, 0, 1);
if (toplevelResponse != null && toplevelResponse.getResult() != null && !toplevelResponse.getResult().isEmpty()) {
return Response.ok().build();
}
return Response.status(Response.Status.NOT_FOUND).build();
} catch (Exception e) {
return Response.status(Response.Status.NOT_FOUND).build();
}
}
@OPTIONS
@Path("/collections/project/{projectId}/")
@Operation(summary = "Get options for project collection endpoint")
public Response optionsProjectCollection() {
return Response.ok()
.header("Allow", "GET, HEAD, OPTIONS")
.build();
}
@GET
@Produces(MediaType.TEXT_PLAIN)
@Path("/info")
@Operation(summary = "Build info & config of this service")
public String info() {
return """
tg-textapi v2 %s
textgrid host: %s
devmode: %s
""".formatted(version, textgridHost, launchMode.isDevOrTest());
}
}