From 2020faf01150530dc2cc1eda7b264673d3480090 Mon Sep 17 00:00:00 2001 From: bkis Date: Fri, 3 Jan 2025 16:16:18 +0100 Subject: [PATCH 1/6] Implement #613, untested --- Tekst-API/openapi.json | 589 +++++------------- Tekst-API/tekst/errors.py | 5 +- Tekst-API/tekst/models/browse.py | 33 +- Tekst-API/tekst/models/correction.py | 55 +- Tekst-API/tekst/models/resource.py | 17 +- Tekst-API/tekst/routers/browse.py | 155 ++++- Tekst-API/tekst/routers/corrections.py | 14 +- Tekst-API/tekst/routers/resources.py | 13 +- Tekst-Web/src/api/schema.d.ts | 311 ++------- .../src/components/browse/BookmarksWidget.vue | 6 +- .../browse/BrowseLocationControls.vue | 17 +- .../components/navigation/PrimaryNavBar.vue | 6 +- .../src/components/navigation/TextSelect.vue | 4 +- .../navigation/UserActionsButton.vue | 2 +- .../components/navigation/navMenuOptions.ts | 13 +- .../components/resource/ContentEditWidget.vue | 6 +- .../resource/CorrectionListItem.vue | 6 +- .../resource/CorrectionNoteWidget.vue | 2 +- .../resource/ResourceCoverageWidget.vue | 13 +- .../components/resource/ResourceListItem.vue | 2 +- .../resource/ResourceSettingsWidget.vue | 2 +- .../src/components/search/QuickSearch.vue | 8 +- Tekst-Web/src/composables/init.ts | 4 +- Tekst-Web/src/router.ts | 18 +- Tekst-Web/src/stores/browse.ts | 104 ++-- Tekst-Web/src/stores/search.ts | 8 +- Tekst-Web/src/stores/state.ts | 2 +- Tekst-Web/src/views/BrowseView.vue | 13 +- Tekst-Web/src/views/ContentsView.vue | 96 ++- Tekst-Web/src/views/CorrectionsView.vue | 4 +- Tekst-Web/src/views/ResourceCreateView.vue | 7 +- Tekst-Web/src/views/ResourceSettingsView.vue | 6 +- Tekst-Web/src/views/ResourcesView.vue | 7 +- .../src/views/admin/AdminNewTextView.vue | 2 +- 34 files changed, 610 insertions(+), 940 deletions(-) diff --git a/Tekst-API/openapi.json b/Tekst-API/openapi.json index afb305178..c7934533f 100644 --- a/Tekst-API/openapi.json +++ b/Tekst-API/openapi.json @@ -310,7 +310,7 @@ "browse" ], "summary": "Get location data", - "description": "Returns the location path from the location with the given level/position\nas the last element, up to its most distant ancestor location\non structure level 0 as the first element of an array as well as all contents\nfor the given resource(s) referencing the locations in the location path.", + "description": "Returns the location path from the location with the given ID or text/level/position\nas the last element, up to its most distant ancestor location\non structure level 0 as the first element of an array as well as all contents\nfor the given resource(s) referencing the locations in the location path.", "operationId": "getLocationData", "security": [ { @@ -321,42 +321,79 @@ } ], "parameters": [ + { + "name": "id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "minLength": 24, + "maxLength": 24, + "pattern": "^[0-9a-f]{24}$", + "example": "5eb7cf5a86d9755df3a6c593" + }, + { + "type": "null" + } + ], + "description": "ID of location to request data for", + "title": "Id" + }, + "description": "ID of location to request data for" + }, { "name": "txt", "in": "query", - "required": true, + "required": false, "schema": { - "type": "string", - "minLength": 24, - "maxLength": 24, - "pattern": "^[0-9a-f]{24}$", - "description": "ID of text to look up data for", - "example": "5eb7cf5a86d9755df3a6c593", + "anyOf": [ + { + "type": "string", + "minLength": 24, + "maxLength": 24, + "pattern": "^[0-9a-f]{24}$", + "example": "5eb7cf5a86d9755df3a6c593" + }, + { + "type": "null" + } + ], + "description": "ID of text the target location belongs to (needed if no location ID is given)", "title": "Txt" }, - "description": "ID of text to look up data for" + "description": "ID of text the target location belongs to (needed if no location ID is given)" }, { "name": "lvl", "in": "query", - "required": true, + "required": false, "schema": { - "type": "integer", - "description": "Location level", + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "description": "Location level (only used if no location ID is given, text's default level is used by default)", "title": "Lvl" }, - "description": "Location level" + "description": "Location level (only used if no location ID is given, text's default level is used by default)" }, { "name": "pos", "in": "query", - "required": true, + "required": false, "schema": { "type": "integer", - "description": "Location position", + "description": "Location position (only used if no location ID is given)", + "default": 0, "title": "Pos" }, - "description": "Location position" + "description": "Location position (only used if no location ID is given)" }, { "name": "res", @@ -371,11 +408,11 @@ "pattern": "^[0-9a-f]{24}$", "example": "5eb7cf5a86d9755df3a6c593" }, - "description": "ID (or list of IDs) of resource(s) to return content data for", + "description": "ID (or list of IDs) of resource(s) to return contents for", "default": [], "title": "Res" }, - "description": "ID (or list of IDs) of resource(s) to return content data for" + "description": "ID (or list of IDs) of resource(s) to return contents for" }, { "name": "head", @@ -413,6 +450,16 @@ } } }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TekstErrorModel" + } + } + }, + "description": "Not Found" + }, "422": { "description": "Validation Error", "content": { @@ -426,14 +473,14 @@ } } }, - "/browse/nearest-content-position": { + "/browse/nearest-content-location-id": { "get": { "tags": [ "browse" ], - "summary": "Get nearest content position", - "description": "Finds the nearest location the given resource holds content for and returns\nits position index or -1 if no content was found.", - "operationId": "getNearestContentPosition", + "summary": "Get nearest content location id", + "description": "Finds the nearest location the given resource holds content for and returns\nits ID or an empty string if no more content was found.", + "operationId": "getNearestContentLocationId", "security": [ { "APIKeyCookie": [] @@ -444,15 +491,19 @@ ], "parameters": [ { - "name": "pos", + "name": "loc", "in": "query", "required": true, "schema": { - "type": "integer", - "description": "Location position", - "title": "Pos" + "type": "string", + "minLength": 24, + "maxLength": 24, + "pattern": "^[0-9a-f]{24}$", + "description": "Current content location", + "example": "5eb7cf5a86d9755df3a6c593", + "title": "Loc" }, - "description": "Location position" + "description": "Current content location" }, { "name": "res", @@ -492,8 +543,8 @@ "content": { "application/json": { "schema": { - "type": "integer", - "title": "Response Get Nearest Content Position Browse Nearest Content Position Get" + "type": "string", + "title": "Response Get Nearest Content Location Id Browse Nearest Content Location Id Get" } } } @@ -1238,6 +1289,16 @@ } } }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TekstErrorModel" + } + } + } + }, "422": { "description": "Validation Error", "content": { @@ -7669,62 +7730,6 @@ "title": "Originalid", "description": "If this is a version of another resource, this ID references the original" }, - "ownerId": { - "anyOf": [ - { - "type": "string", - "maxLength": 24, - "minLength": 24, - "pattern": "^[0-9a-f]{24}$", - "example": "5eb7cf5a86d9755df3a6c593" - }, - { - "type": "null" - } - ], - "title": "Ownerid", - "description": "User owning this resource" - }, - "sharedRead": { - "items": { - "type": "string", - "maxLength": 24, - "minLength": 24, - "pattern": "^[0-9a-f]{24}$", - "example": "5eb7cf5a86d9755df3a6c593" - }, - "type": "array", - "maxItems": 64, - "title": "Sharedread", - "description": "Users with shared read access to this resource", - "default": [] - }, - "sharedWrite": { - "items": { - "type": "string", - "maxLength": 24, - "minLength": 24, - "pattern": "^[0-9a-f]{24}$", - "example": "5eb7cf5a86d9755df3a6c593" - }, - "type": "array", - "maxItems": 64, - "title": "Sharedwrite", - "description": "Users with shared write access to this resource", - "default": [] - }, - "public": { - "type": "boolean", - "title": "Public", - "description": "Publication status of this resource", - "default": false - }, - "proposed": { - "type": "boolean", - "title": "Proposed", - "description": "Whether this resource has been proposed for publication", - "default": false - }, "citation": { "anyOf": [ { @@ -7773,13 +7778,6 @@ "defaultCollapsed": false } } - }, - "contentsChangedAt": { - "type": "string", - "format": "date-time", - "title": "Contentschangedat", - "description": "The last time contents of this resource changed", - "default": "1970-01-02T00:00:00" } }, "type": "object", @@ -8847,10 +8845,14 @@ "description": "ID of the resource this correction refers to", "example": "5eb7cf5a86d9755df3a6c593" }, - "position": { - "type": "integer", - "title": "Position", - "description": "Position of the content this correction refers to" + "locationId": { + "type": "string", + "maxLength": 24, + "minLength": 24, + "pattern": "^[0-9a-f]{24}$", + "title": "Locationid", + "description": "ID of the location this correction refers to", + "example": "5eb7cf5a86d9755df3a6c593" }, "note": { "type": "string", @@ -8863,7 +8865,7 @@ "type": "object", "required": [ "resourceId", - "position", + "locationId", "note" ], "title": "CorrectionCreate" @@ -8887,20 +8889,15 @@ "description": "ID of the resource this correction refers to", "example": "5eb7cf5a86d9755df3a6c593" }, - "userId": { + "locationId": { "type": "string", "maxLength": 24, "minLength": 24, "pattern": "^[0-9a-f]{24}$", - "title": "Userid", - "description": "ID of the user who created the correction note", + "title": "Locationid", + "description": "ID of the location this correction refers to", "example": "5eb7cf5a86d9755df3a6c593" }, - "position": { - "type": "integer", - "title": "Position", - "description": "Position of the content this correction refers to" - }, "note": { "type": "string", "maxLength": 1000, @@ -8908,6 +8905,20 @@ "title": "Note", "description": "Content of the correction note" }, + "userId": { + "type": "string", + "maxLength": 24, + "minLength": 24, + "pattern": "^[0-9a-f]{24}$", + "title": "Userid", + "description": "ID of the user who created the correction note", + "example": "5eb7cf5a86d9755df3a6c593" + }, + "position": { + "type": "integer", + "title": "Position", + "description": "Position of the correction on the resource's level" + }, "date": { "type": "string", "format": "date-time", @@ -8928,9 +8939,10 @@ "required": [ "id", "resourceId", + "locationId", + "note", "userId", "position", - "note", "date", "locationLabels" ], @@ -9416,62 +9428,6 @@ "title": "Originalid", "description": "If this is a version of another resource, this ID references the original" }, - "ownerId": { - "anyOf": [ - { - "type": "string", - "maxLength": 24, - "minLength": 24, - "pattern": "^[0-9a-f]{24}$", - "example": "5eb7cf5a86d9755df3a6c593" - }, - { - "type": "null" - } - ], - "title": "Ownerid", - "description": "User owning this resource" - }, - "sharedRead": { - "items": { - "type": "string", - "maxLength": 24, - "minLength": 24, - "pattern": "^[0-9a-f]{24}$", - "example": "5eb7cf5a86d9755df3a6c593" - }, - "type": "array", - "maxItems": 64, - "title": "Sharedread", - "description": "Users with shared read access to this resource", - "default": [] - }, - "sharedWrite": { - "items": { - "type": "string", - "maxLength": 24, - "minLength": 24, - "pattern": "^[0-9a-f]{24}$", - "example": "5eb7cf5a86d9755df3a6c593" - }, - "type": "array", - "maxItems": 64, - "title": "Sharedwrite", - "description": "Users with shared write access to this resource", - "default": [] - }, - "public": { - "type": "boolean", - "title": "Public", - "description": "Publication status of this resource", - "default": false - }, - "proposed": { - "type": "boolean", - "title": "Proposed", - "description": "Whether this resource has been proposed for publication", - "default": false - }, "citation": { "anyOf": [ { @@ -9520,13 +9476,6 @@ "defaultCollapsed": false } } - }, - "contentsChangedAt": { - "type": "string", - "format": "date-time", - "title": "Contentschangedat", - "description": "The last time contents of this resource changed", - "default": "1970-01-02T00:00:00" } }, "type": "object", @@ -10515,67 +10464,11 @@ "title": "Originalid", "description": "If this is a version of another resource, this ID references the original" }, - "ownerId": { + "citation": { "anyOf": [ { "type": "string", - "maxLength": 24, - "minLength": 24, - "pattern": "^[0-9a-f]{24}$", - "example": "5eb7cf5a86d9755df3a6c593" - }, - { - "type": "null" - } - ], - "title": "Ownerid", - "description": "User owning this resource" - }, - "sharedRead": { - "items": { - "type": "string", - "maxLength": 24, - "minLength": 24, - "pattern": "^[0-9a-f]{24}$", - "example": "5eb7cf5a86d9755df3a6c593" - }, - "type": "array", - "maxItems": 64, - "title": "Sharedread", - "description": "Users with shared read access to this resource", - "default": [] - }, - "sharedWrite": { - "items": { - "type": "string", - "maxLength": 24, - "minLength": 24, - "pattern": "^[0-9a-f]{24}$", - "example": "5eb7cf5a86d9755df3a6c593" - }, - "type": "array", - "maxItems": 64, - "title": "Sharedwrite", - "description": "Users with shared write access to this resource", - "default": [] - }, - "public": { - "type": "boolean", - "title": "Public", - "description": "Publication status of this resource", - "default": false - }, - "proposed": { - "type": "boolean", - "title": "Proposed", - "description": "Whether this resource has been proposed for publication", - "default": false - }, - "citation": { - "anyOf": [ - { - "type": "string", - "maxLength": 1000 + "maxLength": 1000 }, { "type": "null" @@ -10619,13 +10512,6 @@ "defaultCollapsed": true } } - }, - "contentsChangedAt": { - "type": "string", - "format": "date-time", - "title": "Contentschangedat", - "description": "The last time contents of this resource changed", - "default": "1970-01-02T00:00:00" } }, "type": "object", @@ -11152,14 +11038,14 @@ }, "LocationCoverage": { "properties": { + "locId": { + "type": "string", + "title": "Locid" + }, "label": { "type": "string", "title": "Label" }, - "position": { - "type": "integer", - "title": "Position" - }, "covered": { "type": "boolean", "title": "Covered", @@ -11168,8 +11054,8 @@ }, "type": "object", "required": [ - "label", - "position" + "locId", + "label" ], "title": "LocationCoverage" }, @@ -11257,8 +11143,41 @@ }, "type": "array", "title": "Locationpath", + "description": "Path of locations from level 0 to this location", "default": [] }, + "prev": { + "anyOf": [ + { + "type": "string", + "maxLength": 24, + "minLength": 24, + "pattern": "^[0-9a-f]{24}$", + "example": "5eb7cf5a86d9755df3a6c593" + }, + { + "type": "null" + } + ], + "title": "Prev", + "description": "ID of the preceding location on the same level" + }, + "next": { + "anyOf": [ + { + "type": "string", + "maxLength": 24, + "minLength": 24, + "pattern": "^[0-9a-f]{24}$", + "example": "5eb7cf5a86d9755df3a6c593" + }, + { + "type": "null" + } + ], + "title": "Next", + "description": "ID of the subsequent location on the same level" + }, "contents": { "items": { "oneOf": [ @@ -11295,6 +11214,7 @@ }, "type": "array", "title": "Contents", + "description": "Contents of various resources on this location", "default": [] } }, @@ -11947,62 +11867,6 @@ "title": "Originalid", "description": "If this is a version of another resource, this ID references the original" }, - "ownerId": { - "anyOf": [ - { - "type": "string", - "maxLength": 24, - "minLength": 24, - "pattern": "^[0-9a-f]{24}$", - "example": "5eb7cf5a86d9755df3a6c593" - }, - { - "type": "null" - } - ], - "title": "Ownerid", - "description": "User owning this resource" - }, - "sharedRead": { - "items": { - "type": "string", - "maxLength": 24, - "minLength": 24, - "pattern": "^[0-9a-f]{24}$", - "example": "5eb7cf5a86d9755df3a6c593" - }, - "type": "array", - "maxItems": 64, - "title": "Sharedread", - "description": "Users with shared read access to this resource", - "default": [] - }, - "sharedWrite": { - "items": { - "type": "string", - "maxLength": 24, - "minLength": 24, - "pattern": "^[0-9a-f]{24}$", - "example": "5eb7cf5a86d9755df3a6c593" - }, - "type": "array", - "maxItems": 64, - "title": "Sharedwrite", - "description": "Users with shared write access to this resource", - "default": [] - }, - "public": { - "type": "boolean", - "title": "Public", - "description": "Publication status of this resource", - "default": false - }, - "proposed": { - "type": "boolean", - "title": "Proposed", - "description": "Whether this resource has been proposed for publication", - "default": false - }, "citation": { "anyOf": [ { @@ -12062,13 +11926,6 @@ "enabled": false } } - }, - "contentsChangedAt": { - "type": "string", - "format": "date-time", - "title": "Contentschangedat", - "description": "The last time contents of this resource changed", - "default": "1970-01-02T00:00:00" } }, "type": "object", @@ -13789,62 +13646,6 @@ "title": "Originalid", "description": "If this is a version of another resource, this ID references the original" }, - "ownerId": { - "anyOf": [ - { - "type": "string", - "maxLength": 24, - "minLength": 24, - "pattern": "^[0-9a-f]{24}$", - "example": "5eb7cf5a86d9755df3a6c593" - }, - { - "type": "null" - } - ], - "title": "Ownerid", - "description": "User owning this resource" - }, - "sharedRead": { - "items": { - "type": "string", - "maxLength": 24, - "minLength": 24, - "pattern": "^[0-9a-f]{24}$", - "example": "5eb7cf5a86d9755df3a6c593" - }, - "type": "array", - "maxItems": 64, - "title": "Sharedread", - "description": "Users with shared read access to this resource", - "default": [] - }, - "sharedWrite": { - "items": { - "type": "string", - "maxLength": 24, - "minLength": 24, - "pattern": "^[0-9a-f]{24}$", - "example": "5eb7cf5a86d9755df3a6c593" - }, - "type": "array", - "maxItems": 64, - "title": "Sharedwrite", - "description": "Users with shared write access to this resource", - "default": [] - }, - "public": { - "type": "boolean", - "title": "Public", - "description": "Publication status of this resource", - "default": false - }, - "proposed": { - "type": "boolean", - "title": "Proposed", - "description": "Whether this resource has been proposed for publication", - "default": false - }, "citation": { "anyOf": [ { @@ -13893,13 +13694,6 @@ "defaultCollapsed": true } } - }, - "contentsChangedAt": { - "type": "string", - "format": "date-time", - "title": "Contentschangedat", - "description": "The last time contents of this resource changed", - "default": "1970-01-02T00:00:00" } }, "type": "object", @@ -14998,62 +14792,6 @@ "title": "Originalid", "description": "If this is a version of another resource, this ID references the original" }, - "ownerId": { - "anyOf": [ - { - "type": "string", - "maxLength": 24, - "minLength": 24, - "pattern": "^[0-9a-f]{24}$", - "example": "5eb7cf5a86d9755df3a6c593" - }, - { - "type": "null" - } - ], - "title": "Ownerid", - "description": "User owning this resource" - }, - "sharedRead": { - "items": { - "type": "string", - "maxLength": 24, - "minLength": 24, - "pattern": "^[0-9a-f]{24}$", - "example": "5eb7cf5a86d9755df3a6c593" - }, - "type": "array", - "maxItems": 64, - "title": "Sharedread", - "description": "Users with shared read access to this resource", - "default": [] - }, - "sharedWrite": { - "items": { - "type": "string", - "maxLength": 24, - "minLength": 24, - "pattern": "^[0-9a-f]{24}$", - "example": "5eb7cf5a86d9755df3a6c593" - }, - "type": "array", - "maxItems": 64, - "title": "Sharedwrite", - "description": "Users with shared write access to this resource", - "default": [] - }, - "public": { - "type": "boolean", - "title": "Public", - "description": "Publication status of this resource", - "default": false - }, - "proposed": { - "type": "boolean", - "title": "Proposed", - "description": "Whether this resource has been proposed for publication", - "default": false - }, "citation": { "anyOf": [ { @@ -15104,13 +14842,6 @@ "annotationGroups": [], "multiValueDelimiter": "/" } - }, - "contentsChangedAt": { - "type": "string", - "format": "date-time", - "title": "Contentschangedat", - "description": "The last time contents of this resource changed", - "default": "1970-01-02T00:00:00" } }, "type": "object", diff --git a/Tekst-API/tekst/errors.py b/Tekst-API/tekst/errors.py index 3421cdcc2..9ac2809d8 100644 --- a/Tekst-API/tekst/errors.py +++ b/Tekst-API/tekst/errors.py @@ -310,7 +310,10 @@ def update_values( E_400_INVALID_LEVEL = error_instance( status_code=status.HTTP_400_BAD_REQUEST, key="locationInvalidLevel", - msg="The level index passed does not exist in target text", + msg=( + "The level index passed is invalid or doesn't " + "match the level of a referenced object" + ), ) E_400_LOCATION_NO_LEVEL_NOR_PARENT = error_instance( diff --git a/Tekst-API/tekst/models/browse.py b/Tekst-API/tekst/models/browse.py index d5837ca62..cf22519e4 100644 --- a/Tekst-API/tekst/models/browse.py +++ b/Tekst-API/tekst/models/browse.py @@ -1,8 +1,37 @@ +from typing import Annotated + +from beanie import PydanticObjectId +from pydantic import Field + from tekst.models.common import ModelBase from tekst.models.location import LocationRead from tekst.resources import AnyContentRead class LocationData(ModelBase): - location_path: list[LocationRead] = [] - contents: list[AnyContentRead] = [] + location_path: Annotated[ + list[LocationRead], + Field( + description="Path of locations from level 0 to this location", + ), + ] = [] + previous_loc_id: Annotated[ + PydanticObjectId | None, + Field( + description="ID of the preceding location on the same level", + alias="prev", + ), + ] = None + next_loc_id: Annotated[ + PydanticObjectId | None, + Field( + description="ID of the subsequent location on the same level", + alias="next", + ), + ] = None + contents: Annotated[ + list[AnyContentRead], + Field( + description="Contents of various resources on this location", + ), + ] = [] diff --git a/Tekst-API/tekst/models/correction.py b/Tekst-API/tekst/models/correction.py index 171f7068a..615261bee 100644 --- a/Tekst-API/tekst/models/correction.py +++ b/Tekst-API/tekst/models/correction.py @@ -5,6 +5,7 @@ from tekst.models.common import ( DocumentBase, + ExcludeFromModelVariants, ModelBase, ModelFactoryMixin, PydanticObjectId, @@ -19,16 +20,10 @@ class Correction(ModelBase, ModelFactoryMixin): description="ID of the resource this correction refers to", ), ] - user_id: Annotated[ + location_id: Annotated[ PydanticObjectId, Field( - description="ID of the user who created the correction note", - ), - ] - position: Annotated[ - int, - Field( - description="Position of the content this correction refers to", + description="ID of the location this correction refers to", ), ] note: Annotated[ @@ -43,44 +38,33 @@ class Correction(ModelBase, ModelFactoryMixin): ), val.CleanupMultiline, ] - date: Annotated[ - datetime, - Field( - description="Date when the correction was created", - ), - ] - location_labels: Annotated[ - list[str], - Field( - description="Text location labels from root to target location", - ), - ] - - -class CorrectionCreate(ModelBase): - resource_id: Annotated[ + user_id: Annotated[ PydanticObjectId, Field( - description="ID of the resource this correction refers to", + description="ID of the user who created the correction note", ), + ExcludeFromModelVariants(create=True), ] position: Annotated[ int, Field( - description="Position of the content this correction refers to", + description="Position of the correction on the resource's level", ), + ExcludeFromModelVariants(create=True), ] - note: Annotated[ - str, + date: Annotated[ + datetime, Field( - description="Content of the correction note", + description="Date when the correction was created", ), - StringConstraints( - min_length=1, - max_length=1000, - strip_whitespace=True, + ExcludeFromModelVariants(create=True), + ] + location_labels: Annotated[ + list[str], + Field( + description="Text location labels from root to target location", ), - val.CleanupMultiline, + ExcludeFromModelVariants(create=True), ] @@ -89,8 +73,9 @@ class Settings(DocumentBase.Settings): name = "corrections" indexes = [ "resource_id", - "position", + "location_id", ] +CorrectionCreate = Correction.create_model() CorrectionRead = Correction.read_model() diff --git a/Tekst-API/tekst/models/resource.py b/Tekst-API/tekst/models/resource.py index 70c28b41e..abd2c1410 100644 --- a/Tekst-API/tekst/models/resource.py +++ b/Tekst-API/tekst/models/resource.py @@ -154,6 +154,7 @@ class ResourceBase(ModelBase, ModelFactoryMixin): ), ExcludeFromModelVariants( update=True, + create=True, ), ] = None @@ -163,6 +164,9 @@ class ResourceBase(ModelBase, ModelFactoryMixin): description="Users with shared read access to this resource", max_length=64, ), + ExcludeFromModelVariants( + create=True, + ), ] = [] shared_write: Annotated[ @@ -171,6 +175,9 @@ class ResourceBase(ModelBase, ModelFactoryMixin): description="Users with shared write access to this resource", max_length=64, ), + ExcludeFromModelVariants( + create=True, + ), ] = [] public: Annotated[ @@ -180,6 +187,7 @@ class ResourceBase(ModelBase, ModelFactoryMixin): ), ExcludeFromModelVariants( update=True, + create=True, ), ] = False @@ -190,6 +198,7 @@ class ResourceBase(ModelBase, ModelFactoryMixin): ), ExcludeFromModelVariants( update=True, + create=True, ), ] = False @@ -230,6 +239,7 @@ class ResourceBase(ModelBase, ModelFactoryMixin): ), ExcludeFromModelVariants( update=True, + create=True, ), ] = datetime.utcfromtimestamp(86400) @@ -395,13 +405,14 @@ async def __precompute_coverage_data(self) -> None: coverage_per_parent = {} covered_locations_count = 0 for location in data: + loc_id = str(location["id"]) parent_id = str(location["parent_id"]) if parent_id not in coverage_per_parent: coverage_per_parent[parent_id] = [] coverage_per_parent[parent_id].append( { - "label": location_labels[str(location["id"])], - "position": location["position"], + "loc_id": loc_id, + "label": location_labels[loc_id], "covered": location["covered"], } ) @@ -562,8 +573,8 @@ class ResourceReadExtras(ModelBase): class LocationCoverage(ModelBase): + loc_id: str label: str - position: int covered: bool = False diff --git a/Tekst-API/tekst/routers/browse.py b/Tekst-API/tekst/routers/browse.py index 507784c6c..60c2302d4 100644 --- a/Tekst-API/tekst/routers/browse.py +++ b/Tekst-API/tekst/routers/browse.py @@ -15,6 +15,7 @@ from tekst.models.resource import ( ResourceBaseDocument, ) +from tekst.models.text import TextDocument from tekst.resources import AnyContentRead @@ -111,20 +112,53 @@ async def get_content_siblings( "/location-data", response_model=LocationData, status_code=status.HTTP_200_OK, + responses=errors.responses( + [ + errors.E_404_LOCATION_NOT_FOUND, + ] + ), ) async def get_location_data( user: OptionalUserDep, + location_id: Annotated[ + PydanticObjectId | None, + Query( + alias="id", + description="ID of location to request data for", + ), + ] = None, text_id: Annotated[ - PydanticObjectId, - Query(alias="txt", description="ID of text to look up data for"), - ], - level: Annotated[int, Query(alias="lvl", description="Location level")], - position: Annotated[int, Query(alias="pos", description="Location position")], + PydanticObjectId | None, + Query( + alias="txt", + description=( + "ID of text the target location belongs to " + "(needed if no location ID is given)" + ), + ), + ] = None, + level: Annotated[ + int | None, + Query( + alias="lvl", + description=( + "Location level (only used if no location ID is given, " + "text's default level is used by default)" + ), + ), + ] = None, + position: Annotated[ + int, + Query( + alias="pos", + description="Location position (only used if no location ID is given)", + ), + ] = 0, resource_ids: Annotated[ list[PydanticObjectId], Query( alias="res", - description="ID (or list of IDs) of resource(s) to return content data for", + description="ID (or list of IDs) of resource(s) to return contents for", ), ] = [], only_head_contents: Annotated[ @@ -134,21 +168,35 @@ async def get_location_data( description="Only return contents for the head location of the path", ), ] = False, - limit: Annotated[int, Query(description="Return at most contents")] = 4096, + limit: Annotated[ + int, + Query( + description="Return at most contents", + ), + ] = 4096, ) -> LocationData: """ - Returns the location path from the location with the given level/position + Returns the location path from the location with the given ID or text/level/position as the last element, up to its most distant ancestor location on structure level 0 as the first element of an array as well as all contents for the given resource(s) referencing the locations in the location path. """ - location_doc = await LocationDocument.find_one( - LocationDocument.text_id == text_id, - LocationDocument.level == level, - LocationDocument.position == position, - ) + # find target location + location_doc = None + if location_id: + location_doc = await LocationDocument.get(location_id) + elif text_id: + text_doc = await TextDocument.get(text_id) + if text_doc: + lvl = level if level is not None else text_doc.default_level + location_doc = await LocationDocument.find_one( + LocationDocument.text_id == text_id, + LocationDocument.level == lvl, + LocationDocument.position == position or 0, + ) if not location_doc: - return LocationData() + raise errors.E_404_LOCATION_NOT_FOUND + # construct path up to root location location_path = [location_doc] parent_id = location_doc.parent_id @@ -181,12 +229,45 @@ async def get_location_data( .to_list() ) - # return combined data as LocationData - return LocationData(location_path=location_path, contents=content_docs) + # return combined data as LocationData, + # including IDs of preceding and subsequent locations + prev_loc = ( + await LocationDocument.find_one( + LocationDocument.text_id == location_doc.text_id, + LocationDocument.level == location_doc.level, + LocationDocument.position == location_doc.position - 1, + ) + ) or ( + await LocationDocument.find( + LocationDocument.text_id == location_doc.text_id, + LocationDocument.level == location_doc.level, + ) + .sort(-LocationDocument.position) + .first_or_none() + ) + next_loc = ( + await LocationDocument.find_one( + LocationDocument.text_id == location_doc.text_id, + LocationDocument.level == location_doc.level, + LocationDocument.position == location_doc.position + 1, + ) + ) or ( + await LocationDocument.find_one( + LocationDocument.text_id == location_doc.text_id, + LocationDocument.level == location_doc.level, + LocationDocument.position == 0, + ) + ) + return LocationData( + location_path=location_path, + previous_loc_id=prev_loc.id if prev_loc else None, + next_loc_id=next_loc.id if next_loc else None, + contents=content_docs, + ) @router.get( - "/nearest-content-position", + "/nearest-content-location-id", status_code=status.HTTP_200_OK, responses=errors.responses( [ @@ -194,9 +275,15 @@ async def get_location_data( ] ), ) -async def get_nearest_content_position( +async def get_nearest_content_location_id( user: OptionalUserDep, - position: Annotated[int, Query(alias="pos", description="Location position")], + location_id: Annotated[ + PydanticObjectId, + Query( + alias="loc", + description="Current content location", + ), + ], resource_id: Annotated[ PydanticObjectId, Query( @@ -213,25 +300,32 @@ async def get_nearest_content_position( ) ), ] = "subsequent", -) -> int: +) -> str: """ Finds the nearest location the given resource holds content for and returns - its position index or -1 if no content was found. + its ID or an empty string if no more content was found. """ - # we don't check read access here, because are passing data to the location-data - # endpoint later anyway and it already checks for permissions - resource_doc = await ResourceBaseDocument.get(resource_id, with_children=True) + resource_doc = await ResourceBaseDocument.find_one( + await ResourceBaseDocument.access_conditions_read(user), + with_children=True, + ) if not resource_doc: raise errors.E_404_RESOURCE_NOT_FOUND + location_doc = await LocationDocument.get(location_id) + if not location_doc: + raise errors.E_404_LOCATION_NOT_FOUND + if not location_doc.level == resource_doc.level: + raise errors.E_400_INVALID_LEVEL + # get all locations before/after said location locations = ( await LocationDocument.find( LocationDocument.text_id == resource_doc.text_id, LocationDocument.level == resource_doc.level, - (LocationDocument.position < position) + (LocationDocument.position < location_doc.position) if mode == "preceding" - else (LocationDocument.position > position), + else (LocationDocument.position > location_doc.position), ) .sort( +LocationDocument.position @@ -258,7 +352,7 @@ async def get_nearest_content_position( .to_list() ) if not contents: # pragma: no cover - return -1 + return "" # find out nearest of those locations with contents locations = [ @@ -267,8 +361,11 @@ async def get_nearest_content_position( if location.get("_id") in [content.get("location_id") for content in contents] ] - # return position of nearest location with contents of the target resource - return locations[0].get("position") + if len(locations) == 0: # pragma: no cover + return "" + + # return ID of nearest location with contents of the target resource + return str(locations[0].get("_id")) @router.get( diff --git a/Tekst-API/tekst/routers/corrections.py b/Tekst-API/tekst/routers/corrections.py index 61091ce1f..6fc19ea6f 100644 --- a/Tekst-API/tekst/routers/corrections.py +++ b/Tekst-API/tekst/routers/corrections.py @@ -31,6 +31,7 @@ [ errors.E_404_RESOURCE_NOT_FOUND, errors.E_404_CONTENT_NOT_FOUND, + errors.E_400_INVALID_LEVEL, ] ), ) @@ -50,13 +51,11 @@ async def create_correction( raise errors.E_404_RESOURCE_NOT_FOUND # get location, check if it is valid - location_doc = await LocationDocument.find_one( - LocationDocument.text_id == resource_doc.text_id, - LocationDocument.level == resource_doc.level, - LocationDocument.position == correction.position, - ) + location_doc = await LocationDocument.get(correction.location_id) if not location_doc: raise errors.E_404_CONTENT_NOT_FOUND + if not resource_doc.level == location_doc.level: + raise errors.E_400_INVALID_LEVEL # construct full label location_labels = [location_doc.label] @@ -96,9 +95,10 @@ async def create_correction( # create correction return await CorrectionDocument( resource_id=correction.resource_id, - user_id=user.id, - position=correction.position, + location_id=correction.location_id, + position=location_doc.position, note=correction.note, + user_id=user.id, date=datetime.utcnow(), location_labels=location_labels, ).create() diff --git a/Tekst-API/tekst/routers/resources.py b/Tekst-API/tekst/routers/resources.py index 2a064d6ff..4debd08d5 100644 --- a/Tekst-API/tekst/routers/resources.py +++ b/Tekst-API/tekst/routers/resources.py @@ -167,21 +167,16 @@ async def create_resource( if resource.level > len(text.levels) - 1: raise errors.E_400_RESOURCE_INVALID_LEVEL - # force some values on creation - resource.owner_id = user.id - resource.proposed = False - resource.public = False - resource.shared_read = [] - resource.shared_write = [] - # find document model for this resource type, instantiate, create resource_doc = ( - await resource_types_mgr.get(resource.resource_type) + resource_types_mgr.get(resource.resource_type) .resource_model() .document_model() .model_from(resource) - .create() ) + resource_doc.owner_id = user.id # set correct owner ID + await resource_doc.create() # create resource in DB + return await preprocess_resource_read(resource_doc, user) diff --git a/Tekst-Web/src/api/schema.d.ts b/Tekst-Web/src/api/schema.d.ts index 0eb1770a7..e5b558411 100644 --- a/Tekst-Web/src/api/schema.d.ts +++ b/Tekst-Web/src/api/schema.d.ts @@ -74,7 +74,7 @@ export interface paths { }; /** * Get location data - * @description Returns the location path from the location with the given level/position + * @description Returns the location path from the location with the given ID or text/level/position * as the last element, up to its most distant ancestor location * on structure level 0 as the first element of an array as well as all contents * for the given resource(s) referencing the locations in the location path. @@ -88,7 +88,7 @@ export interface paths { patch?: never; trace?: never; }; - '/browse/nearest-content-position': { + '/browse/nearest-content-location-id': { parameters: { query?: never; header?: never; @@ -96,11 +96,11 @@ export interface paths { cookie?: never; }; /** - * Get nearest content position + * Get nearest content location id * @description Finds the nearest location the given resource holds content for and returns - * its position index or -1 if no content was found. + * its ID or an empty string if no more content was found. */ - get: operations['getNearestContentPosition']; + get: operations['getNearestContentLocationId']; put?: never; post?: never; delete?: never; @@ -1502,35 +1502,6 @@ export interface components { * @description If this is a version of another resource, this ID references the original */ originalId?: string | null; - /** - * Ownerid - * @description User owning this resource - */ - ownerId?: string | null; - /** - * Sharedread - * @description Users with shared read access to this resource - * @default [] - */ - sharedRead: string[]; - /** - * Sharedwrite - * @description Users with shared write access to this resource - * @default [] - */ - sharedWrite: string[]; - /** - * Public - * @description Publication status of this resource - * @default false - */ - public: boolean; - /** - * Proposed - * @description Whether this resource has been proposed for publication - * @default false - */ - proposed: boolean; /** * Citation * @description Citation details for this resource @@ -1561,13 +1532,6 @@ export interface components { * } * } */ config: components['schemas']['AudioResourceConfig']; - /** - * Contentschangedat - * Format: date-time - * @description The last time contents of this resource changed - * @default 1970-01-02T00:00:00 - */ - contentsChangedAt: string; }; /** AudioResourceRead */ AudioResourceRead: { @@ -2061,10 +2025,11 @@ export interface components { */ resourceId: string; /** - * Position - * @description Position of the content this correction refers to + * Locationid + * @description ID of the location this correction refers to + * @example 5eb7cf5a86d9755df3a6c593 */ - position: number; + locationId: string; /** * Note * @description Content of the correction note @@ -2084,6 +2049,17 @@ export interface components { * @example 5eb7cf5a86d9755df3a6c593 */ resourceId: string; + /** + * Locationid + * @description ID of the location this correction refers to + * @example 5eb7cf5a86d9755df3a6c593 + */ + locationId: string; + /** + * Note + * @description Content of the correction note + */ + note: string; /** * Userid * @description ID of the user who created the correction note @@ -2092,14 +2068,9 @@ export interface components { userId: string; /** * Position - * @description Position of the content this correction refers to + * @description Position of the correction on the resource's level */ position: number; - /** - * Note - * @description Content of the correction note - */ - note: string; /** * Date * Format: date-time @@ -2355,35 +2326,6 @@ export interface components { * @description If this is a version of another resource, this ID references the original */ originalId?: string | null; - /** - * Ownerid - * @description User owning this resource - */ - ownerId?: string | null; - /** - * Sharedread - * @description Users with shared read access to this resource - * @default [] - */ - sharedRead: string[]; - /** - * Sharedwrite - * @description Users with shared write access to this resource - * @default [] - */ - sharedWrite: string[]; - /** - * Public - * @description Publication status of this resource - * @default false - */ - public: boolean; - /** - * Proposed - * @description Whether this resource has been proposed for publication - * @default false - */ - proposed: boolean; /** * Citation * @description Citation details for this resource @@ -2414,13 +2356,6 @@ export interface components { * } * } */ config: components['schemas']['ExternalReferencesResourceConfig']; - /** - * Contentschangedat - * Format: date-time - * @description The last time contents of this resource changed - * @default 1970-01-02T00:00:00 - */ - contentsChangedAt: string; }; /** ExternalReferencesResourceRead */ ExternalReferencesResourceRead: { @@ -2894,35 +2829,6 @@ export interface components { * @description If this is a version of another resource, this ID references the original */ originalId?: string | null; - /** - * Ownerid - * @description User owning this resource - */ - ownerId?: string | null; - /** - * Sharedread - * @description Users with shared read access to this resource - * @default [] - */ - sharedRead: string[]; - /** - * Sharedwrite - * @description Users with shared write access to this resource - * @default [] - */ - sharedWrite: string[]; - /** - * Public - * @description Publication status of this resource - * @default false - */ - public: boolean; - /** - * Proposed - * @description Whether this resource has been proposed for publication - * @default false - */ - proposed: boolean; /** * Citation * @description Citation details for this resource @@ -2953,13 +2859,6 @@ export interface components { * } * } */ config: components['schemas']['ImagesResourceConfig']; - /** - * Contentschangedat - * Format: date-time - * @description The last time contents of this resource changed - * @default 1970-01-02T00:00:00 - */ - contentsChangedAt: string; }; /** ImagesResourceRead */ ImagesResourceRead: { @@ -3187,10 +3086,10 @@ export interface components { LocaleKey: 'deDE' | 'enUS'; /** LocationCoverage */ LocationCoverage: { + /** Locid */ + locId: string; /** Label */ label: string; - /** Position */ - position: number; /** * Covered * @default false @@ -3235,11 +3134,23 @@ export interface components { LocationData: { /** * Locationpath + * @description Path of locations from level 0 to this location * @default [] */ locationPath: components['schemas']['LocationRead'][]; + /** + * Prev + * @description ID of the preceding location on the same level + */ + prev?: string | null; + /** + * Next + * @description ID of the subsequent location on the same level + */ + next?: string | null; /** * Contents + * @description Contents of various resources on this location * @default [] */ contents: ( @@ -3541,35 +3452,6 @@ export interface components { * @description If this is a version of another resource, this ID references the original */ originalId?: string | null; - /** - * Ownerid - * @description User owning this resource - */ - ownerId?: string | null; - /** - * Sharedread - * @description Users with shared read access to this resource - * @default [] - */ - sharedRead: string[]; - /** - * Sharedwrite - * @description Users with shared write access to this resource - * @default [] - */ - sharedWrite: string[]; - /** - * Public - * @description Publication status of this resource - * @default false - */ - public: boolean; - /** - * Proposed - * @description Whether this resource has been proposed for publication - * @default false - */ - proposed: boolean; /** * Citation * @description Citation details for this resource @@ -3611,13 +3493,6 @@ export interface components { * } * } */ config: components['schemas']['PlainTextResourceConfig']; - /** - * Contentschangedat - * Format: date-time - * @description The last time contents of this resource changed - * @default 1970-01-02T00:00:00 - */ - contentsChangedAt: string; }; /** PlainTextResourceRead */ PlainTextResourceRead: { @@ -4430,35 +4305,6 @@ export interface components { * @description If this is a version of another resource, this ID references the original */ originalId?: string | null; - /** - * Ownerid - * @description User owning this resource - */ - ownerId?: string | null; - /** - * Sharedread - * @description Users with shared read access to this resource - * @default [] - */ - sharedRead: string[]; - /** - * Sharedwrite - * @description Users with shared write access to this resource - * @default [] - */ - sharedWrite: string[]; - /** - * Public - * @description Publication status of this resource - * @default false - */ - public: boolean; - /** - * Proposed - * @description Whether this resource has been proposed for publication - * @default false - */ - proposed: boolean; /** * Citation * @description Citation details for this resource @@ -4489,13 +4335,6 @@ export interface components { * } * } */ config: components['schemas']['RichTextResourceConfig']; - /** - * Contentschangedat - * Format: date-time - * @description The last time contents of this resource changed - * @default 1970-01-02T00:00:00 - */ - contentsChangedAt: string; }; /** RichTextResourceRead */ RichTextResourceRead: { @@ -5004,35 +4843,6 @@ export interface components { * @description If this is a version of another resource, this ID references the original */ originalId?: string | null; - /** - * Ownerid - * @description User owning this resource - */ - ownerId?: string | null; - /** - * Sharedread - * @description Users with shared read access to this resource - * @default [] - */ - sharedRead: string[]; - /** - * Sharedwrite - * @description Users with shared write access to this resource - * @default [] - */ - sharedWrite: string[]; - /** - * Public - * @description Publication status of this resource - * @default false - */ - public: boolean; - /** - * Proposed - * @description Whether this resource has been proposed for publication - * @default false - */ - proposed: boolean; /** * Citation * @description Citation details for this resource @@ -5065,13 +4875,6 @@ export interface components { * "multiValueDelimiter": "/" * } */ config: components['schemas']['TextAnnotationResourceConfig']; - /** - * Contentschangedat - * Format: date-time - * @description The last time contents of this resource changed - * @default 1970-01-02T00:00:00 - */ - contentsChangedAt: string; }; /** TextAnnotationResourceRead */ TextAnnotationResourceRead: { @@ -6006,14 +5809,16 @@ export interface operations { }; getLocationData: { parameters: { - query: { - /** @description ID of text to look up data for */ - txt: string; - /** @description Location level */ - lvl: number; - /** @description Location position */ - pos: number; - /** @description ID (or list of IDs) of resource(s) to return content data for */ + query?: { + /** @description ID of location to request data for */ + id?: string | null; + /** @description ID of text the target location belongs to (needed if no location ID is given) */ + txt?: string | null; + /** @description Location level (only used if no location ID is given, text's default level is used by default) */ + lvl?: number | null; + /** @description Location position (only used if no location ID is given) */ + pos?: number; + /** @description ID (or list of IDs) of resource(s) to return contents for */ res?: string[]; /** @description Only return contents for the head location of the path */ head?: boolean; @@ -6035,6 +5840,15 @@ export interface operations { 'application/json': components['schemas']['LocationData']; }; }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['TekstErrorModel']; + }; + }; /** @description Validation Error */ 422: { headers: { @@ -6046,11 +5860,11 @@ export interface operations { }; }; }; - getNearestContentPosition: { + getNearestContentLocationId: { parameters: { query: { - /** @description Location position */ - pos: number; + /** @description Current content location */ + loc: string; /** @description ID of resource to return nearest location with content for */ res: string; /** @description Whether to look for the nearest preceding or subsequent location with content */ @@ -6068,7 +5882,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': number; + 'application/json': string; }; }; /** @description Not Found */ @@ -6448,6 +6262,15 @@ export interface operations { 'application/json': components['schemas']['CorrectionRead']; }; }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['TekstErrorModel']; + }; + }; /** @description Not Found */ 404: { headers: { diff --git a/Tekst-Web/src/components/browse/BookmarksWidget.vue b/Tekst-Web/src/components/browse/BookmarksWidget.vue index ab5572e36..7eaa6c186 100644 --- a/Tekst-Web/src/components/browse/BookmarksWidget.vue +++ b/Tekst-Web/src/components/browse/BookmarksWidget.vue @@ -73,8 +73,10 @@ async function handleBookmarkSelect(bookmark: BookmarkRead) { showModal.value = false; router.replace({ name: 'browse', - params: { text: pfData.value?.texts.find((t) => t.id === bookmark.textId)?.slug || '' }, - query: { lvl: bookmark.level, pos: bookmark.position }, + params: { + textSlug: pfData.value?.texts.find((t) => t.id === bookmark.textId)?.slug || '', + locId: bookmark.locationId, + }, }); } diff --git a/Tekst-Web/src/components/browse/BrowseLocationControls.vue b/Tekst-Web/src/components/browse/BrowseLocationControls.vue index 9adcee503..dc8325f42 100644 --- a/Tekst-Web/src/components/browse/BrowseLocationControls.vue +++ b/Tekst-Web/src/components/browse/BrowseLocationControls.vue @@ -32,12 +32,13 @@ const { ArrowLeft, ArrowRight } = useMagicKeys(); const showLocationSelectModal = ref(false); function gotoPosition(direction: 'prev' | 'next') { - const targetPos = browse.position + (direction === 'prev' ? -1 : 1); + const targetLocId = direction === 'prev' ? browse.prevLocationId : browse.nextLocationId; + if (!targetLocId) return; router.replace({ - ...route, - query: { - ...route.query, - pos: targetPos >= 0 ? targetPos : 0, + name: 'browse', + params: { + ...route.params, + locId: targetLocId, }, }); emit('navigate'); @@ -50,8 +51,7 @@ function handleLocationSelect(locationPath: LocationRead[]) { name: 'browse', params: { ...route.params }, query: { - lvl: selectedLocation.level, - pos: selectedLocation.position, + loc: selectedLocation.id, }, }); emit('navigate'); @@ -71,11 +71,11 @@ whenever(ArrowRight, () => {