diff --git a/rest/src/main/groovy/whelk/rest/api/Crud.groovy b/rest/src/main/groovy/whelk/rest/api/Crud.groovy index 8107c3e27b..4343a38dd6 100644 --- a/rest/src/main/groovy/whelk/rest/api/Crud.groovy +++ b/rest/src/main/groovy/whelk/rest/api/Crud.groovy @@ -57,6 +57,7 @@ class Crud extends HttpServlet { ConverterUtils converterUtils SiteSearch siteSearch + SearchFeed searchFeed Map> cachedFetches = [:] @@ -92,6 +93,7 @@ class Crud extends HttpServlet { targetVocabMapper = new TargetVocabMapper(jsonld, contextDoc.data) } + searchFeed = new SearchFeed(jsonld, whelk.locales) } protected void cacheFetchedResource(String resourceUri) { @@ -264,12 +266,24 @@ class Crud extends HttpServlet { private Object getNegotiatedDataBody(CrudGetRequest request, Object contextData, Map data, String uri) { if (!(request.getContentType() in [MimeTypes.JSON, MimeTypes.JSONLD])) { data[JsonLd.CONTEXT_KEY] = contextData + if ((request.getContentType() in [MimeTypes.ATOM])) { + var feedId = getFeedId(data, uri) + return searchFeed.represent(feedId, data) + } return converterUtils.convert(data, uri, request.getContentType()) } else { return data } } + String getFeedId(Object data, String uri) { + var searchPath = uri + if (data instanceof Map) { + searchPath = (String) data[JsonLd.ID_KEY] + } + return "${whelk.applicationId}${searchPath.substring(1)}" + } + private static Map frameRecord(Document document) { return JsonLd.frame(document.getCompleteId(), document.data) } diff --git a/rest/src/main/groovy/whelk/rest/api/CrudGetRequest.groovy b/rest/src/main/groovy/whelk/rest/api/CrudGetRequest.groovy index 9df21391a1..551a3e2543 100644 --- a/rest/src/main/groovy/whelk/rest/api/CrudGetRequest.groovy +++ b/rest/src/main/groovy/whelk/rest/api/CrudGetRequest.groovy @@ -20,7 +20,7 @@ class CrudGetRequest { private CrudGetRequest(HttpServletRequest request) { this.request = request parsePath(getPath()) - contentType = getBestContentType(getAcceptHeader(request), dataLeaf) + contentType = getBestContentType(getAcceptHeader(request), dataLeaf ?: resourceId) lens = parseLens(request) profile = parseProfile(request) } diff --git a/rest/src/main/groovy/whelk/rest/api/CrudUtils.groovy b/rest/src/main/groovy/whelk/rest/api/CrudUtils.groovy index e0f118d730..9bac713fc2 100644 --- a/rest/src/main/groovy/whelk/rest/api/CrudUtils.groovy +++ b/rest/src/main/groovy/whelk/rest/api/CrudUtils.groovy @@ -20,6 +20,7 @@ class CrudUtils { final static MediaType TRIG = MediaType.parse(MimeTypes.TRIG) final static MediaType RDFXML = MediaType.parse(MimeTypes.RDF) final static MediaType N3 = MediaType.parse(MimeTypes.N3) + final static MediaType ATOM = MediaType.parse(MimeTypes.ATOM) static final Map ALLOWED_MEDIA_TYPES_BY_EXT = [ '': [JSONLD, JSON], @@ -29,7 +30,8 @@ class CrudUtils { 'ttl': [TURTLE], 'rdf': [RDFXML], 'xml': [RDFXML], - 'n3': [N3] + 'n3': [N3], + 'atom': [ATOM], ] static Map EXTENSION_BY_MEDIA_TYPE = [:] @@ -43,7 +45,7 @@ class CrudUtils { } } - static final List ALLOWED_MEDIA_TYPES = [JSON, JSONLD, TRIG, TURTLE, RDFXML, N3] + static final List ALLOWED_MEDIA_TYPES = [JSON, JSONLD, TRIG, TURTLE, RDFXML, N3, ATOM] static String getBestContentType(String acceptHeader, String resourcePath) { def desired = parseAcceptHeader(acceptHeader) diff --git a/rest/src/main/groovy/whelk/rest/api/MimeTypes.groovy b/rest/src/main/groovy/whelk/rest/api/MimeTypes.groovy index 0386423893..1c8456956d 100644 --- a/rest/src/main/groovy/whelk/rest/api/MimeTypes.groovy +++ b/rest/src/main/groovy/whelk/rest/api/MimeTypes.groovy @@ -7,4 +7,5 @@ class MimeTypes { static final RDF = "application/rdf+xml" static final JSON = "application/json" static final N3 = "text/n3" + static final ATOM = "application/atom+xml" } diff --git a/rest/src/main/groovy/whelk/rest/api/SearchFeed.groovy b/rest/src/main/groovy/whelk/rest/api/SearchFeed.groovy new file mode 100644 index 0000000000..9cec2bda54 --- /dev/null +++ b/rest/src/main/groovy/whelk/rest/api/SearchFeed.groovy @@ -0,0 +1,180 @@ +package whelk.rest.api + +import groovy.transform.CompileStatic +import static groovy.transform.TypeCheckingMode.SKIP + +import groovy.xml.MarkupBuilder +import groovy.xml.StreamingMarkupBuilder + +import whelk.JsonLd +import static whelk.JsonLd.ID_KEY +import static whelk.JsonLd.TYPE_KEY +import static whelk.JsonLd.REVERSE_KEY +import static whelk.JsonLd.asList + +@CompileStatic +class SearchFeed { + + static final String BULLET_SEP = " • " + + JsonLd jsonld + List locales + + Set skipKeys = [ID_KEY, REVERSE_KEY, 'meta', 'reverseLinks'] as Set + Set skipDetails = skipKeys + ([TYPE_KEY, 'commentByLang'] as Set) + + SearchFeed(JsonLd jsonld, List locales) { + this.jsonld = jsonld + this.locales = locales + } + + @CompileStatic(SKIP) + String represent(String feedId, Object searchResults) { + var lastMod = searchResults.items?[0]?.meta?.modified + var feedTitle = buildTitle(searchResults) + return new StreamingMarkupBuilder().bind { mb -> + feed(xmlns: 'http://www.w3.org/2005/Atom') { + title(feedTitle) + id(feedId) + link(rel: 'self', href: searchResults[ID_KEY]) + for (rel in ['next', 'prev', 'first', 'last']) { + def ref = searchResults[rel] + if (ref) { + link(rel: rel, href: ref[ID_KEY]) + } + } + if (lastMod) updated(lastMod) + for (item in searchResults.items) { + entry { + id(item[ID_KEY]) + link(rel: 'alternate', type: 'text/html', href: item[ID_KEY]) + updated(item.meta.modified) + title(toChipString(item)) + summary(type: 'xhtml') { + toEntryCard(mb, item) + } + content(href: item[ID_KEY]) + } + } + } + }.toString() + } + + @CompileStatic(SKIP) + String buildTitle(Map searchResults) { + var title = getByLang((Map) searchResults['titleByLang']) + def params = searchResults.search?.mapping?.findResults { + if (it.value !instanceof Boolean) { + return toValueString(it.value ?: it.object, skipDetails) + } + } + if (params) { + return title + ': ' + params.join(' & ') + } else { + return title + } + } + + @CompileStatic(SKIP) + void toEntryCard(mb, Map item) { + mb.div(xmlns: 'http://www.w3.org/1999/xhtml') { + asList(item.meta?.hasChangeNote).each { note -> + p { b(toChipString(note)) } + } + for (kv in item) { + div { + if (kv.key !in skipKeys) { + var label = getLabelFor(kv.key) + var values = getValues(kv.value, kv.key) + if (label && values) { + span(label + ": ") + span { + values.eachWithIndex { v, i -> + if (i > 0) { + span(", " + v) + } else { + span(v) + } + } + } + } + } + } + } + } + } + + String toChipString(Object item) { + if (item instanceof Map) { + def chip = jsonld.toChip(item) + return toValueString(chip) + } else if (item == null) { + return "" + } else { + return item.toString() + } + } + + String toValueString( Object o, Set skipKeys=skipKeys) { + var sb = new StringBuilder() + buildValueString(sb, o, skipKeys) + return sb.toString() + } + + void buildValueString(StringBuilder sb, Object o, Set skipKeys=skipKeys) { + if (o instanceof List) { + for (v in o) buildValueString(sb, v, skipKeys) + } else if (o instanceof Map) { + for (kv in o) { + if (kv.key !in skipKeys) { + buildValueString(sb, getValues(kv.value, (String) kv.key), skipKeys) + } + } + } else { + if (sb.size() > 0) sb.append(BULLET_SEP) + sb.append(o.toString()) + } + } + + List getValues(Object o, String viaKey) { + if (viaKey == TYPE_KEY || jsonld.isVocabTerm(viaKey)) { + return asList(o).collect { getLabelFor((String) it) } + } else if (jsonld.isLangContainer(jsonld.context[viaKey])) { + return (List) asList(o).findResults { getByLang((Map) it) } + } else { + return (List) asList(o).findResults { toChipString(it) ?: null } + } + } + + String getLabelFor(String key) { + String lookup = key == TYPE_KEY ? 'rdf:type' : key + def term = jsonld.vocabIndex[lookup] + if (term instanceof Map) { + def byLang = term.get('labelByLang') + if (byLang instanceof Map) { + String s = getByLang(byLang) + if (s) { + return s[0].toUpperCase() + s.substring(1) + } + } + } + return key + } + + String getByLang(Map byLang) { + for (lang in locales) { + if (lang in byLang) { + def o = byLang[lang] + if (o instanceof String) { + return o + } else if (o instanceof List && o.size() > 0) { + return o.get(0).toString() + } + } + } + for (value in byLang.values()) { + return value + } + return null + } +} diff --git a/rest/src/main/groovy/whelk/rest/api/SearchUtils.groovy b/rest/src/main/groovy/whelk/rest/api/SearchUtils.groovy index 499a7f9e1f..6d0e1bfa78 100644 --- a/rest/src/main/groovy/whelk/rest/api/SearchUtils.groovy +++ b/rest/src/main/groovy/whelk/rest/api/SearchUtils.groovy @@ -740,6 +740,12 @@ class SearchUtils { termKey = stripPrefix(termKey, ESQuery.AND_PREFIX) termKey = stripPrefix(termKey, ESQuery.OR_PREFIX) + if (termKey.startsWith(ESQuery.EXISTS_PREFIX)) { + termKey = stripPrefix(termKey, ESQuery.EXISTS_PREFIX) + valueProp = 'value' + value = ESQuery.parseBoolean(termKey, val) + } + result << [ 'variable': param, 'predicate': lookup.chip(termKey), diff --git a/rest/src/main/groovy/whelk/rest/api/SiteSearch.groovy b/rest/src/main/groovy/whelk/rest/api/SiteSearch.groovy index b6f9e86809..43eab1148f 100644 --- a/rest/src/main/groovy/whelk/rest/api/SiteSearch.groovy +++ b/rest/src/main/groovy/whelk/rest/api/SiteSearch.groovy @@ -107,12 +107,18 @@ class SiteSearch { if (!queryParameters['_statsrepr'] && searchSettings['statsindex']) { queryParameters.put('_statsrepr', [mapper.writeValueAsString(searchSettings['statsindex'])] as String[]) } - return toDataIndexDescription(appsIndex["${activeSite}data" as String], queryParameters) + var appDesc = appsIndex["${activeSite}data" as String] + return toDataIndexDescription(appDesc, queryParameters) } else { if (!queryParameters['_statsrepr'] && searchSettings['statsfind']) { queryParameters.put('_statsrepr', [mapper.writeValueAsString(searchSettings['statsfind'])] as String[]) } - return search.doSearch(queryParameters) + var results = search.doSearch(queryParameters) + + var appDesc = appsIndex["${activeSite}find" as String] + results['titleByLang'] = appDesc['titleByLang'] + + return results } }