diff --git a/backend/readit/settings.py b/backend/readit/settings.py index 20b99423d..780d1d4fc 100644 --- a/backend/readit/settings.py +++ b/backend/readit/settings.py @@ -80,6 +80,7 @@ 'staff', 'ontology', 'nlp_ontology', + 'source_ontology', 'items', 'sources', 'register', diff --git a/backend/readit/urls.py b/backend/readit/urls.py index 4feba03c8..9eb8381f1 100644 --- a/backend/readit/urls.py +++ b/backend/readit/urls.py @@ -23,6 +23,7 @@ from staff import STAFF_ROUTE from ontology import ONTOLOGY_ROUTE from nlp_ontology import NLP_ONTOLOGY_ROUTE +from source_ontology import SOURCE_ONTOLOGY_ROUTE from sources import SOURCES_ROUTE from items import ITEMS_ROUTE from items.views import SemanticQueryViewSet @@ -49,6 +50,7 @@ path(STAFF_ROUTE, include('staff.urls')), path(ONTOLOGY_ROUTE, include('ontology.urls')), path(NLP_ONTOLOGY_ROUTE, include('nlp_ontology.urls')), + path(SOURCE_ONTOLOGY_ROUTE, include('source_ontology.urls')), path(SOURCES_ROUTE, include('sources.urls')), path(ITEMS_ROUTE, include('items.urls')), path(SPARQL_ROUTE, include('sparql.urls')), diff --git a/backend/requirements.in b/backend/requirements.in index 3a56f63f5..8e70a9225 100644 --- a/backend/requirements.in +++ b/backend/requirements.in @@ -14,3 +14,4 @@ psycopg2 pytest pytest-django pytest-xdist +python-dateutil diff --git a/backend/requirements.txt b/backend/requirements.txt index e30e07c99..72f09a093 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -105,6 +105,8 @@ pytest-forked==1.0.2 # via pytest-xdist pytest-xdist==1.29.0 # via -r requirements.in +python-dateutil==2.8.2 + # via -r requirements.in python3-openid==3.1.0 # via django-allauth pytz==2019.2 @@ -134,6 +136,7 @@ six==1.12.0 # isodate # packaging # pytest-xdist + # python-dateutil soupsieve==1.9.3 # via beautifulsoup4 sqlparse==0.4.2 diff --git a/backend/source_ontology/ReaditSourceOntology.xml b/backend/source_ontology/ReaditSourceOntology.xml new file mode 100644 index 000000000..027bb1332 --- /dev/null +++ b/backend/source_ontology/ReaditSourceOntology.xml @@ -0,0 +1,1182 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + language + + + + + + + + + + source type + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Author (or other creator) of the source. + author + + + + + + + + The source content. For now, only plaintext content is supported. + content + + + + + + + + + + + + Creation date of the source. Allowed values are dates or datetimes in ISO8601 format, as well as free-form text (e.g. "first half the 18th century"). + creation date + + + + + + + + + + + + Publication date of the source. Allowed values are dates or datetimes in ISO8601 format, as well as free-form text (e.g. "first half of the 18th century"). + publication date + + + + + + + + + + + + Retrieval date of the source. Allowed values are dates or datetimes in ISO8601 format as well as free-form text (e.g. "this week"). + retrieval date + + + + + + + + + + + Upload date of the source. This is automatically set on uploading, in ISO8601 format. + upload date + + + + + + + + + + + Editor of the source. + editor + + + + + + + + + + + + Encoding format of the source. This is set programatically upon upload, determined by sourceType. + encoding format (MIME type) + + + + + + + + + + + + + + + + + + + + + image URL + + + + + + + + + + If True, this source is accessible by all users. If False, only the uplaodhas access. + public + + + + + + + + + + + Publisher of the source. + publisher + + + + + + + + + + Catch-all term indicating a collection of any kind that this source belongs to. Examples: repository, location, folio. + repository + + + + + + + + + + + thumbnail URL + + + + + + + + + + + Title of the source. + title + + + + + + + + + + + External URL of the source. + url + + + + + + + + + + + + + ISO6391 Language + + + + + + + + + Article + + + + + + + + + + + + + + + Review + + + + + + + + + Social Media Posting + + + + + + + + + Web Content + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/plain + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Advertisment + TFO01 + + + TFO02 + Book + + + TFO03 + Broadsheet + + + TFO04 + Codex + + + TFO05 + Dazibaos + + + TFO06 + Digitized material + + + Epitaphs/Inscriptions + TFO07 + + + TFO08 + Fax + + + Festschrift (mélanges / liber amicorum) + TFO09 + + + TFO10 + Form + + + Graffito + TFO11 + + + Handbill + TFO12 + + + TFO13 + Journals + + + Leaflets + TFO14 + + + TFO15 + Letter + + + TFO16 + Magazines + + + Newspaper + TFO17 + + + Pamphlet + TFO18 + + + TFO19 + Photocopies + + + TFO20 + Poster + + + TFO21 + Programmes + + + Roll + TFO22 + + + Samizdats + TFO23 + + + Serial/Periodical + TFO24 + + + TFO25 + Sheet + + + TFO26 + Ticket + + + Unknown + TFO27 + + + + + + + diff --git a/backend/source_ontology/__init__.py b/backend/source_ontology/__init__.py new file mode 100644 index 000000000..cad20473d --- /dev/null +++ b/backend/source_ontology/__init__.py @@ -0,0 +1,4 @@ +from .constants import SOURCE_ONTOLOGY_NS, SOURCE_ONTOLOGY_ROUTE +from rdflib.namespace import Namespace + +namespace = Namespace(SOURCE_ONTOLOGY_NS) diff --git a/backend/source_ontology/apps.py b/backend/source_ontology/apps.py new file mode 100644 index 000000000..92119d0c0 --- /dev/null +++ b/backend/source_ontology/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class SourceOntologyConfig(AppConfig): + name = 'source_ontology' diff --git a/backend/source_ontology/conftest.py b/backend/source_ontology/conftest.py new file mode 100644 index 000000000..e2f19edce --- /dev/null +++ b/backend/source_ontology/conftest.py @@ -0,0 +1,46 @@ +import pytest +from rdf.ns import DCTERMS, ISO6391, RDF, XSD, SCHEMA +from rdf.utils import graph_from_triples +from rdflib import Literal +from sources import namespace as SOURCE +from vocab import namespace as VOCAB +from staff import namespace as STAFF + +from . import namespace as SRCONT + + +@pytest.fixture +def old_sources(): + return graph_from_triples(( + (SOURCE['1'], RDF.type, VOCAB.Source), + (SOURCE['1'], RDF.type, SCHEMA.Book), + (SOURCE['1'], DCTERMS.created, Literal( + '2021-09-26T17:52+00:00', datatype=XSD.dateTime)), + (SOURCE['1'], DCTERMS.creator, STAFF.JaneDoe), + (SOURCE['1'], SCHEMA.author, Literal( + 'Blessington, Margaret Gardiner')), + (SOURCE['1'], SCHEMA.datePublished, Literal( + '1841-01-01', datatype=XSD.date)), + (SOURCE['1'], SCHEMA.inLanguage, ISO6391.en), + (SOURCE['1'], SCHEMA.name, Literal('The Idler in France')) + )) + + +@pytest.fixture +def new_sources(): + return graph_from_triples(( + (SOURCE['1'], RDF.type, SRCONT.Source), + (SOURCE['1'], RDF.type, SRCONT.PlainTextSource), + (SOURCE['1'], RDF.type, SRCONT.TFO02_Book), + (SOURCE['1'], SRCONT.dateUploaded, Literal( + '2021-09-26T17:52+00:00', datatype=XSD.dateTime)), + (SOURCE['1'], DCTERMS.creator, STAFF.JaneDoe), + (SOURCE['1'], SRCONT.author, Literal( + 'Blessington, Margaret Gardiner')), + (SOURCE['1'], SRCONT.datePublished, Literal( + '1841-01-01', datatype=XSD.date)), + (SOURCE['1'], SRCONT.language, ISO6391.en), + (SOURCE['1'], SRCONT.title, Literal('The Idler in France')), + (SOURCE['1'], SRCONT.public, Literal('false', datatype=XSD.boolean)), + (SOURCE['1'], SRCONT.encodingFormat, Literal('text/plain')) + )) diff --git a/backend/source_ontology/constants.py b/backend/source_ontology/constants.py new file mode 100644 index 000000000..22f68a99c --- /dev/null +++ b/backend/source_ontology/constants.py @@ -0,0 +1,11 @@ +from django.conf import settings +import os + +SOURCE_ONTOLOGY_ROUTE = 'source-ontology' +SOURCE_ONTOLOGY_NS = '{}{}#'.format( + settings.RDF_NAMESPACE_ROOT, 'source-ontology') + +SOURCE_ONTOLOGY_FILE = os.path.join( + settings.BASE_DIR, 'source_ontology', 'ReaditSourceOntology.xml') +SOURCE_FORMAT = 'xml' +SOURCE_PREFIX = 'https://read-it.acc.hum.uu.nl/source-ontology#' diff --git a/backend/source_ontology/fixture.py b/backend/source_ontology/fixture.py new file mode 100644 index 000000000..f171f2274 --- /dev/null +++ b/backend/source_ontology/fixture.py @@ -0,0 +1,14 @@ +import re +from rdflib import Graph +from .constants import (SOURCE_FORMAT, SOURCE_ONTOLOGY_FILE, + SOURCE_ONTOLOGY_NS, SOURCE_PREFIX) + + +def canonical_graph(): + with open(SOURCE_ONTOLOGY_FILE) as f: + content = f.read() + replaced_source = re.sub( + r'{}'.format(SOURCE_PREFIX), SOURCE_ONTOLOGY_NS, content) + g = Graph() + g.parse(data=replaced_source, format=SOURCE_FORMAT) + return g diff --git a/backend/source_ontology/graph.py b/backend/source_ontology/graph.py new file mode 100644 index 000000000..d2c27d245 --- /dev/null +++ b/backend/source_ontology/graph.py @@ -0,0 +1,7 @@ +from django.conf import settings +from rdflib import Graph +from .constants import SOURCE_ONTOLOGY_NS + + +def graph(): + return Graph(settings.RDFLIB_STORE, SOURCE_ONTOLOGY_NS) diff --git a/backend/source_ontology/rdf_migrations.py b/backend/source_ontology/rdf_migrations.py new file mode 100644 index 000000000..ae816d648 --- /dev/null +++ b/backend/source_ontology/rdf_migrations.py @@ -0,0 +1,119 @@ +from ontology.rdf_migrations import replace_objects +from rdf.migrations import RDFMigration, on_add, on_present +from rdf.ns import OWL, RDF, SCHEMA, UNKNOWN, XSD +from rdflib import Literal, URIRef +from rdflib.namespace import DCTERMS +from sources.graph import graph as sources_graph +from vocab import namespace as VOCAB + +from . import namespace as SOURCE_ONTOLOGY +from .fixture import canonical_graph +from .graph import graph + +REPLACE_PREDICATE_UPDATE = ''' + DELETE { + ?s ?before ?o . + } + INSERT { + ?s ?after ?o . + } + WHERE { + ?s ?before ?o . + } +''' + +REPLACE_SOURCE_DEFINITION_UPDATE = ''' + DELETE { + ?s ?type ?vocabsource . + } + INSERT { + ?s ?type ?ontologysource ; + ?type ?plaintextsource ; + ?format "text/plain" . + } + WHERE { + ?s ?type ?vocabsource + } +''' + + +def replace_predicate_sparql(before, after, input_graph=None): + # Replace predicates in graph (default: source) + context = input_graph if input_graph else sources_graph() + context.update(REPLACE_PREDICATE_UPDATE, initBindings={ + 'before': before, + 'after': after + }) + + +class Migration(RDFMigration): + actual = staticmethod(graph) + desired = staticmethod(canonical_graph) + + # changed predicates + before_after_predicates = ( + (SCHEMA.name, SOURCE_ONTOLOGY.title), + (SCHEMA.editor, SOURCE_ONTOLOGY.editor), + (SCHEMA.author, SOURCE_ONTOLOGY.author), + (SCHEMA.publisher, SOURCE_ONTOLOGY.publisher), + (SCHEMA.inLanguage, SOURCE_ONTOLOGY.language), + (OWL.sameAs, SOURCE_ONTOLOGY.url), + (SCHEMA.datePublished, SOURCE_ONTOLOGY.datePublished), + (DCTERMS.created, SOURCE_ONTOLOGY.dateUploaded) + ) + + # new predicates with default + # if no default, no triples are added + new_predicates = ( + (SOURCE_ONTOLOGY.public, Literal('true', datatype=XSD.boolean)), + (SOURCE_ONTOLOGY.dateCreated, None), + (SOURCE_ONTOLOGY.dateRetrieved, None), + (SOURCE_ONTOLOGY.repository, None), + ) + + # source type mapping + sourcetype_mapping = ( + (SCHEMA.Book, SOURCE_ONTOLOGY.TFO02_Book), + # no suitable replacements for following source types + (SCHEMA.Article, SCHEMA.Article), + (SCHEMA.Review, SCHEMA.Review), + (SCHEMA.SocialMediaPosting, SCHEMA.SocialMediaPosting), + (SCHEMA.WebContent, SCHEMA.WebContent), + (URIRef(UNKNOWN), SOURCE_ONTOLOGY.TFO27_Unknown) + ) + + # added + added = ['encodingformat', ] + changed = ['sourceType', ] + + @on_add(SOURCE_ONTOLOGY.title) + def source_ontology_changes(self, actual, desired): + for before, after in self.before_after_predicates: + replace_predicate_sparql(before, after) + + @on_add(SOURCE_ONTOLOGY.public) + def source_ontology_additions(self, actual, desired): + # add new predicates (only public has a default and needs to be added) + querystring = 'INSERT {?s ?public ?pubdefault} WHERE {?s ?p ?o}' + sources_graph().update(querystring, initBindings={ + 'public': SOURCE_ONTOLOGY.public, + 'pubdefault': Literal('false', datatype=XSD.boolean) + }) + + @on_add(SOURCE_ONTOLOGY.Source) + def source_ontology_types(self, actual, desired): + # map old source types to new + for before, after in self.sourcetype_mapping: + replace_objects(before, after, sources_graph()) + + # add encodingFormat and PlainTextSource instance for each source + sources_graph().update( + REPLACE_SOURCE_DEFINITION_UPDATE, + initBindings={ + 'vocabsource': VOCAB.Source, + 'ontologysource': SOURCE_ONTOLOGY.Source, + 'plaintextsource': SOURCE_ONTOLOGY.PlainTextSource, + 'format': SOURCE_ONTOLOGY.encodingFormat, + 'type': RDF.type + } + ) diff --git a/backend/source_ontology/rdf_migrations_test.py b/backend/source_ontology/rdf_migrations_test.py new file mode 100644 index 000000000..4d2b40f93 --- /dev/null +++ b/backend/source_ontology/rdf_migrations_test.py @@ -0,0 +1,26 @@ +from .rdf_migrations import Migration +from rdf.utils import graph_from_triples +from sources.graph import graph as sources_graph + + +def test_source_ontology_migrations(old_sources, new_sources): + + g = graph_from_triples(old_sources) + + s = sources_graph() + s.remove((None, None, None)) + assert len(s) == 0 + s += g + + m = Migration() + + m.source_ontology_additions(None, None) + m.source_ontology_changes(None, None) + m.source_ontology_types(None, None) + + exp_g = graph_from_triples(new_sources) + + from pprint import pprint + pprint(list(s ^ exp_g)) + + assert len(s ^ exp_g) == 0 diff --git a/backend/source_ontology/urls.py b/backend/source_ontology/urls.py new file mode 100644 index 000000000..1fbf3e246 --- /dev/null +++ b/backend/source_ontology/urls.py @@ -0,0 +1,8 @@ +from django.urls import path +from rest_framework.urlpatterns import format_suffix_patterns + +from .views import ListSourceOntology + +urlpatterns = format_suffix_patterns([ + path('', ListSourceOntology.as_view()), +]) diff --git a/backend/source_ontology/views.py b/backend/source_ontology/views.py new file mode 100644 index 000000000..f6afbfc36 --- /dev/null +++ b/backend/source_ontology/views.py @@ -0,0 +1,9 @@ +from rdf.views import RDFView +from .graph import graph + + +class ListSourceOntology(RDFView): + """ List the full ontology in RDF. """ + + def graph(self): + return graph() diff --git a/backend/sources/utils.py b/backend/sources/utils.py index 3e68009f5..98f3a7a98 100644 --- a/backend/sources/utils.py +++ b/backend/sources/utils.py @@ -1,3 +1,6 @@ +from dateutil import parser +from rdflib import Literal +from rdf.ns import XSD from typing import Dict @@ -16,6 +19,32 @@ def get_serial_from_subject(subject): return str(subject).split('/')[-1] +def has_time(dt): + # Determine if a datetime object has a defined time + must_be_zero = ['hour', 'minute', 'second', 'microsecond'] + return any([getattr(dt, x) != 0 for x in must_be_zero]) + + +def parse_isodate(datestring): + try: + dt = parser.isoparse(datestring) + if has_time(dt): + return Literal(dt, datatype=XSD.dateTime) + return Literal(dt, datatype=XSD.date) + except ValueError: + return Literal(datestring) + + +def literal_from_datestring(datestr, ignoretz=True): + # Attempts to parse string as date/datetime + # Returns adequatly formatted Literal + try: + dt = parser.parse(timestr=datestr, ignoretz=ignoretz, dayfirst=True) + if has_time(dt): + return Literal(dt, datatype=XSD.dateTime) + return Literal(dt, datatype=XSD.date) + except parser.ParserError: + return Literal(datestr) def optional_localized(document: Dict) -> Dict: '''Optionally adds text_ field to elasticsearch index Skipped if language = 'other' diff --git a/backend/sources/utils_test.py b/backend/sources/utils_test.py index d6f3e9b0b..bf1dd6c73 100644 --- a/backend/sources/utils_test.py +++ b/backend/sources/utils_test.py @@ -1,4 +1,25 @@ -from sources.utils import optional_localized +from datetime import datetime + +from dateutil import parser +from rdf.ns import XSD +from rdflib import Literal +from sources.utils import literal_from_datestring, optional_localized + + +def test_dateparser(): + test_dates = [ + ('04/12/2021 8:40', datetime(2021, 12, 4, 8, 40), + Literal(datetime(2021, 12, 4, 8, 40), datatype=XSD.dateTime)), + ('12/04/2021', datetime(2021, 4, 12), + Literal(datetime(2021, 4, 12), datatype=XSD.date)), + ('4th of may 2021', datetime(2021, 5, 4), Literal( + datetime(2021, 5, 4), datatype=XSD.date)) + ] + + for string, dt, literal in test_dates: + parsed = parser.parse(string, ignoretz=True, dayfirst=True) + assert parsed == dt + assert literal_from_datestring(string) == literal def test_optional_localized(): diff --git a/backend/sources/views.py b/backend/sources/views.py index 6d27bfe77..df4de3e2e 100644 --- a/backend/sources/views.py +++ b/backend/sources/views.py @@ -4,6 +4,7 @@ import html import functools import operator +import ast import requests from requests.utils import quote @@ -15,6 +16,7 @@ from rest_framework.permissions import IsAuthenticated, IsAdminUser from rest_framework.response import Response from rest_framework.status import * +from rest_framework.mixins import UpdateModelMixin from rest_framework.parsers import MultiPartParser from rest_framework.exceptions import ValidationError, NotFound from rest_framework.reverse import reverse @@ -35,10 +37,11 @@ from . import namespace as ns from .constants import SOURCES_NS from .graph import graph as sources_graph -from .utils import get_media_filename, get_serial_from_subject, optional_localized +from .utils import (get_serial_from_subject, optional_localized, parse_isodate) from .models import SourcesCounter from .permissions import UploadSourcePermission, DeleteSourcePermission from .tasks import poll_automated_annotations +from source_ontology import namespace as source_ontology es = Elasticsearch( hosts=[{'host': settings.ES_HOST, 'port': settings.ES_PORT}]) @@ -46,6 +49,10 @@ # Get sources logger for logging on server logger = logging.getLogger(__name__) +LITERALS = ('title', 'author', 'editor', 'publisher', 'repository', 'url') +URIS = ('sourceType',) +DATES = ('datePublished', 'dateCreated', 'dateRetrieved') + SELECT_SOURCES_QUERY_START = ''' CONSTRUCT { ?id ?p ?o. @@ -118,9 +125,9 @@ def inject_fulltext(input, inline, request): } }, index=settings.ES_ALIASNAME) f = result['hits']['hits'][0]['_source']['text'] - text_triples.add((s, SCHEMA.text, Literal(f))) + text_triples.add((s, source_ontology.content, Literal(f))) else: - text_triples.add((s, vocab.fullText, URIRef(reverse( + text_triples.add((s, source_ontology.fullText, URIRef(reverse( 'sources:fulltext', kwargs={'serial': serial}, request=request, @@ -244,7 +251,10 @@ def construct_highlight_graph(self, highlights): class SourcesAPISingular(RDFResourceView): """ API endpoint for fetching individual subjects. """ - permission_classes = [IsAuthenticated, DeleteSourcePermission] + permission_classes = [ + IsAuthenticated, + DeleteSourcePermission + ] def graph(self): return sources_graph() @@ -252,11 +262,76 @@ def graph(self): def get_graph(self, request, **kwargs): return inject_fulltext(super().get_graph(request, **kwargs), True, request) + def patch(self, request, format=None, **kwargs): + """ given the request data, add triples which don't exist, + or amend triples which do exist (both in Fuseki and, if applicable, in Elasticearch). + We assume that only one author, one language, + one publication date etc. can be set at any one time. + request.body: json of the form + { + 'author': 'Guybrush Threepwood', + 'title': 'Why I'm the greatest pirate ever', + ... + } + Only contains the *changes* wrt the previously saved source metadata. + The source text itself *cannot* be changed in this request. + """ + data = ast.literal_eval(request.body.decode('utf-8')) + source_uri = get_source_uri(request) + existing_graph = self.graph() + new = format_source_data(URIRef(source_uri), data) + for triple in new: + query = (triple[0], triple[1], None) + if query in existing_graph: + existing_triple = existing_graph.triples(query) + existing_graph -= existing_triple + existing_graph += graph_from_triples(tuple(new)) + serial = get_serial_from_subject(source_uri) + self.update_elastic(serial, data) + resulting_graph = graph_from_triples( + existing_graph.triples((URIRef(source_uri), None, None)) + ) + return Response(resulting_graph, HTTP_200_OK) + + def update_elastic(self, serial, data): + ''' update data in Elasticsearch ''' + keys = ['author', 'title', 'language', 'public'] + doc = {key: data.get(key) for key in keys if key in data} + if 'public' in doc: + doc['public'] = (doc['public'] == 'public') + if 'language' in doc: + result = es.search( + index=settings.ES_ALIASNAME, + body={"query": { + "term": { + "id": serial + } + }} + ) + existing = result['hits']['hits'][0] + original_language = existing['_source']['language'] + set_language = doc['language'] + if set_language != original_language: + doc['text_{}'.format(original_language)] = None + if set_language != 'other': + doc['text_'.format(set_language)] = existing['_source']['text'] + if not doc: + return None + body={ + "doc": doc + } + try: + es.update( + index=settings.ES_ALIASNAME, + id=serial, + body=body + ) + except Exception as e: + logger.error(e) + def delete(self, request, format=None, **kwargs): - source_uri = request.build_absolute_uri(request.path) - bindings = {'source': URIRef(source_uri)} - if not self.graph().query(SOURCE_EXISTS_QUERY, initBindings=bindings): - raise NotFound('Source \'{}\' not found'.format(source_uri)) + source_uri = get_source_uri(request) + bindings = self.get_source_bindings(source_uri) conjunctive = get_conjunctive_graph() conjunctive.update( SOURCE_DELETE_QUERY, initNs=PREFIXES, initBindings=bindings @@ -272,6 +347,22 @@ def delete(self, request, format=None, **kwargs): ) return Response(Graph(), HTTP_204_NO_CONTENT) + def get_source_bindings(self, source_uri): + bindings = {'source': URIRef(source_uri)} + if not self.graph().query(SOURCE_EXISTS_QUERY, initBindings=bindings): + raise NotFound('Source \'{}\' not found'.format(source_uri)) + return bindings + +def source_valid(data): + is_valid = True + missing_fields = [] + required_fields = ['title', 'author', + 'source', 'language', 'sourceType', 'datePublished', 'public'] + for f in required_fields: + if not data.get(f, False): + is_valid = False + missing_fields.append(f) + return is_valid, missing_fields def source_fulltext(request, serial, query=None): """ API endpoint for fetching the full text of a single source. """ @@ -287,6 +378,9 @@ def source_fulltext(request, serial, query=None): else: raise NotFound +def get_source_uri(request): + source_uri = request.build_absolute_uri() + return source_uri def select_sources_elasticsearch(results): endpoint = sources_graph() @@ -309,7 +403,7 @@ class AddSource(RDFResourceView): permission_classes = [IsAuthenticated, UploadSourcePermission] parser_classes = [MultiPartParser] - def store(self, source_file, source_id, source_language, author, title): + def store(self, source_file, source_id, source_language, author, title, public): """ sanitize and store the text in an Elasticsearch index return the sanitized text """ @@ -324,89 +418,10 @@ def store(self, source_file, source_id, source_language, author, title): 'author': author, 'title': title, 'text': text, + 'public': public })) return text - def is_valid(self, data): - is_valid = True - missing_fields = [] - required_fields = ['title', 'author', - 'source', 'language', 'type', 'pubdate'] - - for f in required_fields: - if not data.get(f, False): - is_valid = False - missing_fields.append(f) - - return is_valid, missing_fields - - def resolve_language(self, input_language): - known_languages = { - 'en': ISO6391.en, - 'de': ISO6391.de, - 'nl': ISO6391.nl, - 'fr': ISO6391.fr, - 'it': ISO6391.it, - 'cs': ISO6391.cs - } - result = known_languages.get(input_language) - if result: - return result - else: - return UNKNOWN - - def resolve_type(self, input_type): - known_types = { - 'book': SCHEMA.Book, - 'article': SCHEMA.Article, - 'review': SCHEMA.Review, - 'socialmediaposting': SCHEMA.SocialMediaPosting, - 'webcontent': SCHEMA.WebContent - } - result = known_types.get(input_type) - if result: - return result - else: - return UNKNOWN - - def parse_date(self, input_date): - dt = datetime.strptime(input_date, "%Y/%m/%d") - return dt.replace(tzinfo=timezone.utc) - - def get_required(self, new_subject, data): - return [ - (new_subject, RDF.type, vocab.Source), - (new_subject, RDF.type, URIRef(self.resolve_type(data['type']))), - (new_subject, SCHEMA.name, Literal(data['title'])), - (new_subject, SCHEMA.author, Literal(data['author'])), - (new_subject, SCHEMA.inLanguage, URIRef( - self.resolve_language(data['language']))), - (new_subject, SCHEMA.datePublished, Literal( - self.parse_date(data['pubdate']))) - ] - - def get_optional(self, new_subject, data): - literals = { - 'editor': SCHEMA.editor, - 'publisher': SCHEMA.publisher, - } - uris = { - 'url': OWL.sameAs - } - - optionals = [] - for l in literals: - value = data.get(l) - if value: - optionals.append((new_subject, literals[l], Literal(value))) - - for u in uris: - value = data.get(u) - if value: - optionals.append((new_subject, uris[u], URIRef(value))) - - return optionals - def query_automated_annotations(self, text, uploaded_file, uri): headers = {'Authorization': 'Token token={}'.format( settings.IRISA_TOKEN)} @@ -432,7 +447,7 @@ def query_automated_annotations(self, text, uploaded_file, uri): def post(self, request, format=None): data = request.data - is_valid, missing_fields = self.is_valid(data) + is_valid, missing_fields = source_valid(data) if not is_valid: raise ValidationError( detail="Missing fields: {}".format(", ".join(missing_fields))) @@ -444,19 +459,17 @@ def post(self, request, format=None): # store the file in ES index sanitized_text = self.store(data['source'], get_serial_from_subject(new_subject), - data['language'], data['author'], data['title']) + data['language'], data['author'], data['title'], data['public']=='public') self.query_automated_annotations( sanitized_text, data['source'], counter.__str__()) - # TODO: voor author en editor een instantie van SCHEMA.Person maken? Of iets uit CIDOC/ontologie? # create graph - triples = self.get_required(new_subject, data) - triples.extend(self.get_optional(new_subject, data)) + triples = format_source_data(new_subject, data) result = graph_from_triples(tuple(triples)) user, now = submission_info(request) result.add((new_subject, DCTERMS.creator, user)) - result.add((new_subject, DCTERMS.created, now)) + result.add((new_subject, source_ontology.dateUploaded, now)) # add to store full_graph = sources_graph() @@ -479,10 +492,47 @@ def construct_es_body(request): body = {"query": clause} return body - def get_number_search_results(request): body = construct_es_body(request) results = es.search(body=body, index=settings.ES_ALIASNAME, size=0) response = {'total_results': results['hits']['total'] ['value'], 'results_per_page': settings.RESULTS_PER_PAGE} return JsonResponse(response) + +def format_source_data(subject, data): + access = ('public',) + language = ('language',) + conversions = [ + (LITERALS, Literal), + (URIS, URIRef), + (DATES, parse_isodate), + (access, resolve_access), + (language, resolve_language), + ] + return [ + (subject, RDF.type, source_ontology.Source), + ] + [ + (subject, source_ontology[key], coerce(data[key])) + for terms, coerce in conversions + for key in terms if key in data + ] + +def resolve_language(input_language): + known_languages = { + 'en': ISO6391.en, + 'de': ISO6391.de, + 'nl': ISO6391.nl, + 'fr': ISO6391.fr, + 'it': ISO6391.it, + 'cs': ISO6391.cs + } + result = known_languages.get(input_language) + if result: + return result + else: + return URIRef(UNKNOWN) + +def resolve_access(value): + if value == 'public': + return Literal('true', datatype=XSD.boolean) + return Literal('false', datatype=XSD.boolean) diff --git a/backend/sparql/endpoints/source_ontology.py b/backend/sparql/endpoints/source_ontology.py new file mode 100644 index 000000000..9e6ce13ea --- /dev/null +++ b/backend/sparql/endpoints/source_ontology.py @@ -0,0 +1,25 @@ +from django.urls import path + +from source_ontology import SOURCE_ONTOLOGY_ROUTE +from source_ontology.graph import graph +from sparql.views import SPARQLQueryAPIView, SPARQLUpdateAPIView + + +class SourceOntologyQueryView(SPARQLQueryAPIView): + + def graph(self): + return graph() + + +class SourceOntologyUpdateView(SPARQLUpdateAPIView): + + def graph(self): + return graph() + + +SOURCE_ONTOLOGY_URLS = [ + path('{}/query'.format(SOURCE_ONTOLOGY_ROUTE), + NlpOntologyQueryView.as_view()), + path('{}/update'.format(SOURCE_ONTOLOGY_ROUTE), + NlpOntologyUpdateView.as_view()) +] diff --git a/frontend/package.json b/frontend/package.json index 67750b00c..9e47bb6dc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,100 +1,99 @@ { - "name": "READ-IT-frontend", - "description": "Public interface for READ-IT (frontend)", - "repository": "github:UUDigitalHumanitieslab/readit-interface", - "author": "UU Digital Humanities Lab", - "license": "cc-by-nc-sa 4.0", - "private": true, - "browserslist": [ - "defaults", - "not ie < 10" - ], - "dependencies": { - "@dhl-uu/jquery-promise": "^1.1.1", - "@rdfjs/serializer-jsonld-ext": "^3.0.0", - "@types/select2": "^4.0.53", - "async": "^3.2.2", - "backbone": "^1.4.0", - "backbone-fractal": "^1.1.0", - "backbone-machina": "^1.1.0", - "backbone.radio": "^2.0.0", - "bulma": "^0.7.1", - "bulma-accordion": "^2.0.1", - "bulma-tooltip": "^2.0.2", - "handlebars": "^4.7.7", - "handlebars-i18next": "^1.0.1", - "i18next": "^11.6.0", - "i18next-browser-languagedetector": "^2.2.3", - "jquery": "^3.5.1", - "jquery-validation": "^1.19.5", - "js-cookie": "^2.2.0", - "jsonld": "^6.0.0", - "lodash": "^4.17.21", - "node-sass": "^7.0.0", - "rangy": "^1.3.0", - "rdf-parse": "^2.0.0", - "select2": "^4.0.13", - "streamify-string": "^1.0.1" - }, - "devDependencies": { - "@babel/core": "^7.6.4", - "@babel/preset-env": "^7.6.3", - "@tsconfig/node12": "^1.0.9", - "@types/backbone": "^1.4.13", - "@types/backbone.radio": "^0.8.37", - "@types/jasmine": "^3.5.10", - "@types/jasmine-ajax": "^3.3.0", - "@types/jquery": "^3.3.5", - "@types/jquery.validation": "^1.16.6", - "@types/lodash": "^4.14.113", - "@types/node": "^12.7.5", - "@types/rangy": "^0.0.32", - "aliasify": "^2.1.0", - "autoprefixer": "^9.0.2", - "babelify": "^10.0.0", - "browserify": "^16.2.2", - "canvas": "2.7.0", - "chalk": "^2.4.2", - "cssnano": "^5.1.12", - "del": "^3.0.0", - "exorcist": "^2.0.0", - "exposify": "^0.5.0", - "fancy-log": "^1.3.3", - "glob": "^7.1.3", - "gulp": "4", - "gulp-browserify-watchify-glob": "^1.0.0-3", - "gulp-cached": "^1.1.1", - "gulp-cdnizer": "^2.0.0", - "gulp-changed": "^4.0.1", - "gulp-cli": "^2.0.1", - "gulp-connect": "^5.6.1", - "gulp-define-module": "^0.1.5", - "gulp-handlebars": "^5.0.2", - "gulp-hb": "^7.0.1", - "gulp-if": "^2.0.2", - "gulp-load-plugins": "^1.5.0", - "gulp-postcss": "^9.0.1", - "gulp-rename": "^1.4.0", - "gulp-sass": "^5.0.0", - "gulp-sourcemaps": "^2.6.4", - "gulp-uglify": "^3.0.1", - "handlebars-i18next-parser": "^1.0.2", - "http-proxy-middleware": "^0.19.0", - "i18next-parser": "^6.0.1", - "jasmine-ajax": "^4.0.0", - "jasmine-core": "~3.5.0", - "jasmine-terminal-reporter": "^1.0.3", - "jsdom": "^16.5.0", - "plugin-error": "^1.0.1", - "postcss": "^8.4.14", - "streamqueue": "^1.1.2", - "through2": "^3.0.1", - "ts-node": "^8.3.0", - "tsify": "^4.0.0", - "typescript": "^4.0.0", - "vinyl-buffer": "^1.0.1", - "vinyl-source-stream": "^2.0.0", - "watchify": "^3.11.0", - "yargs": "^12.0.1" - } + "name": "READ-IT-frontend", + "description": "Public interface for READ-IT (frontend)", + "repository": "github:UUDigitalHumanitieslab/readit-interface", + "author": "UU Digital Humanities Lab", + "license": "cc-by-nc-sa 4.0", + "private": true, + "browserslist": [ + "defaults", + "not ie < 10" + ], + "dependencies": { + "@dhl-uu/jquery-promise": "^1.1.1", + "@rdfjs/serializer-jsonld-ext": "^3.0.0", + "@types/select2": "^4.0.53", + "async": "^3.2.2", + "backbone": "^1.4.0", + "backbone-fractal": "^1.1.0", + "backbone-machina": "^1.1.0", + "backbone.radio": "^2.0.0", + "bulma": "^0.7.1", + "bulma-accordion": "^2.0.1", + "bulma-tooltip": "^2.0.2", + "handlebars": "^4.7.7", + "handlebars-i18next": "^1.0.1", + "i18next": "^11.6.0", + "i18next-browser-languagedetector": "^2.2.3", + "jquery": "^3.5.1", + "jquery-validation": "^1.19.5", + "js-cookie": "^2.2.0", + "lodash": "^4.17.21", + "node-sass": "^7.0.0", + "rangy": "^1.3.0", + "rdf-parse": "^2.0.0", + "select2": "^4.0.13", + "streamify-string": "^1.0.1" + }, + "devDependencies": { + "@babel/core": "^7.6.4", + "@babel/preset-env": "^7.6.3", + "@tsconfig/node12": "^1.0.9", + "@types/backbone": "^1.4.13", + "@types/backbone.radio": "^0.8.37", + "@types/jasmine": "^3.5.10", + "@types/jasmine-ajax": "^3.3.0", + "@types/jquery": "^3.3.5", + "@types/jquery.validation": "^1.16.6", + "@types/lodash": "^4.14.113", + "@types/node": "^12.7.5", + "@types/rangy": "^0.0.32", + "aliasify": "^2.1.0", + "autoprefixer": "^9.0.2", + "babelify": "^10.0.0", + "browserify": "^16.2.2", + "canvas": "2.6.1", + "chalk": "^2.4.2", + "cssnano": "^5.1.12", + "del": "^3.0.0", + "exorcist": "^2.0.0", + "exposify": "^0.5.0", + "fancy-log": "^1.3.3", + "glob": "^7.1.3", + "gulp": "4", + "gulp-browserify-watchify-glob": "^1.0.0-3", + "gulp-cached": "^1.1.1", + "gulp-cdnizer": "^2.0.0", + "gulp-changed": "^4.0.1", + "gulp-cli": "^2.0.1", + "gulp-connect": "^5.6.1", + "gulp-define-module": "^0.1.5", + "gulp-handlebars": "^5.0.2", + "gulp-hb": "^7.0.1", + "gulp-if": "^2.0.2", + "gulp-load-plugins": "^1.5.0", + "gulp-postcss": "^9.0.1", + "gulp-rename": "^1.4.0", + "gulp-sass": "^5.0.0", + "gulp-sourcemaps": "^2.6.4", + "gulp-uglify": "^3.0.1", + "handlebars-i18next-parser": "^1.0.2", + "http-proxy-middleware": "^0.19.0", + "i18next-parser": "^6.0.1", + "jasmine-ajax": "^4.0.0", + "jasmine-core": "~3.5.0", + "jasmine-terminal-reporter": "^1.0.3", + "jsdom": "^16.5.0", + "plugin-error": "^1.0.1", + "postcss": "^8.4.14", + "streamqueue": "^1.1.2", + "through2": "^3.0.1", + "ts-node": "^8.3.0", + "tsify": "^4.0.0", + "typescript": "^4.0.0", + "vinyl-buffer": "^1.0.1", + "vinyl-source-stream": "^2.0.0", + "watchify": "^3.11.0", + "yargs": "^12.0.1" + } } diff --git a/frontend/src/common-rdf/conversion.ts b/frontend/src/common-rdf/conversion.ts index a55d7892f..2b35f424c 100644 --- a/frontend/src/common-rdf/conversion.ts +++ b/frontend/src/common-rdf/conversion.ts @@ -95,6 +95,19 @@ const knownConversions: ConversionTable = { return obj; }, }, + [xsd.date]: { + toLD(date: Date): FlatTypedLiteral { + return { + '@value': date.toJSON().slice(0, 10), + '@type': xsd.date, + }; + }, + fromLD(value: FlatTypedLiteral): Date { + let obj = new Date(value['@value'] as string); + obj['@type'] = xsd.date; + return obj; + }, + }, }; /** diff --git a/frontend/src/common-rdf/ns.ts b/frontend/src/common-rdf/ns.ts index 4e0d2300e..73197a8a9 100644 --- a/frontend/src/common-rdf/ns.ts +++ b/frontend/src/common-rdf/ns.ts @@ -486,6 +486,7 @@ export const schemaTerms = [ 'accessibilityFeature', 'affiliation', 'alternativeHeadline', + 'Article', 'Audience', 'audience', 'author', @@ -500,7 +501,10 @@ export const schemaTerms = [ // 'name', // causes runtime error, use schema('name') instead 'Organization', 'Person', - 'text' + 'Review', + 'SocialMediaPosting', + 'text', + 'WebContent', ] as const; export const schema = Vocabulary(schemaPrefix, schemaTerms); @@ -517,7 +521,9 @@ export const iso6391Terms = [ 'en', 'de', 'fr', - 'nl' + 'nl', + 'it', + 'cs', ] as const; export const iso6391 = Vocabulary(iso6391Prefix, iso6391Terms); @@ -570,6 +576,35 @@ export const nlpOntologyPrefix = READIT + 'nlp-ontology#' export const nlp = Vocabulary(nlpOntologyPrefix, ontologyNotHardcoded); +export const sourceOntologyPrefix = READIT + 'source-ontology#'; + +const sourceOntologyTerms = [ + 'author', + 'content', + 'dateCreated', + 'datePublished', + 'dateRetrieved', + 'dateUploaded', + 'editor', + 'encodingFormat', + 'fullText', + 'image', + 'language', + 'PlainTextSource', + 'public', + 'publisher', + 'ReaditSourceType', + 'repository', + 'Source', + 'sourceType', + 'TFO_TextForm', + 'thumbnail', + 'title', + 'url', +] as const; + +export const sourceOntology = Vocabulary(sourceOntologyPrefix, sourceOntologyTerms); + /** * READ-IT items * (not a vocabulary at all, but associated with a prefix nonetheless) @@ -623,6 +658,7 @@ export const nsMap: nsTable = { vocab: vocabPrefix, staff: staffPrefix, nlp: nlpOntologyPrefix, + sourceOnto: sourceOntologyPrefix, source: sourcePrefix, item: itemPrefix, }; diff --git a/frontend/src/global/ld-store.ts b/frontend/src/global/ld-store.ts index dfa67d85d..ae849be20 100644 --- a/frontend/src/global/ld-store.ts +++ b/frontend/src/global/ld-store.ts @@ -61,12 +61,13 @@ async function prefetch2() { inhouseGraphs.forEach(ns => globalGraph.import(ns)); const gotOntology = ldChannel.request('ontology:promise'); const gotNlpOntology = ldChannel.request('nlp-ontology:promise'); + const gotSourceOntology = ldChannel.request('source-ontology:promise'); ldChannel.request('sources:tally'); ldChannel.request('items:tally'); // We wait for the ontologies to arrive before fetching sources and items, // because this potentially prevents double requests if client code starts // flattening the Nodes already before the ontologies arrive. - await Promise.all([gotOntology, gotNlpOntology]); + await Promise.all([gotOntology, gotNlpOntology, gotSourceOntology]); ldChannel.request('sources:user'); ldChannel.request('items:user'); ldChannel.request('sources:random'); diff --git a/frontend/src/global/source-ontology.ts b/frontend/src/global/source-ontology.ts new file mode 100644 index 000000000..33425a091 --- /dev/null +++ b/frontend/src/global/source-ontology.ts @@ -0,0 +1,39 @@ +import ldChannel from '../common-rdf/radio'; +import { sourceOntology as source } from '../common-rdf/ns'; +import Graph from '../common-rdf/graph'; + +const sourceOntology = new Graph(); +export default sourceOntology; +let promise: PromiseLike = null; + +/** + * The function that takes care of the lazy fetching. + */ +function ensurePromise(): PromiseLike { + if (promise) return promise; + promise = sourceOntology.fetch({ url: source() }).then(handleSuccess, handleError); + return promise; +} + +/** + * Promise resolution and rejection handlers. + * Besides returning the result or error, they short-circuit the + * promise in order to save a few ticks. + */ +function handleSuccess(): Graph { + promise = Promise.resolve(sourceOntology); + return sourceOntology; +} + +function handleError(error: any): any { + console.log(error) + promise = Promise.reject(error); + return error; +} + +/** + * Registering our services with the radio channel. + */ +ldChannel.once('cache:source-ontology', ensurePromise); +ldChannel.reply('source-ontology:promise', ensurePromise); +ldChannel.reply('source-ontology:graph', () => (ensurePromise(), sourceOntology)); diff --git a/frontend/src/i18n/en/translation.json b/frontend/src/i18n/en/translation.json index 4a5fb7fc3..edffb6ad1 100644 --- a/frontend/src/i18n/en/translation.json +++ b/frontend/src/i18n/en/translation.json @@ -59,7 +59,9 @@ "edit": "Edit", "new": "New", "delete-source": "Delete source", - "register": "Register" + "save-changes": "Save changes", + "register": "Register", + "preview": "Preview" }, "feedback": { "success-heading": "Feedback submitted", @@ -82,12 +84,32 @@ "which-item": "Which one?", "edit-properties": "Edit properties", "item-properties": "Item properties", - "properties": "Properties", "email": "Email", + "suffix-optional": "(optional)", "title": "Title", "author": "Author", - "editor": "Editor (optional)", + "editor": "Editor", + "publisher": "Publisher", + "repository": "Repository", "file": "File", + "choose-file": "Choose a file…", + "language": "Language", + "select-language": "Select language", + "english": "English", + "german": "German", + "french": "French", + "italian": "Italian", + "czech": "Czech", + "dutch": "Dutch", + "other": "Other", + "source-type": "Type of source", + "source-access": "Source access", + "public": "Public", + "private": "Private", + "url": "URL", + "publication-date": "Publication date", + "creation-date": "Creation date", + "retrieval-date": "Retrieval date", "text-search": "Textual search", "semantic-search": "Semantic search" }, @@ -98,7 +120,8 @@ "password": "Password", "repeat-password": "Repeat password", "search-database": "Search our database", - "optional-label": "optional label for reference" + "optional-label": "optional label for reference", + "repository": "Archive, location, collection, call, fasc, folio" }, "footer": { "developed-by": "Developed by" @@ -208,7 +231,7 @@ }, "source": { "list-panel-title": "Sources", - "metadata-title": "Source metadata", + "meta-summary": "Source {{identifier}}, created by {{creator}} at {{dateUploaded}}", "delete-confirm": "\nAre you sure you want to delete this source?\nIf you delete this source, all its annotation will be deleted as well, including any annotations that other users may have made.\nThis cannot be undone.\n" }, "register": { @@ -229,6 +252,25 @@ "failed": "Registration failed. Please try again and if this error keeps occuring, contact the site administrator." }, "upload": { - "form-title": "Upload source" + "no-file": "No file selected", + "only-txt-utf8-lf": "Only txt files in UTF-8 encoding (LF for line endings) are supported.", + "source-lang-select-help": "If the source contains multiple languages, please select 'Other'.", + "source-type-select-help": "When in doubt, choose 'Unknown'.", + "public-private-help": "Provide access to everyone (public) or only to authenticated users (private).", + "publication-date-help": { + "begin": " ", + "link": "ISO formatted date with optional time and timezone", + "end": ", or free-form text" + }, + "creation-date-help": "If known and different from publishing date, specify creation date.", + "retrieval-date-help": "Date (and optional time) at which the source was accessed or retrieved.", + "form-title": "Upload source", + "success": { + "begin": "Source uploaded successfully! Upload another one or", + "link": "go to the Explorer", + "end": "." + }, + "failed-help": "Uploading failed.\n Please try again and if this error keeps occuring,\n contact the site administrator.\n Do you have permission to upload sources?", + "preview-title": "Preview source content" } } diff --git a/frontend/src/i18n/en/translation_old.json b/frontend/src/i18n/en/translation_old.json index 25ef943e1..328ade787 100644 --- a/frontend/src/i18n/en/translation_old.json +++ b/frontend/src/i18n/en/translation_old.json @@ -15,6 +15,10 @@ "feedback": { "failed": "Feedback submission failed. Please try again and if this error keeps occuring, contact the site administrator." }, + "label": { + "properties": "Properties", + "editor": "Editor (optional)" + }, "property": { "typeHints": { "integer": "Include extra leading zeros to force interpretation as an integer type.", @@ -38,6 +42,7 @@ } }, "source": { + "metadata-title": "Source metadata", "delete-confirm": "\nAre you sure you want to delete this source?\nIf you delete this source, all its annotation will be deleted as well, including any annotations that other users may have made.\nThis cannot be undone.\n" }, "register": { diff --git a/frontend/src/i18n/fr/translation.json b/frontend/src/i18n/fr/translation.json index 4a7fd3e0a..8e9eca61c 100644 --- a/frontend/src/i18n/fr/translation.json +++ b/frontend/src/i18n/fr/translation.json @@ -59,7 +59,9 @@ "edit": "Edit", "new": "New", "delete-source": "Delete source", - "register": "Register" + "save-changes": "Save changes", + "register": "Register", + "preview": "Preview" }, "feedback": { "success-heading": "Feedback submitted", @@ -82,12 +84,32 @@ "which-item": "Which one?", "edit-properties": "Edit properties", "item-properties": "Item properties", - "properties": "Properties", "email": "Email", + "suffix-optional": "(optional)", "title": "Title", "author": "Author", "editor": "Editor (optional)", + "publisher": "Publisher", + "repository": "Repository", "file": "File", + "choose-file": "Choose a file…", + "language": "Language", + "select-language": "Select language", + "english": "English", + "german": "German", + "french": "French", + "italian": "Italian", + "czech": "Czech", + "dutch": "Dutch", + "other": "Other", + "source-type": "Type of source", + "source-access": "Source access", + "public": "Public", + "private": "Private", + "url": "URL", + "publication-date": "Publication date", + "creation-date": "Creation date", + "retrieval-date": "Retrieval date", "text-search": "Textual search", "semantic-search": "Semantic search" }, @@ -98,7 +120,8 @@ "password": "Password", "repeat-password": "Repeat password", "search-database": "Search our database", - "optional-label": "optional label for reference" + "optional-label": "optional label for reference", + "repository": "Archive, location, collection, call, fasc, folio" }, "footer": { "developed-by": "Developed by" @@ -208,7 +231,7 @@ }, "source": { "list-panel-title": "Sources", - "metadata-title": "Source metadata", + "meta-summary": "Source {{identifier}}, created by {{creator}} at {{dateUploaded}}", "delete-confirm": "\nAre you sure you want to delete this source?\nIf you delete this source, all its annotation will be deleted as well, including any annotations that other users may have made.\nThis cannot be undone.\n" }, "register": { @@ -229,6 +252,25 @@ "failed": "Registration failed. Please try again and if this error keeps occuring, contact the site administrator." }, "upload": { - "form-title": "Upload source" + "no-file": "No file selected", + "only-txt-utf8-lf": "Only txt files in UTF-8 encoding (LF for line endings) are supported.", + "source-lang-select-help": "If the source contains multiple languages, please select 'Other'.", + "source-type-select-help": "When in doubt, choose 'Unknown'.", + "public-private-help": "Provide access to everyone (public) or only to authenticated users (private).", + "publication-date-help": { + "begin": " ", + "link": "ISO formatted date with optional time and timezone", + "end": ", or free-form text" + }, + "creation-date-help": "If known and different from publishing date, specify creation date.", + "retrieval-date-help": "Date (and optional time) at which the source was accessed or retrieved.", + "form-title": "Upload source", + "success": { + "begin": "Source uploaded successfully! Upload another one or", + "link": "go to the Explorer", + "end": "." + }, + "failed-help": "Uploading failed.\n Please try again and if this error keeps occuring,\n contact the site administrator.\n Do you have permission to upload sources?", + "preview-title": "Preview source content" } } diff --git a/frontend/src/i18n/fr/translation_old.json b/frontend/src/i18n/fr/translation_old.json index c3ee640c3..6cafbb002 100644 --- a/frontend/src/i18n/fr/translation_old.json +++ b/frontend/src/i18n/fr/translation_old.json @@ -1,5 +1,11 @@ { "tooltip": { "explore-menu": "Get a random selection of reading experiences and annotations" + }, + "label": { + "properties": "Properties" + }, + "source": { + "metadata-title": "Source metadata" } } diff --git a/frontend/src/iri-hyperlink/selector-iri-view.ts b/frontend/src/iri-hyperlink/selector-iri-view.ts index dea65c3ca..2a86cceda 100644 --- a/frontend/src/iri-hyperlink/selector-iri-view.ts +++ b/frontend/src/iri-hyperlink/selector-iri-view.ts @@ -1,7 +1,7 @@ import { extend } from 'lodash'; import { CompositeView } from '../core/view'; -import { schema } from '../common-rdf/ns'; +import { sourceOntology } from '../common-rdf/ns'; import Node from '../common-rdf/node'; import FlatItem from '../common-adapters/flat-item-model'; import { getLabel } from '../utilities/linked-data-utilities'; @@ -30,7 +30,7 @@ export default class SelectorIRIView extends CompositeView { renderContainer(): this { const source = this.model.get('source'); this.$el.html(this.template(extend({ - title: source && source.get(schema('name')), + title: source && source.get(sourceOntology.title), }, this.model.toJSON()))); return this; } diff --git a/frontend/src/item-edit/linked-item-editor-template.hbs b/frontend/src/item-edit/linked-item-editor-template.hbs index 208830064..5f8bc4040 100644 --- a/frontend/src/item-edit/linked-item-editor-template.hbs +++ b/frontend/src/item-edit/linked-item-editor-template.hbs @@ -8,13 +8,3 @@ -

