diff --git a/files/processed_images/resized/.gitignore b/files/processed_images/resized/.gitignore deleted file mode 100644 index e69de29bb..000000000 diff --git a/install/1_structure.sql b/install/1_structure.sql index d961dba4c..c9d49da08 100644 --- a/install/1_structure.sql +++ b/install/1_structure.sql @@ -2,8 +2,8 @@ -- PostgreSQL database dump -- --- Dumped from database version 13.9 (Debian 13.9-0+deb11u1) --- Dumped by pg_dump version 13.9 (Debian 13.9-0+deb11u1) +-- Dumped from database version 13.13 (Debian 13.13-0+deb11u1) +-- Dumped by pg_dump version 13.13 (Debian 13.13-0+deb11u1) SET statement_timeout = 0; SET lock_timeout = 0; @@ -33,6 +33,9 @@ ALTER TABLE IF EXISTS ONLY web.hierarchy DROP CONSTRAINT IF EXISTS hierarchy_id_ ALTER TABLE IF EXISTS ONLY web.hierarchy_openatlas_class DROP CONSTRAINT IF EXISTS hierarchy_form_hierarchy_id_fkey; ALTER TABLE IF EXISTS ONLY web.entity_profile_image DROP CONSTRAINT IF EXISTS entity_profile_image_image_id_fkey; ALTER TABLE IF EXISTS ONLY web.entity_profile_image DROP CONSTRAINT IF EXISTS entity_profile_image_entity_id_fkey; +ALTER TABLE IF EXISTS ONLY web.annotation_image DROP CONSTRAINT IF EXISTS annotation_image_user_id_fkey; +ALTER TABLE IF EXISTS ONLY web.annotation_image DROP CONSTRAINT IF EXISTS annotation_image_image_id_fkey; +ALTER TABLE IF EXISTS ONLY web.annotation_image DROP CONSTRAINT IF EXISTS annotation_image_entity_id_fkey; ALTER TABLE IF EXISTS ONLY model.property DROP CONSTRAINT IF EXISTS property_range_class_code_fkey; ALTER TABLE IF EXISTS ONLY model.property_inheritance DROP CONSTRAINT IF EXISTS property_inheritance_super_code_fkey; ALTER TABLE IF EXISTS ONLY model.property_inheritance DROP CONSTRAINT IF EXISTS property_inheritance_sub_code_fkey; @@ -98,6 +101,7 @@ ALTER TABLE IF EXISTS ONLY web."group" DROP CONSTRAINT IF EXISTS group_pkey; ALTER TABLE IF EXISTS ONLY web."group" DROP CONSTRAINT IF EXISTS group_name_key; ALTER TABLE IF EXISTS ONLY web.entity_profile_image DROP CONSTRAINT IF EXISTS entity_profile_image_pkey; ALTER TABLE IF EXISTS ONLY web.entity_profile_image DROP CONSTRAINT IF EXISTS entity_profile_image_entity_id_key; +ALTER TABLE IF EXISTS ONLY web.annotation_image DROP CONSTRAINT IF EXISTS annotation_image_pkey; ALTER TABLE IF EXISTS ONLY model.property DROP CONSTRAINT IF EXISTS property_pkey; ALTER TABLE IF EXISTS ONLY model.property_inheritance DROP CONSTRAINT IF EXISTS property_inheritance_pkey; ALTER TABLE IF EXISTS ONLY model.property_i18n DROP CONSTRAINT IF EXISTS property_i18n_property_code_language_code_key; @@ -133,6 +137,7 @@ ALTER TABLE IF EXISTS web.hierarchy_openatlas_class ALTER COLUMN id DROP DEFAULT ALTER TABLE IF EXISTS web.hierarchy ALTER COLUMN id DROP DEFAULT; ALTER TABLE IF EXISTS web."group" ALTER COLUMN id DROP DEFAULT; ALTER TABLE IF EXISTS web.entity_profile_image ALTER COLUMN id DROP DEFAULT; +ALTER TABLE IF EXISTS web.annotation_image ALTER COLUMN id DROP DEFAULT; ALTER TABLE IF EXISTS model.property_inheritance ALTER COLUMN id DROP DEFAULT; ALTER TABLE IF EXISTS model.property_i18n ALTER COLUMN id DROP DEFAULT; ALTER TABLE IF EXISTS model.property ALTER COLUMN id DROP DEFAULT; @@ -174,6 +179,8 @@ DROP SEQUENCE IF EXISTS web.group_id_seq; DROP TABLE IF EXISTS web."group"; DROP SEQUENCE IF EXISTS web.entity_profile_image_id_seq; DROP TABLE IF EXISTS web.entity_profile_image; +DROP SEQUENCE IF EXISTS web.annotation_image_id_seq; +DROP TABLE IF EXISTS web.annotation_image; DROP SEQUENCE IF EXISTS model.property_inheritance_id_seq; DROP TABLE IF EXISTS model.property_inheritance; DROP SEQUENCE IF EXISTS model.property_id_seq; @@ -800,6 +807,44 @@ ALTER TABLE model.property_inheritance_id_seq OWNER TO openatlas; ALTER SEQUENCE model.property_inheritance_id_seq OWNED BY model.property_inheritance.id; +-- +-- Name: annotation_image; Type: TABLE; Schema: web; Owner: openatlas +-- + +CREATE TABLE web.annotation_image ( + id integer NOT NULL, + image_id integer NOT NULL, + entity_id integer, + coordinates text NOT NULL, + user_id integer, + annotation text NOT NULL, + created timestamp without time zone DEFAULT now() NOT NULL +); + + +ALTER TABLE web.annotation_image OWNER TO openatlas; + +-- +-- Name: annotation_image_id_seq; Type: SEQUENCE; Schema: web; Owner: openatlas +-- + +CREATE SEQUENCE web.annotation_image_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER TABLE web.annotation_image_id_seq OWNER TO openatlas; + +-- +-- Name: annotation_image_id_seq; Type: SEQUENCE OWNED BY; Schema: web; Owner: openatlas +-- + +ALTER SEQUENCE web.annotation_image_id_seq OWNED BY web.annotation_image.id; + + -- -- Name: entity_profile_image; Type: TABLE; Schema: web; Owner: openatlas -- @@ -1450,6 +1495,13 @@ ALTER TABLE ONLY model.property_i18n ALTER COLUMN id SET DEFAULT nextval('model. ALTER TABLE ONLY model.property_inheritance ALTER COLUMN id SET DEFAULT nextval('model.property_inheritance_id_seq'::regclass); +-- +-- Name: annotation_image id; Type: DEFAULT; Schema: web; Owner: openatlas +-- + +ALTER TABLE ONLY web.annotation_image ALTER COLUMN id SET DEFAULT nextval('web.annotation_image_id_seq'::regclass); + + -- -- Name: entity_profile_image id; Type: DEFAULT; Schema: web; Owner: openatlas -- @@ -1716,6 +1768,14 @@ ALTER TABLE ONLY model.property ADD CONSTRAINT property_pkey PRIMARY KEY (id); +-- +-- Name: annotation_image annotation_image_pkey; Type: CONSTRAINT; Schema: web; Owner: openatlas +-- + +ALTER TABLE ONLY web.annotation_image + ADD CONSTRAINT annotation_image_pkey PRIMARY KEY (id); + + -- -- Name: entity_profile_image entity_profile_image_entity_id_key; Type: CONSTRAINT; Schema: web; Owner: openatlas -- @@ -2221,6 +2281,30 @@ ALTER TABLE ONLY model.property ADD CONSTRAINT property_range_class_code_fkey FOREIGN KEY (range_class_code) REFERENCES model.cidoc_class(code) ON UPDATE CASCADE ON DELETE CASCADE; +-- +-- Name: annotation_image annotation_image_entity_id_fkey; Type: FK CONSTRAINT; Schema: web; Owner: openatlas +-- + +ALTER TABLE ONLY web.annotation_image + ADD CONSTRAINT annotation_image_entity_id_fkey FOREIGN KEY (entity_id) REFERENCES model.entity(id) ON UPDATE CASCADE ON DELETE SET NULL; + + +-- +-- Name: annotation_image annotation_image_image_id_fkey; Type: FK CONSTRAINT; Schema: web; Owner: openatlas +-- + +ALTER TABLE ONLY web.annotation_image + ADD CONSTRAINT annotation_image_image_id_fkey FOREIGN KEY (image_id) REFERENCES model.entity(id) ON UPDATE CASCADE ON DELETE CASCADE; + + +-- +-- Name: annotation_image annotation_image_user_id_fkey; Type: FK CONSTRAINT; Schema: web; Owner: openatlas +-- + +ALTER TABLE ONLY web.annotation_image + ADD CONSTRAINT annotation_image_user_id_fkey FOREIGN KEY (user_id) REFERENCES web."user"(id) ON UPDATE CASCADE ON DELETE SET NULL; + + -- -- Name: entity_profile_image entity_profile_image_entity_id_fkey; Type: FK CONSTRAINT; Schema: web; Owner: openatlas -- diff --git a/openatlas/api/endpoints/iiif.py b/openatlas/api/endpoints/iiif.py index aa353d136..02a1447c5 100644 --- a/openatlas/api/endpoints/iiif.py +++ b/openatlas/api/endpoints/iiif.py @@ -1,12 +1,15 @@ +import math import mimetypes from typing import Any import requests from flask import jsonify, Response, url_for, g from flask_restful import Resource +from shapely.geometry import Polygon from openatlas.api.resources.model_mapper import get_entity_by_id from openatlas.api.resources.util import get_license_name +from openatlas.models.annotation import AnnotationImage from openatlas.models.entity import Entity @@ -14,7 +17,7 @@ class IIIFSequenceV2(Resource): @staticmethod def get(id_: int) -> Response: return jsonify( - {"@context": "https://iiif.io/api/presentation/2/context.json"} | + {"@context": "http://iiif.io/api/presentation/2/context.json"} | IIIFSequenceV2.build_sequence(get_metadata(get_entity_by_id(id_)))) @staticmethod @@ -23,7 +26,6 @@ def build_sequence(metadata: dict[str, Any]) -> dict[str, Any]: "@id": url_for( 'api.iiif_sequence', id_=metadata['entity'].id, - version=2, _external=True), "@type": "sc:Sequence", "label": [{ @@ -37,7 +39,7 @@ class IIIFCanvasV2(Resource): @staticmethod def get(id_: int) -> Response: return jsonify( - {"@context": "https://iiif.io/api/presentation/2/context.json"} | + {"@context": "http://iiif.io/api/presentation/2/context.json"} | IIIFCanvasV2.build_canvas(get_metadata(get_entity_by_id(id_)))) @staticmethod @@ -45,17 +47,22 @@ def build_canvas(metadata: dict[str, Any]) -> dict[str, Any]: entity = metadata['entity'] mime_type, _ = mimetypes.guess_type(g.files[entity.id]) return { - "@id": url_for( - 'api.iiif_canvas', id_=entity.id, version=2, _external=True), + "@id": url_for('api.iiif_canvas', id_=entity.id, _external=True), "@type": "sc:Canvas", "label": entity.name, "height": metadata['img_api']['height'], "width": metadata['img_api']['width'], "description": { - "@value": entity.description, + "@value": entity.description or '', "@language": "en"}, "images": [IIIFImageV2.build_image(metadata)], "related": "", + "otherContent": [{ + "@id": url_for( + 'api.iiif_annotation_list', + id_=entity.id, + _external=True), + "@type": "sc:AnnotationList"}], "thumbnail": { "@id": f'{metadata["img_url"]}/full/!200,200/0/default.jpg', "@type": "dctypes:Image", @@ -79,9 +86,9 @@ def build_image(metadata: dict[str, Any]) -> dict[str, Any]: id_ = metadata['entity'].id mime_type, _ = mimetypes.guess_type(g.files[id_]) return { - "@context": "https://iiif.io/api/presentation/2/context.json", + "@context": "http://iiif.io/api/presentation/2/context.json", "@id": - url_for('api.iiif_image', id_=id_, version=2, _external=True), + url_for('api.iiif_image', id_=id_, _external=True), "@type": "oa:Annotation", "motivation": "sc:painting", "resource": { @@ -95,7 +102,97 @@ def build_image(metadata: dict[str, Any]) -> dict[str, Any]: "height": metadata['img_api']['height'], "width": metadata['img_api']['width']}, "on": - url_for('api.iiif_canvas', id_=id_, version=2, _external=True)} + url_for('api.iiif_canvas', id_=id_, _external=True)} + + +class IIIFAnnotationListV2(Resource): + @staticmethod + def get(id_: int) -> Response: + return jsonify( + IIIFAnnotationListV2.build_annotation_list( + get_metadata(get_entity_by_id(id_)))) + + @staticmethod + def build_annotation_list(metadata: dict[str, Any]) -> dict[str, Any]: + id_ = metadata['entity'].id + return { + "@context": "http://iiif.io/api/presentation/2/context.json", + "@id": url_for( + 'api.iiif_annotation_list', + id_=id_, + _external=True), + "@type": "sc:AnnotationList", + "resources": + [IIIFAnnotationV2.build_annotation(metadata, anno) + for anno in AnnotationImage.get_by_file(id_)]} + + +class IIIFAnnotationV2(Resource): + @staticmethod + def get(id_: int, annotation_id: int) -> Response: + return jsonify( + IIIFImageV2.build_annotation( + get_metadata(get_entity_by_id(id_)), + AnnotationImage.get_by_file(annotation_id))) + + @staticmethod + def build_annotation( + metadata: dict[str, Any], + anno: dict[str, Any]) -> dict[str, Any]: + id_ = metadata['entity'].id + selector = generate_selector(anno) + return { + "@context": "http://iiif.io/api/presentation/2/context.json", + "@id": url_for( + 'api.iiif_annotation', + id_=id_, + annotation_id=anno['id'], + _external=True), + "@type": "oa:Annotation", + "motivation": ["oa:commenting"], + "resource": [{ + "@type": "dctypes:Text", + "chars": anno['annotation'], + "format": "text/html" + }], + "on": { + "@type": "oa:SpecificResource", + "full": url_for('api.iiif_canvas', id_=id_, _external=True), + "selector": selector, + "within": { + "@id": url_for( + 'api.iiif_manifest', + id_=id_, version=2, + _external=True), + "@type": "sc:Manifest"}}} + + +def calculate_fragment_selector_coordinates(coordinates): + coordinates_str = coordinates['coordinates'] + # Splitting the coordinates string into individual values + coordinates_list = list(map(float, coordinates_str.split(','))) + + # Extracting x, y, width, and height from the coordinates + x_min, y_min, x_max, y_max = min(coordinates_list[::2]), min(coordinates_list[1::2]), max(coordinates_list[::2]), max(coordinates_list[1::2]) + x = x_min + y = y_min + width = x_max - x_min + height = y_max - y_min + + return x, y, width, height + +def generate_selector(annotation): + print(annotation) + coordinates = [ + float(coord) for coord in annotation['coordinates'].split(',')] + + x, y, width, height = calculate_fragment_selector_coordinates(annotation) + print(x, y, width, height) + output = { + "@type": "oa:FragmentSelector", + "value": f"xywh={x},{y},{width},{height}"} + + return output class IIIFManifest(Resource): @@ -108,7 +205,7 @@ def get(version: int, id_: int) -> Response: def get_manifest_version_2(id_: int) -> dict[str, Any]: entity = get_entity_by_id(id_) return { - "@context": "https://iiif.io/api/presentation/2/context.json", + "@context": "http://iiif.io/api/presentation/2/context.json", "@id": url_for( 'api.iiif_manifest', @@ -117,9 +214,11 @@ def get_manifest_version_2(id_: int) -> dict[str, Any]: _external=True), "@type": "sc:Manifest", "label": entity.name, - "metadata": [], + "metadata": [{ + "label": "Title", + "value": entity.name}], "description": [{ - "@value": entity.description, + "@value": entity.description or '', "@language": "en"}], "license": get_license_name(entity), "attribution": "By OpenAtlas", @@ -144,6 +243,6 @@ def get_logo() -> dict[str, Any]: filename=g.settings['logo_file_id'], _external=True), "service": { - "@context": "https://iiif.io/api/image/2/context.json", + "@context": "http://iiif.io/api/image/2/context.json", "@id": url_for('overview', _external=True), - "profile": "https://iiif.io/api/image/2/level2.json"}} + "profile": "http://iiif.io/api/image/2/level2.json"}} diff --git a/openatlas/api/routes.py b/openatlas/api/routes.py index ed0236948..0efc2d1fc 100644 --- a/openatlas/api/routes.py +++ b/openatlas/api/routes.py @@ -1,18 +1,19 @@ from flask_restful import Api -from openatlas.api.endpoints.iiif import ( - IIIFManifest, IIIFImageV2, IIIFCanvasV2, IIIFSequenceV2) -from openatlas.api.endpoints.content import ( - ClassMapping, SystemClassCount, GetBackendDetails) -from openatlas.api.endpoints.special import ( - GetGeometricEntities, ExportDatabase, GetSubunits) -from openatlas.api.endpoints.display_image import ( - DisplayImage, LicensedFileOverview) -from openatlas.api.endpoints.entities import ( - GetByCidocClass, GetBySystemClass, GetByViewClass, GetTypeEntitiesAll, - GetEntitiesLinkedToEntity, GetEntity, GetLatest, GetQuery, GetTypeEntities) -from openatlas.api.endpoints.type import ( - GetTypeByViewClass, GetTypeOverview, GetTypeTree) +from openatlas.api.endpoints.iiif import \ + (IIIFManifest, IIIFImageV2, IIIFCanvasV2, IIIFSequenceV2, IIIFAnnotationV2, + IIIFAnnotationListV2) +from openatlas.api.endpoints.content import ClassMapping, \ + SystemClassCount, GetBackendDetails +from openatlas.api.endpoints.special import GetGeometricEntities, \ + ExportDatabase, GetSubunits +from openatlas.api.endpoints.display_image import \ + (DisplayImage, LicensedFileOverview) +from openatlas.api.endpoints.entities import GetByCidocClass, \ + GetBySystemClass, GetByViewClass, GetEntitiesLinkedToEntity, GetEntity, \ + GetLatest, GetQuery, GetTypeEntities, GetTypeEntitiesAll +from openatlas.api.endpoints.type import \ + (GetTypeByViewClass, GetTypeOverview, GetTypeTree) def entity_routes(api: Api) -> None: @@ -113,6 +114,14 @@ def display_routes(api: Api) -> None: IIIFManifest, '/iiif_manifest//', endpoint='iiif_manifest') + api.add_resource( + IIIFAnnotationListV2, + '/iiif_annotation_list/.json', + endpoint='iiif_annotation_list') + api.add_resource( + IIIFAnnotationV2, + '/iiif_annotation//.json', + endpoint='iiif_annotation') api.add_resource( IIIFImageV2, '/iiif_image/.json', diff --git a/openatlas/database/annotation.py b/openatlas/database/annotation.py new file mode 100644 index 000000000..40afd4de3 --- /dev/null +++ b/openatlas/database/annotation.py @@ -0,0 +1,66 @@ +from typing import Optional, Any + +from flask import g + + +class AnnotationImage: + + @staticmethod + def get_by_id(id_: int) -> Optional[dict[str, Any]]: + g.cursor.execute( + """ + SELECT + image_id, + entity_id, + coordinates, + user_id, + annotation, + created + FROM web.annotation_image + WHERE id = %(id)s; + """, + {'id': id_}) + return dict(g.cursor.fetchone()) if g.cursor.rowcount else None + + @staticmethod + def get_by_file(image_id: int) -> list[dict[str, Any]]: + g.cursor.execute( + """ + SELECT + id, + image_id, + entity_id, + coordinates, + user_id, + annotation, + created + FROM web.annotation_image + WHERE image_id = %(image_id)s; + """, + {'image_id': image_id}) + return [dict(row) for row in g.cursor.fetchall()] + + @staticmethod + def insert(data: dict[str, Any]) -> None: + g.cursor.execute( + """ + INSERT INTO web.annotation_image ( + image_id, + entity_id, + coordinates, + user_id, + annotation + ) VALUES ( + %(image_id)s, + %(entity_id)s, + %(coordinates)s, + %(user_id)s, + %(annotation)s); + """, + data) + + @staticmethod + def delete(id_: int) -> None: + g.cursor.execute( + 'DELETE FROM web.annotation_image WHERE id = %(id)s;', + {'id': id_}) diff --git a/openatlas/display/display.py b/openatlas/display/display.py index f5f5e4cf3..20f810c0b 100644 --- a/openatlas/display/display.py +++ b/openatlas/display/display.py @@ -73,6 +73,9 @@ def add_button_others(self) -> None: self.buttons.append(button( _('download'), url_for('download_file', filename=path.name))) + self.buttons.append(button( + _('annotate'), + url_for('annotate_image', id_=self.entity.id))) return self.buttons.append( '' + uc_first(_("missing file")) + '') diff --git a/openatlas/forms/base_manager.py b/openatlas/forms/base_manager.py index f8158e5bf..5c7d238e7 100644 --- a/openatlas/forms/base_manager.py +++ b/openatlas/forms/base_manager.py @@ -83,6 +83,8 @@ class Form(FlaskForm): setattr(Form, 'gis_points', HiddenField(default='[]')) setattr(Form, 'gis_polygons', HiddenField(default='[]')) setattr(Form, 'gis_lines', HiddenField(default='[]')) + if 'annotation' in self.fields: + setattr(Form, 'annotation', HiddenField(default='[]')) self.add_buttons() self.form: Any = Form(obj=self.link_ or self.entity) self.customize_labels() @@ -224,6 +226,9 @@ def process_form(self) -> None: self.data['gis'] = { shape: getattr(self.form, f'gis_{shape}s').data for shape in ['point', 'line', 'polygon']} + if 'annotation' in self.fields: + self.data['annotation'] = getattr(self.form, 'annotation').data + print(self.data['annotation']) def insert_entity(self) -> None: self.entity = Entity.insert(self.class_.name, self.form.name.data) @@ -344,8 +349,8 @@ def get_crumbs(self) -> list[Any]: crumbs = super().get_crumbs() if self.place_info['structure'] and self.origin: if count := len([ - i for i in self.place_info['structure']['siblings'] if - i.class_.name == self.class_.name]): + i for i in self.place_info['structure']['siblings'] if + i.class_.name == self.class_.name]): crumbs[-1] = crumbs[-1] + f' ({count} {_("exists")})' return crumbs diff --git a/openatlas/forms/manager.py b/openatlas/forms/manager.py index a649a0fce..e3e522d06 100644 --- a/openatlas/forms/manager.py +++ b/openatlas/forms/manager.py @@ -261,7 +261,7 @@ def process_form(self) -> None: class FileManager(BaseManager): - fields = ['name', 'description'] + fields = ['name', 'description', 'annotation'] def additional_fields(self) -> dict[str, Any]: fields = {} diff --git a/openatlas/models/annotation.py b/openatlas/models/annotation.py new file mode 100644 index 000000000..343bfc290 --- /dev/null +++ b/openatlas/models/annotation.py @@ -0,0 +1,31 @@ +from typing import Optional, Any + +from flask_login import current_user + +from openatlas.database.annotation import AnnotationImage as Db + + +class AnnotationImage: + @staticmethod + def get_by_id(id_: int) -> Optional[dict[str, Any]]: + return Db.get_by_id(id_) + + @staticmethod + def get_by_file(image_id: int) -> list[dict[str, Any]]: + return Db.get_by_file(image_id) + + @staticmethod + def insert_annotation_image( + image_id: int, + coordinates: str, + annotation: Optional[str] = None) -> None: + Db.insert({ + 'image_id': image_id, + 'user_id': current_user.id, + 'entity_id': None, + 'coordinates': coordinates, + 'annotation': annotation}) + + @staticmethod + def delete(id_: int) -> None: + Db.delete(id_) diff --git a/openatlas/static/js/annotation.js b/openatlas/static/js/annotation.js new file mode 100644 index 000000000..e9d61d5ae --- /dev/null +++ b/openatlas/static/js/annotation.js @@ -0,0 +1,110 @@ +map = L.map('annotate', { + crs: L.CRS.Simple +}); + +let baseLayer; +let drawnItems = new L.FeatureGroup(); + +$.getJSON(iiif_manifest, function (data) { + const page = data.sequences[0].canvases[0]; + baseLayer = L.tileLayer.iiif( + page.images[0].resource.service['@id'] + '/info.json', + { + continuousWorld: false, + fitBounds: true, // Set fitBounds to true for automatic fitting + setMaxBounds: true, + tileSize: 128 + } + ).addTo(map); + + // Get the bounds of the image and fit the map to those bounds + const bounds = [ + [0, 0], // Assuming the image starts from the top-left corner + [page.width, page.height] // Adjust if the coordinates are different + ]; + map.fitBounds(bounds); + // Iterate through annotations and add them to the map + $.getJSON(page.otherContent[0]['@id'], function(annoData) { + const scaleFactor = baseLayer.x / baseLayer._imageSizes[map.getZoom()].x; + $.each(annoData.resources, function(i, value) { + const b = /xywh=(.*)/.exec(value.on.selector.value)[1].split(','); + const minPoint = L.point(parseInt(b[0]) / scaleFactor, parseInt(b[1]) / scaleFactor); + const maxPoint = L.point((parseInt(b[0]) + parseInt(b[2])) / scaleFactor, (parseInt(b[1]) + parseInt(b[3])) / scaleFactor); + const min = map.unproject(minPoint, map.getZoom()); + const max = map.unproject(maxPoint, map.getZoom()); + L.rectangle(L.latLngBounds(min, max)).bindPopup(value.resource[0].chars).addTo(map); + }); + }); +}); + +map.addLayer(drawnItems); + +let drawnGeometries = []; // Array to store drawn geometries + +let drawControl = new L.Control.Draw({ + draw: { + polyline: false, + circle: false, + circlemarker: false, + marker: false, + polygon: { + allowIntersection: false + } + }, + edit: { + featureGroup: drawnItems + } +}); +map.addControl(drawControl); + +map.on('draw:created', function (event) { + // Clear drawn geometries and add the new one + clearDrawnGeometries(); + drawnItems.addLayer(event.layer); + drawnGeometries.push(event.layer); + // Update the input field with the coordinates + updateCoordinatesInput(event); +}); + +// Event handler for when a geometry is edited +map.on('draw:edited', function (event) { + // Update the input field with the coordinates after editing + updateCoordinatesInput(event); +}); + +// Function to update the #coordinate input field with the latest pixel coordinates +function updateCoordinatesInput(event) { + const mapinstance = event.target; + const scaleFactor = baseLayer.x / baseLayer._imageSizes[mapinstance.getZoom()].x; + if (drawnItems.getLayers().length > 0) { + let coordinates = drawnItems.getLayers()[0].getLatLngs()[0].map(latlng => { + // Convert each LatLng to pixel coordinates + const point = mapinstance.project(latlng, mapinstance.getZoom()); + return [ + point.x * scaleFactor, + point.y * scaleFactor + ]; + }); + $('#coordinate').val(coordinates); + } else { + $('#coordinate').val(''); + } +} + + +// To remove all drawn geometries +function clearDrawnGeometries() { + drawnItems.clearLayers(); + drawnGeometries = []; +} + + + +// Event handler for when a geometry is deleted +map.on('draw:deleted', function (event) { + // Clear drawn geometries and update the input field with the remaining coordinates + clearDrawnGeometries(); + updateCoordinatesInput(); +}); + + diff --git a/openatlas/static/package.json b/openatlas/static/package.json index 2e86a9e8c..20da2281b 100644 --- a/openatlas/static/package.json +++ b/openatlas/static/package.json @@ -20,6 +20,7 @@ "jstree": "^3.3.12", "leaflet-imageoverlay-rotated": "^v0.2.1", "leaflet-draw": "^1.0.4", + "leaflet-iiif": "^3.0.0", "leaflet-groupedlayercontrol": "^0.6.1", "leaflet.fullscreen": "2.2.0", "leaflet.markercluster": "^1.5.3", diff --git a/openatlas/templates/annotate.html b/openatlas/templates/annotate.html new file mode 100644 index 000000000..0a562810f --- /dev/null +++ b/openatlas/templates/annotate.html @@ -0,0 +1,19 @@ + + + + +
+ +
+
+ + + + + + + + diff --git a/openatlas/templates/entity/update.html b/openatlas/templates/entity/update.html index 56e0edb88..63ccf946f 100644 --- a/openatlas/templates/entity/update.html +++ b/openatlas/templates/entity/update.html @@ -4,6 +4,9 @@
{{ form|display_form(manual_page='entity/' + entity.class_.view)|safe }} {{ entity.class_.view|display_citation_example|safe }} + {% if entity.class_.view == 'file' %} + {% include 'annotate.html' %} + {% endif %}
{% else %}
@@ -16,7 +19,8 @@
-
+
diff --git a/openatlas/templates/iiif.html b/openatlas/templates/iiif.html index 0d8a46be5..dc029e872 100644 --- a/openatlas/templates/iiif.html +++ b/openatlas/templates/iiif.html @@ -28,4 +28,4 @@ }; Mirador.viewer(config); - \ No newline at end of file + diff --git a/openatlas/views/file.py b/openatlas/views/file.py index b307831f1..a46cf0fd1 100644 --- a/openatlas/views/file.py +++ b/openatlas/views/file.py @@ -1,13 +1,25 @@ +import json from typing import Any, Union -from flask import g, render_template, request, send_from_directory, url_for +from flask import g, render_template, request, send_from_directory, url_for, \ + flash from flask_babel import lazy_gettext as _ +from flask_login import current_user +from flask_wtf import FlaskForm from werkzeug.utils import redirect from werkzeug.wrappers import Response +from werkzeug.exceptions import abort +from wtforms import StringField, TextAreaField +from wtforms.validators import InputRequired from openatlas import app -from openatlas.display.util import required_group, convert_image_to_iiif +from openatlas.display.tab import Tab +from openatlas.display.table import Table +from openatlas.display.util import required_group, convert_image_to_iiif, \ + get_file_path, is_authorized, button, format_date +from openatlas.forms.field import SubmitField from openatlas.forms.form import get_table_form +from openatlas.models.annotation import AnnotationImage from openatlas.models.entity import Entity @@ -85,3 +97,74 @@ def view_iiif(id_: int) -> str: id_=id_, version=g.settings['iiif_version'], _external=True)) + + +class AnnotationForm(FlaskForm): + coordinate = StringField( + _('coordinates'), + validators=[InputRequired()]) + annotation = TextAreaField(_('annotation')) + save = SubmitField(_('save')) + + +@app.route('/annotate_image/', methods=['GET', 'POST']) +@required_group('contributor') +def annotate_image(id_: int) -> str: + entity = Entity.get_by_id(id_, types=True, aliases=True) + if not get_file_path(entity.id): + return abort(404) + table = None + form = AnnotationForm() + if annotations := AnnotationImage.get_by_file(entity.id): + rows = [] + for item in annotations: + delete = '' + if is_authorized('editor') or ( + is_authorized('contributor') + and current_user.id == item['user_id']): + delete = button( + _('delete'), + url_for('delete_annotation', id_=item['id'])) + rows.append([ + format_date(item['created']), + item['annotation'], + item['coordinates'], + delete]) + table = Table( + ['date', 'annotation', 'coordinates', ''], + rows=rows, + order=[[0, 'desc']]) + if form.validate_on_submit(): + # Todo: validate input + AnnotationImage.insert_annotation_image( + image_id=id_, + coordinates=form.coordinate.data, + annotation=form.annotation.data) + return redirect(url_for('annotate_image', id_=entity.id)) + return render_template( + 'tabs.html', + tabs={'annotation': Tab( + 'annotation', + form=form, + table=table, + content=render_template( + 'annotate.html', + entity=entity, + annotation_list=json.dumps(annotations, default=str)))}, + entity=entity, + crumbs=[ + [_('file'), url_for('index', view='file')], + entity, + _('annotate')]) + + +@app.route('/delete_annotation/', methods=['GET', 'POST']) +@required_group('contributor') +def delete_annotation(id_: int) -> str: + annotation = AnnotationImage.get_by_id(id_) + if current_user.group == 'contributor' \ + and annotation['user_id'] != current_user.id: + abort(403) + AnnotationImage.delete(id_) + flash(_('annotation deleted'), 'info') + return redirect(url_for('annotate_image', id_=annotation['image_id']))