- {{#i18n 'property.all-types-permitted'}} - This property permits any simple type. - {{/i18n}} -

-

- {{#i18n 'property.no-type-matched'}} - This value does not match the permitted type(s). - {{/i18n}} -

diff --git a/frontend/src/item-edit/linked-item-editor-view.ts b/frontend/src/item-edit/linked-item-editor-view.ts index 9d1366ef5..84e9f2eca 100644 --- a/frontend/src/item-edit/linked-item-editor-view.ts +++ b/frontend/src/item-edit/linked-item-editor-view.ts @@ -1,5 +1,5 @@ import { - find, intersection, extend, chain, isNumber, isBoolean, isString, isDate, + extend, chain } from 'lodash'; import Model from '../core/model'; @@ -7,53 +7,17 @@ import { CompositeView } from '../core/view'; import { asLD, Native } from '../common-rdf/conversion'; import Node from '../common-rdf/node'; import Graph from '../common-rdf/graph'; -import { rdfs, xsd } from '../common-rdf/ns'; +import { rdfs } from '../common-rdf/ns'; import Select2Picker from '../forms/select2-picker-view'; import RemoveButton from '../forms/remove-button-view'; import InputField from '../forms/input-field-view'; import { getRdfSuperProperties } from '../utilities/linked-data-utilities'; -import interpretText from '../utilities/interpret-text'; -import AllowedTypesListHelpText from './allowed-type-list-view'; -import DetectedTypeHelpText from './detected-type-help-view'; import linkedItemTemplate from './linked-item-editor-template'; +import TypeAwareHelpText from './type-aware-help-view'; // Selector of the control where the object picker is inserted. const objectControl = '.field.has-addons .control:nth-child(2)'; -// Selector of template element displaying "all types allowed" help text. -const allTypesAllowedHelp = 'p.help:first-of-type'; -// Selector of template element displaying "no matching type" help text. -const noMatchHelp = 'p.help.is-danger'; - -const semiCompatibleTypes: [(v: any) => boolean, string[]][] = [ - [isBoolean, [xsd.boolean]], - [isNumber, [ - xsd.double, xsd.float, xsd.byte, xsd.unsignedByte, xsd.short, - xsd.unsignedShort, xsd.int, xsd.unsignedInt, xsd.long, - xsd.unsignedLong, xsd.integer, xsd.nonNegativeInteger, - xsd.nonPositiveInteger, xsd.positiveInteger, xsd.negativeInteger, - xsd.decimal, - ]], - [isDate, [xsd.dateTime, xsd.date]], - [isString, [ - xsd.string, xsd.normalizedString, xsd.token, xsd.language, - xsd.base64Binary, - ]], -]; - -function findType(range: Graph, value: any): string { - const available = range.map(n => (n.id as string)); - let singleType; - if (range.length === 1) { - singleType = available[0]; - if (singleType !== rdfs.Literal) return singleType; - } - const matches = find(semiCompatibleTypes, ([check]) => check(value))[1]; - if (!range.length || singleType === rdfs.Literal) { - return matches[0]; - } - return intersection(matches, available)[0]; -} export default class LinkedItemEditor extends CompositeView { collection: Graph; @@ -61,19 +25,15 @@ export default class LinkedItemEditor extends CompositeView { predicatePicker: Select2Picker; removeButton: RemoveButton; literalField: InputField; - allowedTypesList: AllowedTypesListHelpText; - detectedTypeHelp: DetectedTypeHelpText; + typeAwareHelp: TypeAwareHelpText; initialize() { this.range = new Graph; this.predicatePicker = new Select2Picker({collection: this.collection}); this.literalField = new InputField(); this.removeButton = new RemoveButton().on('click', this.close, this); - this.allowedTypesList = new AllowedTypesListHelpText({ - collection: this.range, - }); - this.detectedTypeHelp = new DetectedTypeHelpText({ model: new Model }); - this.render().updateRange(); + this.typeAwareHelp = new TypeAwareHelpText({model: this.model.get('predicate')}); + this.render(); this.predicateFromModel(this.model).objectFromModel(this.model); this.literalField.on('keyup', this.updateObject, this); this.predicatePicker.on('change', this.updatePredicate, this); @@ -87,49 +47,12 @@ export default class LinkedItemEditor extends CompositeView { updatePredicate(picker: Select2Picker, id: string): void { const predicate = this.collection.get(id); this.model.set('predicate', predicate); - this.updateRange(); + this.typeAwareHelp.updateRange(predicate); this.updateObject(this.literalField, this.literalField.getValue()); } - updateRange(): this { - const predicate = this.model.get('predicate'); - this.$(allTypesAllowedHelp).hide(); - this.allowedTypesList.$el.hide(); - if (!predicate) { - this.range.reset(); - return this; - } - const allProperties = getRdfSuperProperties([predicate]); - this.range.set( - chain(allProperties) - .map(n => n.get(rdfs.range) as Node[]) - .flatten() - .compact() - .value() - ); - if (!this.range.length || this.range.get(rdfs.Literal)) { - this.$(allTypesAllowedHelp).show(); - } else { - this.allowedTypesList.$el.show(); - } - return this; - } - updateObject(labelField: InputField, val: string): void { - const interpretation = interpretText(val, this.range); - this.literalField.$el.removeClass('is-danger'); - this.$(noMatchHelp).hide(); - if (interpretation) { - this.detectedTypeHelp.model.set(interpretation); - this.detectedTypeHelp.$el.show(); - this.model.set('object', interpretation.jsonld); - } else { - if (val) { - this.$(noMatchHelp).show(); - this.literalField.$el.addClass('is-danger'); - } - this.detectedTypeHelp.$el.hide(); - } + this.typeAwareHelp.updateHelpText(val); } predicateFromModel(model: Model, selectedPredicate?: Node): this { @@ -143,15 +66,8 @@ export default class LinkedItemEditor extends CompositeView { setLiteral || (setLiteral = model.get('object')); if (setLiteral) { const jsonld = asLD(setLiteral); - this.literalField.setValue(jsonld['@value']); - if (!jsonld['@type']) { - jsonld['@type'] = findType(this.range, jsonld['@value']); - } - this.detectedTypeHelp.model.set({ jsonld }); - } else { - this.detectedTypeHelp.$el.hide(); + this.typeAwareHelp.setHelpText(jsonld); } - this.$(noMatchHelp).hide(); return this; } @@ -173,8 +89,8 @@ extend(LinkedItemEditor.prototype, { view: 'removeButton', selector: '.field.has-addons', }, { - view: 'allowedTypesList', - selector: noMatchHelp, - method: 'before', + view: 'typeAwareHelp', + selector: '.field.has-addons', + method: 'after', }, 'detectedTypeHelp'], }); diff --git a/frontend/src/item-edit/type-aware-help-template.hbs b/frontend/src/item-edit/type-aware-help-template.hbs new file mode 100644 index 000000000..23ec8f4f0 --- /dev/null +++ b/frontend/src/item-edit/type-aware-help-template.hbs @@ -0,0 +1,10 @@ +

+ {{#i18n 'property.all-types-permitted'}} + This property permits any simple type. + {{/i18n}} +

+

+ {{#i18n 'property.no-type-matched'}} + This value does not match the permitted type(s). + {{/i18n}} +

diff --git a/frontend/src/item-edit/type-aware-help-view.ts b/frontend/src/item-edit/type-aware-help-view.ts new file mode 100644 index 000000000..3b134bca1 --- /dev/null +++ b/frontend/src/item-edit/type-aware-help-view.ts @@ -0,0 +1,138 @@ +import { + find, intersection, extend, isNumber, isBoolean, isString, isDate, chain, +} from 'lodash'; +import { CompositeView } from '../core/view'; +import { rdfs, xsd } from '../common-rdf/ns'; +import interpretText from '../utilities/interpret-text'; +import AllowedTypesListHelpText from './allowed-type-list-view'; +import DetectedTypeHelpText from './detected-type-help-view'; +import Graph from '../common-rdf/graph'; +import Node from '../common-rdf/node'; +import { FlatSingleValue } from '../common-rdf/json'; +import { asLD, Native } from '../common-rdf/conversion'; +import Model from '../core/model'; + +import typeAwareHelpTemplate from './type-aware-help-template'; +import { getRdfSuperProperties } from '../utilities/linked-data-utilities'; + +// Selector of template element displaying "all types allowed" help text. +const allTypesAllowedHelp = 'p.help:first-of-type'; +// Selector of template element displaying "no matching type" help text. +const noMatchHelp = 'p.help.is-danger'; + +const semiCompatibleTypes: [(v: any) => boolean, string[]][] = [ + [isBoolean, [xsd.boolean]], + [isNumber, [ + xsd.double, xsd.float, xsd.byte, xsd.unsignedByte, xsd.short, + xsd.unsignedShort, xsd.int, xsd.unsignedInt, xsd.long, + xsd.unsignedLong, xsd.integer, xsd.nonNegativeInteger, + xsd.nonPositiveInteger, xsd.positiveInteger, xsd.negativeInteger, + xsd.decimal, + ]], + [isDate, [xsd.dateTime, xsd.date]], + [isString, [ + xsd.string, xsd.normalizedString, xsd.token, xsd.language, + xsd.base64Binary, + ]], +]; + +function findType(range: Graph, value: any): string { + const available = range.map((n) => n.id as string); + let singleType; + if (range.length === 1) { + singleType = available[0]; + if (singleType !== rdfs.Literal) return singleType; + } + const matches = find(semiCompatibleTypes, ([check]) => check(value)); + const matchedTypes = matches ? matches[1] : []; + + if (!range.length || singleType === rdfs.Literal) { + return matchedTypes[0]; + } + return intersection(matchedTypes, available)[0]; +} + +export default class TypeAwareHelpText extends CompositeView { + collection: Graph; + allowedTypesList: AllowedTypesListHelpText; + detectedTypeHelp: DetectedTypeHelpText; + range: Graph; + model: Node; + + initialize() { + this.range = this.collection || new Graph(); + this.detectedTypeHelp = new DetectedTypeHelpText({ + model: new Model(), + }); + this.allowedTypesList = new AllowedTypesListHelpText({ + collection: this.range, + }); + this.render().updateRange(this.model); + } + + renderContainer(): this { + this.$el.html(this.template(this)); + this.$(noMatchHelp).hide(); + this.$(allTypesAllowedHelp).hide(); + this.detectedTypeHelp.$el.hide(); + return this; + } + + setHelpText(jsonld: FlatSingleValue): this { + if (!jsonld["@type"]) { + jsonld["@type"] = findType(this.range, jsonld["@value"]); + } + this.detectedTypeHelp.model.set({ jsonld }); + this.$(noMatchHelp).hide(); + return this; + } + + updateRange(predicate: Node): this { + this.$(allTypesAllowedHelp).hide(); + this.allowedTypesList.$el.hide(); + if (!predicate) { + this.range.reset(); + return this; + } + const allProperties = getRdfSuperProperties([predicate]); + this.range.set( + chain(allProperties) + .map(n => n.get(rdfs.range) as Node[]) + .flatten() + .compact() + .value() + ); + if (!this.range.length || this.range.get(rdfs.Literal)) { + this.$(allTypesAllowedHelp).show(); + } else { + this.allowedTypesList.$el.show(); + } + return this; + } + + updateHelpText(val: string): void { + const interpretation = interpretText(val, this.range); + this.$(noMatchHelp).hide(); + if (interpretation) { + this.detectedTypeHelp.model.set(interpretation); + this.detectedTypeHelp.$el.show(); + } else { + if (val) { + this.$(noMatchHelp).show(); + } + this.detectedTypeHelp.$el.hide(); + } + } +} +extend(TypeAwareHelpText.prototype, { + className: "rit help-text", + template: typeAwareHelpTemplate, + subviews: [ + { + view: "allowedTypesList", + selector: noMatchHelp, + method: "before", + }, + "detectedTypeHelp", + ], +}); diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 46dcfb83f..cce0ef41c 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -3,6 +3,7 @@ import { when, ready } from 'jquery'; import '@dhl-uu/jquery-promise'; import { baseUrl } from 'config.json'; +import './global/source-ontology'; import './global/scroll-easings'; import { i18nPromise } from './global/i18n'; import './global/internalLinks'; diff --git a/frontend/src/mock-data/mock-sources.ts b/frontend/src/mock-data/mock-sources.ts index 671c8379f..ae7b4966b 100644 --- a/frontend/src/mock-data/mock-sources.ts +++ b/frontend/src/mock-data/mock-sources.ts @@ -1,54 +1,46 @@ import { - rdf, - rdfs, - owl, dcterms, staff, - readit, - item, source, - vocab, - skos, - schema, + sourceOntology, xsd, - oa, iso6391, } from '../common-rdf/ns'; import mockSourceText from './mock-source-text'; export const source1instance = { "@id": source('1'), - "@type": vocab('Source'), - [schema('name')]: [ + "@type": sourceOntology.Source, + [sourceOntology.title]: [ { "@type": xsd.string, "@value": "Corpus_50_exp_lectures_interligne1.5 (1).pdf" } ], - [schema.author]: [ + [sourceOntology.author]: [ { "@type": xsd.string, "@value": "Tess T. Author" } ], // TODO: move text to a file and link to it (in an appropriate property!) - [schema.text]: [ + [sourceOntology.content]: [ { "@value": mockSourceText }, ], - [schema.datePublished]: [ + [sourceOntology.datePublished]: [ { "@type": xsd.dateTime, "@value": "1900-01-01T00:00:00+0100" } ], - [schema.inLanguage]: [ + [sourceOntology.language]: [ { "@id": iso6391.en }, ], - [dcterms.created]: [ + [sourceOntology.dateUploaded]: [ { "@type": xsd.dateTime, "@value": "2085-12-31T04:33:16+0100" diff --git a/frontend/src/panel-search-results/search-result-annotation-view.ts b/frontend/src/panel-search-results/search-result-annotation-view.ts index 58d9c4fcd..38b563f54 100644 --- a/frontend/src/panel-search-results/search-result-annotation-view.ts +++ b/frontend/src/panel-search-results/search-result-annotation-view.ts @@ -1,7 +1,7 @@ import { extend, after } from 'lodash'; import { CompositeView } from '../core/view'; -import { schema } from '../common-rdf/ns'; +import { sourceOntology } from '../common-rdf/ns'; import ldChannel from '../common-rdf/radio'; import Node from '../common-rdf/node'; import LabelView from '../label/label-view'; @@ -22,7 +22,7 @@ export default class SearchResultAnnotationView extends CompositeView } processSource(model: FlatItem, source: Node): void { - source.when(schema('name'), this.processTitle, this); + source.when(sourceOntology.title, this.processTitle, this); source.when('@type', this.processLabel, this); this.delayedRender = after(2, this.render); } diff --git a/frontend/src/panel-search-results/search-result-source-view.ts b/frontend/src/panel-search-results/search-result-source-view.ts index 0ba3f5d40..5e21ab658 100644 --- a/frontend/src/panel-search-results/search-result-source-view.ts +++ b/frontend/src/panel-search-results/search-result-source-view.ts @@ -1,6 +1,6 @@ import { extend } from 'lodash'; -import { schema } from "../common-rdf/ns"; +import { sourceOntology } from "../common-rdf/ns"; import FlatItem from "../common-adapters/flat-item-model"; import View from "../core/view"; @@ -13,9 +13,9 @@ export default class SearchResultSourceView extends View { initialize() { const sourceItem = this.model.get('item'); - this.author = sourceItem.get(schema.author)[0]; - this.title = sourceItem.get(schema('name'))[0]; - const date = sourceItem.get(schema.datePublished)[0]; + this.author = sourceItem.get(sourceOntology.author)[0]; + this.title = sourceItem.get(sourceOntology.title)[0]; + const date = sourceItem.get(sourceOntology.datePublished)[0]; this.datePublished = date.toISOString().split("T")[0]; } diff --git a/frontend/src/panel-source-list/source-list-view.ts b/frontend/src/panel-source-list/source-list-view.ts index 7773dba39..8dea3f5f2 100644 --- a/frontend/src/panel-source-list/source-list-view.ts +++ b/frontend/src/panel-source-list/source-list-view.ts @@ -4,7 +4,7 @@ import Model from '../core/model'; import { CollectionView, ViewOptions as BaseOpt } from '../core/view'; import Graph from '../common-rdf/graph'; import Node from '../common-rdf/node'; -import { dcterms, vocab } from '../common-rdf/ns'; +import { vocab, sourceOntologyPrefix } from '../common-rdf/ns'; import { announceRoute } from '../explorer/utilities'; import sourceListTemplate from './source-list-template'; @@ -79,7 +79,7 @@ export default class SourceListView extends CollectionView node.get(oa.hasTarget)[0]['id']===dcterms.title); + await this.highlights.fetch({ + url: highlightURL, + data: $.param({ + source: this.identifier, + query: this.query, + fields: this.fields, + }), + }); + const titleNode = this.highlights.models.find( + (node) => + (node.get(oa.hasTarget)[0] as Node)["id"] == + sourceOntology("title") + ); if (titleNode) { - this.name = titleNode.get(oa.hasBody)[0].toString(); + this.name = (titleNode.get(oa.hasBody)[0] as Node).toString(); } - const authorNode = this.highlights.models.find(node => node.has(oa.hasTarget, schema.author)); + const authorNode = this.highlights.models.find((node) => + node.has(oa.hasTarget, sourceOntology("author")) + ); if (authorNode) { - this.author = authorNode.get(oa.hasBody)[0].toString(); + this.author = (authorNode.get(oa.hasBody)[0] as Node).toString(); } - const textNode = this.highlights.models.find(node => node.get(oa.hasTarget)[0]['id']===schema.text); + const textNode = this.highlights.models.find( + (node) => (node.get(oa.hasTarget)[0] as Node)["id"] === sourceOntology.content + ); if (textNode) { - this.snippets = textNode.get(oa.hasBody).map(snip => snip.toString()); + this.snippets = (textNode.get(oa.hasBody) as Node[]).map((snip) => + snip.toString() + ); } this.render(); } @@ -63,7 +78,7 @@ export default class SourceSummaryView extends View { } onSourceClicked(): this { - this.trigger('click', this.model.cid); + this.trigger("click", this.model.cid); return this; } } @@ -74,4 +89,4 @@ extend(SourceSummaryView.prototype, { events: { 'click .card': 'onSourceClicked' } -}); \ No newline at end of file +}); diff --git a/frontend/src/panel-source/source-metadata-panel-template.hbs b/frontend/src/panel-source/source-metadata-panel-template.hbs new file mode 100644 index 000000000..5a5534e80 --- /dev/null +++ b/frontend/src/panel-source/source-metadata-panel-template.hbs @@ -0,0 +1,12 @@ +
+

{{#i18n 'source.meta-summary'}}Source {{identifier}}, created by {{creator}} at {{dateUploaded}}{{/i18n}}

+
+
+ +
+ diff --git a/frontend/src/panel-source/source-metadata-panel.ts b/frontend/src/panel-source/source-metadata-panel.ts new file mode 100644 index 000000000..d9cf8f93b --- /dev/null +++ b/frontend/src/panel-source/source-metadata-panel.ts @@ -0,0 +1,116 @@ +import { extend, isEmpty } from 'lodash'; + +import Node from "../common-rdf/node"; +import { dcterms, sourceOntology } from "../common-rdf/ns"; +import { currentUserOwnsModel } from '../common-user/utilities'; +import { CompositeView } from "../core/view"; +import i18nChannel from "../i18n/radio"; +import SourceMetadataView from '../source-metadata/source-metadata-view'; +import { getLabelFromId } from '../utilities/linked-data-utilities'; +import metadataTemplate from "./source-metadata-panel-template"; + +let sourceDeletionDialog: string; +const dialogDefault = ` +Are you sure you want to delete this source? +If you delete this source, all its annotation will be deleted as well, including any annotations that other users may have made. +This cannot be undone. +`; +(async function () { + const i18next = await i18nChannel.request("i18next"); + sourceDeletionDialog = i18next.t("source.delete-confirm", dialogDefault); +})(); + +export default class MetadataPanel extends CompositeView { + /** + * Class to show source's metadata + */ + userIsOwner: boolean; + sourceMetadataView: SourceMetadataView; + creator: string; + identifier: string; + dateUploaded: string; + changes: {} = {}; + + initialize(): this { + this.model.when(dcterms.creator, this.checkOwnership, this); + this.identifier = getLabelFromId(this.model.id as string); + this.sourceMetadataView = new SourceMetadataView({ model: this.model }); + this.render().listenTo(this.model, "change", this.render); + this.listenTo(this.sourceMetadataView, "valueChanged", this.pushChange); + return this; + } + + renderContainer(): this { + this.$el.html(this.template(this)); + this.$(".edit-mode").toggle(); + return this; + } + + checkOwnership(model, creators: Node[]): void { + const creator = creators[0]; + const creatorId = creator.id || creator["@id"]; + this.creator = getLabelFromId(creatorId); + this.userIsOwner = currentUserOwnsModel(model); + this.dateUploaded = + this.model.attributes[ + sourceOntology("dateUploaded") + ][0].toLocaleDateString(); + this.render(); + } + + onCloseClicked() { + this.trigger("metadata:hide", this); + } + + toggleEditMode() { + this.$(".btn-edit").toggle(); + this.$(".edit-mode").toggle(); + this.sourceMetadataView.toggle().render(); + this.$(".date") + .find("input") + .prop("readonly", this.sourceMetadataView.readonly); + } + + async onDeleteClicked() { + if (!confirm(sourceDeletionDialog)) return; + const button = this.$(".btn-delete"); + button.addClass("is-loading"); + try { + await this.model.destroy({ wait: true }); + } catch (e) { + console.debug(e); + button.prop("disabled", true); + } finally { + button.removeClass("is-loading"); + } + } + + pushChange(changedField: string, value: string) { + this.changes[changedField] = value; + } + + onSubmit(event: JQueryEventObject): this { + event.preventDefault(); + if (!isEmpty(this.changes)) { + this.model.save(this.changes, { patch: true }); + } + this.toggleEditMode(); + return this; + } +} + +extend(MetadataPanel.prototype, { + tagName: 'form', + className: 'metadata-panel', + template: metadataTemplate, + subviews: [ + { view: 'sourceMetadataView', selector: '.panel-content'}, + ], + events: { + 'click .btn-close': 'onCloseClicked', + 'click .btn-edit': 'toggleEditMode', + 'click .btn-cancel': 'toggleEditMode', + 'click .btn-delete': 'onDeleteClicked', + 'submit': 'onSubmit', + } +}); diff --git a/frontend/src/panel-source/source-metadata-template.hbs b/frontend/src/panel-source/source-metadata-template.hbs deleted file mode 100644 index 484ad786b..000000000 --- a/frontend/src/panel-source/source-metadata-template.hbs +++ /dev/null @@ -1,37 +0,0 @@ -
- -
-

{{#i18n 'source.metadata-title'}}Source metadata{{/i18n}}

-
-
-
-
-
-
-

{{#i18n 'label.properties'}}Properties{{/i18n}}

-
- - - - - - - - - {{#each properties}} - - - - - {{/each}} - -
{{#i18n 'label.name'}}Name{{/i18n}}{{#i18n 'label.value'}}Value{{/i18n}}
{{@key}}{{this}}
-
-
- -
- diff --git a/frontend/src/panel-source/source-metadata-view.ts b/frontend/src/panel-source/source-metadata-view.ts deleted file mode 100644 index 539260aa4..000000000 --- a/frontend/src/panel-source/source-metadata-view.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { extend } from 'lodash'; - -import View from '../core/view'; -import { currentUserOwnsModel } from '../common-user/utilities'; -import { dcterms } from '../common-rdf/ns'; -import Node, { isNode } from '../common-rdf/node'; -import { getLabel, getTurtleTerm } from '../utilities/linked-data-utilities'; -import explorerChannel from '../explorer/explorer-radio'; -import i18nChannel from '../i18n/radio'; - -import metadataTemplate from './source-metadata-template'; - -const excludedProperties = [ - '<@id>', - '<@type>', - 'vocab:fullText', - 'schema:text', - 'owl:sameAs' -]; - -let sourceDeletionDialog: string; -const dialogDefault = ` -Are you sure you want to delete this source? -If you delete this source, all its annotation will be deleted as well, including any annotations that other users may have made. -This cannot be undone. -`; -(async function() { - const i18next = await i18nChannel.request('i18next'); - sourceDeletionDialog = i18next.t('source.delete-confirm', dialogDefault); -}()); - -export default class MetadataView extends View { - /** - * Class to show source's metadata - */ - properties: any; - userIsOwner: boolean; - - initialize(): this { - this.model.when(dcterms.creator, this.checkOwnership, this); - this.render().listenTo(this.model, 'change', this.render); - return this; - } - - checkOwnership(model): void { - if (this.userIsOwner = currentUserOwnsModel(model)) this.render(); - } - - render(): this { - this.$el.html(this.template(this.formatAttributes())); - this.$('.btn-cancel, .btn-delete').hide(); - return this; - } - - formatAttributes(): this { - this.properties = {}; - for (let attribute in this.model.attributes) { - // don't include @id, @value, fullText or sameAs info - let attributeLabel = getTurtleTerm(attribute); - if (excludedProperties.includes(attributeLabel)) { - continue; - } - let value: string | Node = this.model.get(attribute)[0]; - if (isNode(value)) value = getLabel(value); - this.properties[attributeLabel] = value; - } - return this; - } - - onCloseClicked() { - this.trigger('metadata:hide', this); - } - - toggleEditMode() { - this.$('.panel-footer button').toggle(); - } - - async onDeleteClicked() { - if (!confirm(sourceDeletionDialog)) return; - const button = this.$('.btn-delete'); - button.addClass('is-loading'); - try { - await this.model.destroy({wait: true}); - } catch (e) { - console.debug(e); - button.prop('disabled', true); - } finally { - button.removeClass('is-loading'); - } - } -} - -extend(MetadataView.prototype, { - tagName: 'div', - className: 'metadata-panel', - template: metadataTemplate, - events: { - 'click .btn-close': 'onCloseClicked', - 'click .btn-edit': 'toggleEditMode', - 'click .btn-cancel': 'toggleEditMode', - 'click .btn-delete': 'onDeleteClicked', - } -}); diff --git a/frontend/src/panel-source/source-view-test.ts b/frontend/src/panel-source/source-view-test.ts index 2c386bcfc..094870fb2 100644 --- a/frontend/src/panel-source/source-view-test.ts +++ b/frontend/src/panel-source/source-view-test.ts @@ -4,7 +4,7 @@ import { pick } from 'lodash'; import { startStore, endStore } from '../test-util'; import mockSources from '../mock-data/mock-sources'; -import { schema, vocab } from '../common-rdf/ns'; +import { sourceOntology } from '../common-rdf/ns'; import Node from '../common-rdf/node'; import Graph from '../common-rdf/graph'; import FlatCollection from '../common-adapters/flat-annotation-collection'; @@ -35,8 +35,8 @@ describe('SourceView', function() { it('will start rendering highlights eventually', function(done) { // Change `this.source` to provide the text as an URI instead of // containing the text inline. - const text = this.source.get(schema.text)[0]; - this.source.unset(schema.text).set(vocab('fullText'), textURI); + const text = this.source.get(sourceOntology.content)[0]; + this.source.unset(sourceOntology.content).set(sourceOntology.fullText, textURI); // This will cause the source panel to request the text with an XHR. const view = new SourcePanel({ diff --git a/frontend/src/panel-source/source-view.ts b/frontend/src/panel-source/source-view.ts index e1aef5cfc..b0d7d55c3 100644 --- a/frontend/src/panel-source/source-view.ts +++ b/frontend/src/panel-source/source-view.ts @@ -4,7 +4,7 @@ import { SubViewDescription } from 'backbone-fractal/dist/composite-view'; import Model from '../core/model'; import { CompositeView } from '../core/view'; -import { schema, vocab } from '../common-rdf/ns'; +import { sourceOntology } from '../common-rdf/ns'; import Node from '../common-rdf/node'; import FlatItem from '../common-adapters/flat-item-model'; import FlatCollection from '../common-adapters/flat-annotation-collection'; @@ -16,7 +16,7 @@ import { announceRoute, report404 } from '../explorer/utilities'; import HighlightableTextView from './highlightable-text-view'; import SourceToolbarView from '../toolbar/toolbar-view'; -import MetadataView from './source-metadata-view'; +import MetadataPanel from './source-metadata-panel'; import sourceTemplate from './source-template'; import LoadingSpinnerView from '../loading-spinner/loading-spinner-view'; @@ -32,7 +32,7 @@ function announce() { } export interface ViewOptions extends BaseOpt { - // An instance of vocab('Source'). + // An instance of sourceOntology.Source. model: Node; // The collection of annotations (and the items representing their details) @@ -66,7 +66,7 @@ class SourcePanel extends CompositeView { // view. htv: HighlightableTextView; - metaView: MetadataView; + metaView: MetadataPanel; loadingSpinnerView: LoadingSpinnerView; @@ -92,10 +92,11 @@ class SourcePanel extends CompositeView { }); this.toolbar = new SourceToolbarView({ model: this.toolbarModel }).render(); this.isEditable = options.isEditable || false; - this.metaView = new MetadataView({ + this.metaView = new MetadataPanel({ model: this.model }); + this.render(); // The next line used to be conditional on @@ -129,7 +130,7 @@ class SourcePanel extends CompositeView { } processText(): this { - const text = this.model.get(schema.text); + const text = this.model.get(sourceOntology.content); if (text && text.length) { this._createHtv(text[0] as string); } else { @@ -203,7 +204,7 @@ class SourcePanel extends CompositeView { } renderContainer(): this { - const names = this.model.get(schema('name')); + const names = this.model.get(sourceOntology.title); this.$el.html(this.template({ title: names ? names[0] : '' })); diff --git a/frontend/src/source-metadata/date-field-template.hbs b/frontend/src/source-metadata/date-field-template.hbs new file mode 100644 index 000000000..9620c3f24 --- /dev/null +++ b/frontend/src/source-metadata/date-field-template.hbs @@ -0,0 +1,6 @@ + +
+ +

{{{additionalHelpText}}} +

+
diff --git a/frontend/src/source-metadata/date-field-view.ts b/frontend/src/source-metadata/date-field-view.ts new file mode 100644 index 000000000..16c02e718 --- /dev/null +++ b/frontend/src/source-metadata/date-field-view.ts @@ -0,0 +1,58 @@ +import { timingSafeEqual } from 'crypto'; +import { extend } from 'lodash'; +import { ViewOptions as BaseOptions } from 'backbone'; + +import Node from "../common-rdf/node"; +import { CompositeView } from "../core/view"; +import TypeAwareHelpText from "../item-edit/type-aware-help-view"; + +import dateFieldTemplate from './date-field-template'; + +interface DateFieldOptions extends BaseOptions { + model: Node; + name: string; + label: string; + additionalHelpText?: string; + readonly?: boolean; + required?: boolean; +} + +export default class DateField extends CompositeView { + helpText: TypeAwareHelpText; + name: string; + label: string; + additionalHelpText: string; + required: boolean; + readonly: boolean; + + initialize(options: DateFieldOptions) { + this.name = options.name; + this.label = options.label; + this.helpText = new TypeAwareHelpText({model: this.model}); + this.additionalHelpText = options.additionalHelpText; + this.readonly = (options.readonly !== false); + this.required = (options.required === true); + this.render(); + } + + renderContainer(): this { + this.$el.html(this.template(this)); + return this; + } + + updateHelpText(event: JQueryEventObject) { + const value = $(event.currentTarget).val() as string; + this.helpText.updateHelpText(value); + } +} +extend(DateField.prototype, { + className: 'field date', + template: dateFieldTemplate, + subviews: [{ + view: 'helpText', selector: '.date', method: 'append' + }, + ], + events: { + 'keyup .input': 'updateHelpText' + } +}); \ No newline at end of file diff --git a/frontend/src/source-metadata/source-metadata-template.hbs b/frontend/src/source-metadata/source-metadata-template.hbs new file mode 100644 index 000000000..72ceabd83 --- /dev/null +++ b/frontend/src/source-metadata/source-metadata-template.hbs @@ -0,0 +1,137 @@ +
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+{{#upload}} +
+ +
+ +
+

{{#i18n 'upload.only-txt-utf8-lf'}}Only txt files in UTF-8 encoding (LF for line endings) are supported.{{/i18n}}

+
+{{/upload}} + +
+ +
+ {{#if readonly}} + + {{else}} +
+ +
+ {{/if}} +
+

+ {{#i18n 'upload.source-lang-select-help' + }}If the source contains multiple languages, please select 'Other'.{{/i18n}} +

+
+ +
+ + {{#if readonly}} +
+ +
+ {{else}} +
+
+ {{/if}} +

{{#i18n 'upload.source-type-select-help'}}When in doubt, choose 'Unknown'.{{/i18n}}

+
+
+ {{!date fields with type-aware help will be rendered here}} +
+
+ +
+ + +

{{#i18n 'upload.public-private-help'}}Provide access to everyone (public) or only to authenticated users (private).{{/i18n}}

+
+
+
+ +
+ +
+
diff --git a/frontend/src/source-metadata/source-metadata-view.ts b/frontend/src/source-metadata/source-metadata-view.ts new file mode 100644 index 000000000..c9561c8c6 --- /dev/null +++ b/frontend/src/source-metadata/source-metadata-view.ts @@ -0,0 +1,226 @@ +import { find, extend } from 'lodash'; +import { ViewOptions as BaseOpt } from 'backbone'; +import * as i18next from 'i18next'; + +import { rdfs, xsd, sourceOntology as sourceNS, sourceOntologyPrefix } from '../common-rdf/ns'; +import ldChannel from '../common-rdf/radio'; +import { getLabel, getLabelFromId } from '../utilities/linked-data-utilities'; +import FilteredCollection from "../common-adapters/filtered-collection"; +import Graph from "../common-rdf/graph"; +import { CompositeView } from "../core/view"; +import Select2PickerView from "../forms/select2-picker-view"; +import DateField from "./date-field-view"; + +import sourceMetadataTemplate from './source-metadata-template'; + +const sourceTypePrefix = sourceNS('TF'); +const isSourceType = t => t.startsWith(sourceTypePrefix); + +const externalAttributes = [ + 'language', + 'sourceType', + 'creator' +]; + +interface MetaDataOptions extends BaseOpt { + readonly? : boolean; + upload?: boolean; +} + +export default class SourceMetadataView extends CompositeView { + readonly: boolean; + upload: boolean; + + sourceTypePicker: Select2PickerView; + + sourceTypes: Graph; + ontologyGraph: Graph; + + publicationDateField: DateField; + creationDateField: DateField; + retrievalDateField: DateField; + + initialize(options: MetaDataOptions = {}): void { + this.getOntology(); + this.readonly = false; + if (options.readonly !== false) this.toggle(); + this.upload = options.upload !== undefined ? options.upload : false; + this.listenTo(this.model, "change", this.render); + } + + toggle(): this { + this.$el.toggleClass('is-static'); + this.readonly = !this.readonly; + return this; + } + + renderContainer(): this { + this.$el.html(this.template(this)); + return this; + } + + afterRender(): this { + // Assign names to select2 pickers to ensure form contains their data + this.$(".sourceTypeSelect select").attr({ name: "sourceType" }); + this.renderValues(); + return this; + } + + isSourceType(node): boolean { + if (!node.has(rdfs.subClassOf)) { + return false; + } + return ( + (node.has(rdfs.subClassOf, { + "@id": sourceNS("ReaditSourceType"), + }) && + !(node.id == sourceNS("TFO_TextForm"))) || + node.has(rdfs.subClassOf, { "@id": sourceNS("TFO_TextForm") }) + ); + } + + async getOntology() { + this.ontologyGraph = await ldChannel.request("source-ontology:promise"); + this.setTypeOptions(); + this.initDateFields(); + } + + setTypeOptions(): void { + const sourceTypes = new FilteredCollection( + this.ontologyGraph, + this.isSourceType + ) as unknown as Graph; + this.sourceTypePicker = new Select2PickerView({ + collection: sourceTypes, + }); + } + + initDateFields() { + this.publicationDateField = new DateField({ + model: this.getNode("datePublished"), + name: "datePublished", + required: true, + label: i18next.t("label.publication-date", "Publication date"), + additionalHelpText: ` + ${i18next.t("upload.publication-date-help.begin", " ")}${i18next.t( + "upload.publication-date-help.link", + "ISO formatted date with optional time and timezone" + )}${i18next.t( + "upload.publication-date-help.end", + ", or free-form text" + )} + `, + readonly: this.readonly, + }); + this.creationDateField = new DateField({ + model: this.getNode("dateCreated"), + name: "dateCreated", + label: i18next.t( + "label.creation-date", + "Creation date" + ), + additionalHelpText: i18next.t( + "upload.creation-date-help", + "If known and different from publishing date, specify creation date." + ), + readonly: this.readonly, + }); + this.retrievalDateField = new DateField({ + model: this.getNode("dateRetrieved"), + name: "dateRetrieved", + label: i18next.t( + "label.retrieval-date", + "Retrieval date" + ), + additionalHelpText: i18next.t( + "upload.retrieval-date-help", + "Date (and optional time) at which the source was accessed or retrieved." + ), + readonly: this.readonly, + }); + this.render(); + } + + getNode(predicate: string) { + return this.ontologyGraph.get(sourceOntologyPrefix + predicate); + } + + renderValues(): this { + if (!this.model) return this; + for (let attribute in this.model.attributes) { + const values = this.model.get(attribute); + let attributeLabel, value, valueId; + if (!values.length) continue; + if (attribute.startsWith(sourceOntologyPrefix)) { + attributeLabel = getLabelFromId(attribute) as string; + value = values[0]; + } else if (attribute === '@type') { + attributeLabel = 'sourceType'; + value = find(values, isSourceType); + if (!value) continue; + valueId = value; + } else continue; + const queryString = `[name="${attributeLabel}"]`; + const element = this.$(queryString); + if (!element.length) continue; + if (externalAttributes.includes(attributeLabel)) { + valueId = valueId || value.id; + if (this.readonly || attributeLabel === 'creator') { + const nodeFromUri = ldChannel.request("obtain", valueId); + value = typeof nodeFromUri === "string" + ? nodeFromUri + : getLabel(nodeFromUri); + } else { + value = attributeLabel === 'language' + ? getLabelFromId(valueId) + : valueId; + } + } + if (value instanceof Date) { + const isPlainDate = (value['@type'] === xsd.date); + value = value.toISOString(); + if (isPlainDate) value = value.slice(0, 10); + } + if (attributeLabel === 'public') { + value = [value ? 'public' : 'private']; + } + if (!this.readonly && attributeLabel === 'sourceType') { + this.sourceTypePicker.val(value); + } else { + element.val(value); + } + } + return this; + } + + getModelValue(attribute: string) { + const node = this.model.get(sourceNS(attribute)); + if (node) { + return node[0]; + } else return ""; + } + + updateModel(event) { + const el = event.target; + const suffix = el.type === 'radio' ? ':checked' : ''; + const selector = `[name='${el.name}']${suffix}`; + const value = this.$(selector).val(); + this.trigger("valueChanged", el.name, value); + } +} +extend(SourceMetadataView.prototype, { + className: 'section source', + template: sourceMetadataTemplate, + subviews: [ + { view: 'sourceTypePicker', selector: '.sourceTypeSelect' }, + { view: 'publicationDateField', selector: '.dates', method: 'prepend'}, + { view: 'creationDateField', selector: '.dates', method: 'append'}, + { view: 'retrievalDateField', selector: '.dates', method: 'append'}, + ], + events: { + 'change': 'updateModel' + } +}) diff --git a/frontend/src/sparql/query-templates/count-nodes-template.hbs b/frontend/src/sparql/query-templates/count-nodes-template.hbs index 9d6ee9aa3..b46834b71 100644 --- a/frontend/src/sparql/query-templates/count-nodes-template.hbs +++ b/frontend/src/sparql/query-templates/count-nodes-template.hbs @@ -6,6 +6,6 @@ select (count(distinct ?subject) as ?tally) where { ?subject a ?x. {{/itemQuery}} {{^itemQuery}} - ?subject a vocab:Source. + ?subject a sourceOnto:Source. {{/itemQuery}} } diff --git a/frontend/src/sparql/query-templates/nodes-by-user-template.hbs b/frontend/src/sparql/query-templates/nodes-by-user-template.hbs index 1277e1983..d1764f525 100644 --- a/frontend/src/sparql/query-templates/nodes-by-user-template.hbs +++ b/frontend/src/sparql/query-templates/nodes-by-user-template.hbs @@ -10,7 +10,7 @@ construct { ?subject a ?x. {{/itemQuery}} {{^itemQuery}} - ?subject a vocab:Source. + ?subject a sourceOnto:Source. {{/itemQuery}} } ORDER BY desc(?subject) diff --git a/frontend/src/sparql/query-templates/random-nodes-template.hbs b/frontend/src/sparql/query-templates/random-nodes-template.hbs index 8ebd15473..b0bdcb548 100644 --- a/frontend/src/sparql/query-templates/random-nodes-template.hbs +++ b/frontend/src/sparql/query-templates/random-nodes-template.hbs @@ -23,7 +23,7 @@ construct { {{^itemQuery}} {{! If, on the other hand, we're randomly sampling sources, they must obviously be actual sources. }} - ?subject a vocab:Source. + ?subject a sourceOnto:Source. {{/itemQuery}} {{! In the next few computations, we determine the serial number of the `?subject`, then split the digits of the number in a prefix and diff --git a/frontend/src/style/metadata.sass b/frontend/src/style/metadata.sass index 0053a1740..be0a0e92c 100644 --- a/frontend/src/style/metadata.sass +++ b/frontend/src/style/metadata.sass @@ -1,10 +1,13 @@ .metadata-panel z-index: 3 width: 400px - border: 1px solid black - border-radius: 3px background-color: $background height: auto - padding: 25px - margin: 5px - position: relative \ No newline at end of file + position: relative + +.file + .file-cta + background-color: $background-fore + + &:hover + background-color: $background diff --git a/frontend/src/style/source.sass b/frontend/src/style/source.sass index 9920bc936..27099099a 100644 --- a/frontend/src/style/source.sass +++ b/frontend/src/style/source.sass @@ -27,6 +27,32 @@ &.is-active background-color: bisque + &.section + padding-left: 0 + padding-right: 0 + + &.is-static + display: block table + .dates + display: table-row-group + .field + display: table-row + height: 2.25em + .label, .control + display: table-cell + .label + padding-right: 1em + input + @extend .is-static + padding-top: 0 + padding-bottom: 0 + border-top: 0 + border-bottom: 0 + border-radius: 0 + height: unset + .help, .help-text + display: none + .source-list .source-summary margin-bottom: 20px @@ -41,8 +67,9 @@ mark .upload-source height: 100% - overflow: auto - padding: 0 50px + overflow-y: auto + &.section + padding: 0 .modal pre @extend %textWrapper diff --git a/frontend/src/upload/upload-source-template.hbs b/frontend/src/upload/upload-source-template.hbs index 3ec0ddcf2..b73594f81 100644 --- a/frontend/src/upload/upload-source-template.hbs +++ b/frontend/src/upload/upload-source-template.hbs @@ -1,139 +1,47 @@
- -
-
- -
- -
-
- -
- -
- -
-
- -
- -
- -
-
- -
- -
- -

No file selected

-
-

Note that only txt files in UTF-8 encoding (LF for line endings) are supported.

-
- -
- -
-
- -
-
-

Specify the language that the source text is in. If the source contains multiple languages, - please select 'Other'.

-
- -
- -
-
- -
-
-

Specify the type of the source. When in doubt, choose 'Other'

-
- -
- -
- -
-
- -
- -
- -
-
- -
- -
- -
-
- - -
- +
- +
- +
-
-