From f21a01bbc0ab81d3635b73481d6e222160f90d1f Mon Sep 17 00:00:00 2001 From: IRBorisov <8611739+IRBorisov@users.noreply.github.com> Date: Thu, 21 Sep 2023 23:09:51 +0300 Subject: [PATCH] Refactor backend using drf-spectacular --- README.md | 2 +- rsconcept/backend/apps/rsform/serializers.py | 7 + rsconcept/backend/apps/rsform/views.py | 127 +++++++++++ rsconcept/backend/apps/users/serializers.py | 2 + rsconcept/backend/apps/users/views.py | 16 ++ rsconcept/backend/mypy.ini | 2 +- rsconcept/backend/project/settings.py | 44 ++-- rsconcept/backend/project/urls.py | 11 +- rsconcept/backend/requirements.txt | 1 + rsconcept/backend/requirements_dev.txt | 2 +- rsconcept/frontend/src/models/language.ts | 200 +++++++++++++----- .../src/pages/RSFormPage/DlgEditTerm.tsx | 131 +++++++----- rsconcept/frontend/src/utils/color.ts | 27 +++ rsconcept/frontend/src/utils/constants.ts | 2 +- rsconcept/frontend/src/utils/selectors.ts | 4 +- 15 files changed, 445 insertions(+), 133 deletions(-) diff --git a/README.md b/README.md index 0aa2914b..6b3b11ae 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,7 @@ This readme file is used mostly to document project dependencies - djangorestframework - django-cors-headers - django-filter + - drf-spectacular - tzdata - gunicorn - coreapi @@ -72,7 +73,6 @@ This readme file is used mostly to document project dependencies - coverage - pylint - mypy - - django-stubs[compatible-mypy] - djangorestframework-stubs[compatible-mypy] diff --git a/rsconcept/backend/apps/rsform/serializers.py b/rsconcept/backend/apps/rsform/serializers.py index 9c8e5ba8..4107cd1b 100644 --- a/rsconcept/backend/apps/rsform/serializers.py +++ b/rsconcept/backend/apps/rsform/serializers.py @@ -27,6 +27,11 @@ class ExpressionSerializer(serializers.Serializer): expression = serializers.CharField() +class ResultTextSerializer(serializers.Serializer): + ''' Serializer: Text result of a function call. ''' + result = serializers.CharField() + + class TextSerializer(serializers.Serializer): ''' Serializer: Text with references. ''' text = serializers.CharField() @@ -379,6 +384,7 @@ class CstRenameSerializer(serializers.ModelSerializer): class CstListSerializer(serializers.Serializer): ''' Serializer: List of constituents from one origin. ''' + # TODO: fix schema items = serializers.ListField( child=CstStandaloneSerializer() ) @@ -403,6 +409,7 @@ class CstMoveSerializer(CstListSerializer): class ResolverSerializer(serializers.Serializer): ''' Serializer: Resolver results serializer. ''' + # TODO: add schema def to_representation(self, instance: Resolver) -> dict: return { 'input': instance.input, diff --git a/rsconcept/backend/apps/rsform/views.py b/rsconcept/backend/apps/rsform/views.py index 02ca5103..5dac29b2 100644 --- a/rsconcept/backend/apps/rsform/views.py +++ b/rsconcept/backend/apps/rsform/views.py @@ -9,6 +9,7 @@ from rest_framework import views, viewsets, filters, generics, permissions from rest_framework.decorators import action from rest_framework.response import Response from rest_framework.decorators import api_view +from drf_spectacular.utils import extend_schema, extend_schema_view import pyconcept from . import models as m @@ -16,6 +17,8 @@ from . import serializers as s from . import utils +@extend_schema(tags=['Library']) +@extend_schema_view() class LibraryActiveView(generics.ListAPIView): ''' Endpoint: Get list of rsforms available for active user. ''' permission_classes = (permissions.AllowAny,) @@ -32,6 +35,8 @@ class LibraryActiveView(generics.ListAPIView): return m.LibraryItem.objects.filter(is_common=True).order_by('-time_update') +@extend_schema(tags=['Constituenta']) +@extend_schema_view() class ConstituentAPIView(generics.RetrieveUpdateAPIView): ''' Endpoint: Get / Update Constituenta. ''' queryset = m.Constituenta.objects.all() @@ -45,7 +50,10 @@ class ConstituentAPIView(generics.RetrieveUpdateAPIView): result.append(utils.SchemaOwnerOrAdmin()) return result + # pylint: disable=too-many-ancestors +@extend_schema(tags=['Library']) +@extend_schema_view() class LibraryViewSet(viewsets.ModelViewSet): ''' Endpoint: Library operations. ''' queryset = m.LibraryItem.objects.all() @@ -76,6 +84,12 @@ class LibraryViewSet(viewsets.ModelViewSet): def _get_item(self) -> m.LibraryItem: return cast(m.LibraryItem, self.get_object()) + # TODO: response schema + @extend_schema( + request=s.LibraryItemSerializer, + summary='clone item including contents', + tags=['Library'] + ) @transaction.atomic @action(detail=True, methods=['post'], url_path='clone') def clone(self, request, pk): @@ -99,6 +113,12 @@ class LibraryViewSet(viewsets.ModelViewSet): return Response(status=201, data=s.RSFormParseSerializer(new_schema).data) return Response(status=404) + @extend_schema( + request=None, + responses={200: s.LibraryItemSerializer}, + summary='claim item', + tags=['Library'] + ) @transaction.atomic @action(detail=True, methods=['post']) def claim(self, request, pk=None): @@ -112,6 +132,12 @@ class LibraryViewSet(viewsets.ModelViewSet): m.Subscription.subscribe(user=item.owner, item=item) return Response(status=200, data=s.LibraryItemSerializer(item).data) + @extend_schema( + request=None, + responses={200: None}, + summary='subscribe to item', + tags=['Library'] + ) @action(detail=True, methods=['post']) def subscribe(self, request, pk): ''' Endpoint: Subscribe current user to item. ''' @@ -119,6 +145,12 @@ class LibraryViewSet(viewsets.ModelViewSet): m.Subscription.subscribe(user=self.request.user, item=item) return Response(status=200) + @extend_schema( + request=None, + responses={200: None}, + summary='unsubscribe from item', + tags=['Library'] + ) @action(detail=True, methods=['delete']) def unsubscribe(self, request, pk): ''' Endpoint: Unsubscribe current user from item. ''' @@ -127,6 +159,8 @@ class LibraryViewSet(viewsets.ModelViewSet): return Response(status=200) +@extend_schema(tags=['RSForm']) +@extend_schema_view() class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.RetrieveAPIView): ''' Endpoint: RSForm operations. ''' queryset = m.LibraryItem.objects.filter(item_type=m.LibraryItemType.RSFORM) @@ -144,6 +178,12 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr permission_classes = [permissions.AllowAny] return [permission() for permission in permission_classes] + # TODO: response schema + @extend_schema( + request=s.CstCreateSerializer, + summary='create constituenta', + tags=['Constituenta'] + ) @action(detail=True, methods=['post'], url_path='cst-create') def cst_create(self, request, pk): ''' Create new constituenta. ''' @@ -160,6 +200,12 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr response['Location'] = new_cst.get_absolute_url() return response + # TODO: response schema + @extend_schema( + request=s.CstRenameSerializer, + summary='rename constituenta', + tags=['Constituenta'] + ) @transaction.atomic @action(detail=True, methods=['patch'], url_path='cst-rename') def cst_rename(self, request, pk): @@ -178,6 +224,12 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr 'schema': s.RSFormParseSerializer(schema).data }) + # TODO: response schema + @extend_schema( + request=s.CstListSerializer, + summary='delete constituents', + tags=['Constituenta'] + ) @action(detail=True, methods=['patch'], url_path='cst-multidelete') def cst_multidelete(self, request, pk): ''' Endpoint: Delete multiple constituents. ''' @@ -188,6 +240,12 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr schema.item.refresh_from_db() return Response(status=202, data=s.RSFormParseSerializer(schema).data) + # TODO: response schema + @extend_schema( + request=s.CstMoveSerializer, + summary='move constituenta', + tags=['Constituenta'] + ) @action(detail=True, methods=['patch'], url_path='cst-moveto') def cst_moveto(self, request, pk): ''' Endpoint: Move multiple constituents. ''' @@ -198,6 +256,12 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr schema.item.refresh_from_db() return Response(status=200, data=s.RSFormParseSerializer(schema).data) + # TODO: response schema + @extend_schema( + request=None, + summary='reset aliases, update expressions and references', + tags=['RSForm'] + ) @action(detail=True, methods=['patch'], url_path='reset-aliases') def reset_aliases(self, request, pk): ''' Endpoint: Recreate all aliases based on order. ''' @@ -205,6 +269,12 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr schema.reset_aliases() return Response(status=200, data=s.RSFormParseSerializer(schema).data) + # TODO: response schema + @extend_schema( + request=s.RSFormUploadSerializer, + summary='load data from TRS file', + tags=['RSForm'] + ) @action(detail=True, methods=['patch'], url_path='load-trs') def load_trs(self, request, pk): ''' Endpoint: Load data from file and replace current schema. ''' @@ -220,12 +290,24 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr schema = serializer.save() return Response(status=200, data=s.RSFormParseSerializer(schema).data) + # TODO: response schema + @extend_schema( + request=None, + summary='get all constituents data from DB', + tags=['RSForm'] + ) @action(detail=True, methods=['get']) def contents(self, request, pk): ''' Endpoint: View schema db contents (including constituents). ''' schema = s.RSFormSerializer(self._get_schema()).data return Response(schema) + # TODO: response schema + @extend_schema( + request=None, + summary='get all constituents data and parses', + tags=['RSForm'] + ) @action(detail=True, methods=['get']) def details(self, request, pk): ''' Endpoint: Detailed schema view including statuses and parse. ''' @@ -233,6 +315,12 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr serializer = s.RSFormParseSerializer(schema) return Response(serializer.data) + # TODO: response schema + @extend_schema( + request=s.ExpressionSerializer, + summary='check RSLang expression', + tags=['RSForm', 'Functions'] + ) @action(detail=True, methods=['post']) def check(self, request, pk): ''' Endpoint: Check RSLang expression against schema context. ''' @@ -243,6 +331,12 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr result = pyconcept.check_expression(json.dumps(schema.data), expression) return Response(json.loads(result)) + @extend_schema( + request=s.TextSerializer, + responses={200: s.ResolverSerializer}, + summary='resolve text with references', + tags=['RSForm', 'Functions'] + ) @action(detail=True, methods=['post']) def resolve(self, request, pk): ''' Endpoint: Resolve refenrces in text against schema terms context. ''' @@ -253,6 +347,12 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr resolver.resolve(text) return Response(status=200, data=s.ResolverSerializer(resolver).data) + # TODO: create a proper file response schema + @extend_schema( + responses={(200, 'application/zip'): bytes}, + summary='export as TRS file', + tags=['RSForm'] + ) @action(detail=True, methods=['get'], url_path='export-trs') def export_trs(self, request, pk): ''' Endpoint: Download Exteor compatible file. ''' @@ -274,6 +374,10 @@ class TrsImportView(views.APIView): ''' Endpoint: Upload RS form in Exteor format. ''' serializer_class = s.FileSerializer + @extend_schema( + summary='import TRS file into RSForm', + tags=['RSForm'] + ) def post(self, request): data = utils.read_trs(request.FILES['file'].file) owner = self.request.user @@ -287,6 +391,10 @@ class TrsImportView(views.APIView): return Response(status=201, data=result.data) +@extend_schema( + summary='create new RSForm empty or from file', + tags=['RSForm'] +) @api_view(['POST']) def create_rsform(request): ''' Endpoint: Create RSForm from user input and/or trs file. ''' @@ -334,6 +442,13 @@ def _prepare_rsform_data(data: dict, request, owner: m.User): is_canonical = request.data['is_canonical'] == 'true' data['is_canonical'] = is_canonical + +# TODO: define schema for response +@extend_schema( + request=s.ExpressionSerializer, + summary='RS expression into Syntax Tree', + tags=['Functions'] +) @api_view(['POST']) def parse_expression(request): ''' Endpoint: Parse RS expression. ''' @@ -344,6 +459,12 @@ def parse_expression(request): return Response(json.loads(result)) +@extend_schema( + request=s.ExpressionSerializer, + responses={200: s.ResultTextSerializer}, + summary='Unicode syntax to ASCII TeX', + tags=['Functions'] +) @api_view(['POST']) def convert_to_ascii(request): ''' Endpoint: Convert expression to ASCII syntax. ''' @@ -354,6 +475,12 @@ def convert_to_ascii(request): return Response({'result': result}) +@extend_schema( + request=s.ExpressionSerializer, + responses={200: s.ResultTextSerializer}, + summary='ASCII TeX syntax to Unicode symbols', + tags=['Functions'] +) @api_view(['POST']) def convert_to_math(request): ''' Endpoint: Convert expression to MATH syntax. ''' diff --git a/rsconcept/backend/apps/users/serializers.py b/rsconcept/backend/apps/users/serializers.py index 6ddc6618..e1b6607f 100644 --- a/rsconcept/backend/apps/users/serializers.py +++ b/rsconcept/backend/apps/users/serializers.py @@ -9,6 +9,7 @@ from . import models class LoginSerializer(serializers.Serializer): ''' Serializer: User authentification by login/password. ''' + # TODO: declare schema username = serializers.CharField( label='Имя пользователя', write_only=True @@ -43,6 +44,7 @@ class LoginSerializer(serializers.Serializer): class AuthSerializer(serializers.Serializer): ''' Serializer: Authentication data. ''' + # TODO: declare schema def to_representation(self, instance: models.User) -> dict: if instance.is_anonymous: return { diff --git a/rsconcept/backend/apps/users/views.py b/rsconcept/backend/apps/users/views.py index 6cd097e8..04d5a47e 100644 --- a/rsconcept/backend/apps/users/views.py +++ b/rsconcept/backend/apps/users/views.py @@ -3,10 +3,14 @@ from django.contrib.auth import login, logout from rest_framework import status, permissions, views, generics from rest_framework.response import Response +from drf_spectacular.utils import extend_schema, extend_schema_view from . import serializers from . import models + +@extend_schema(tags=['Auth']) +@extend_schema_view() class LoginAPIView(views.APIView): ''' Endpoint: Login via username + password. ''' permission_classes = (permissions.AllowAny,) @@ -22,6 +26,8 @@ class LoginAPIView(views.APIView): return Response(None, status=status.HTTP_202_ACCEPTED) +@extend_schema(tags=['Auth']) +@extend_schema_view() class LogoutAPIView(views.APIView): ''' Endpoint: Logout. ''' permission_classes = (permissions.IsAuthenticated,) @@ -31,12 +37,16 @@ class LogoutAPIView(views.APIView): return Response(None, status=status.HTTP_204_NO_CONTENT) +@extend_schema(tags=['User']) +@extend_schema_view() class SignupAPIView(generics.CreateAPIView): ''' Endpoint: Register user. ''' permission_classes = (permissions.AllowAny, ) serializer_class = serializers.SignupSerializer +@extend_schema(tags=['Auth']) +@extend_schema_view() class AuthAPIView(generics.RetrieveAPIView): ''' Endpoint: Current user info. ''' permission_classes = (permissions.AllowAny,) @@ -46,6 +56,8 @@ class AuthAPIView(generics.RetrieveAPIView): return self.request.user +@extend_schema(tags=['User']) +@extend_schema_view() class ActiveUsersView(generics.ListAPIView): ''' Endpoint: Get list of active users. ''' permission_classes = (permissions.AllowAny,) @@ -55,6 +67,8 @@ class ActiveUsersView(generics.ListAPIView): return models.User.objects.filter(is_active=True) +@extend_schema(tags=['User']) +@extend_schema_view() class UserProfileAPIView(generics.RetrieveUpdateAPIView): ''' Endpoint: User profile. ''' permission_classes = (permissions.IsAuthenticated,) @@ -64,6 +78,8 @@ class UserProfileAPIView(generics.RetrieveUpdateAPIView): return self.request.user +@extend_schema(tags=['Auth']) +@extend_schema_view() class UpdatePassword(views.APIView): ''' Endpoint: Change password for current user. ''' permission_classes = (permissions.IsAuthenticated, ) diff --git a/rsconcept/backend/mypy.ini b/rsconcept/backend/mypy.ini index 98caa4a0..f3e51989 100644 --- a/rsconcept/backend/mypy.ini +++ b/rsconcept/backend/mypy.ini @@ -4,7 +4,7 @@ warn_return_any = True warn_unused_configs = True -plugins = mypy_drf_plugin.main, mypy_django_plugin.main +plugins = mypy_django_plugin.main # Per-module options: [mypy.plugins.django-stubs] diff --git a/rsconcept/backend/project/settings.py b/rsconcept/backend/project/settings.py index f6ee3343..c38a3fb6 100644 --- a/rsconcept/backend/project/settings.py +++ b/rsconcept/backend/project/settings.py @@ -1,4 +1,4 @@ -""" +''' Django settings for project. Generated by 'django-admin startproject' using Django 4.1.7. @@ -8,7 +8,7 @@ https://docs.djangoproject.com/en/4.1/topics/settings/ For the full list of settings and their values, see https://docs.djangoproject.com/en/4.1/ref/settings/ -""" +''' import os from pathlib import Path @@ -27,10 +27,7 @@ SECRET_KEY = os.environ.get('SECRET_KEY', 'not-a-secret') DEBUG = os.environ.get('DEBUG', True) in [True, 'True', '1'] ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS', '*').split(';') - -INTERNAL_IPS = [ - "127.0.0.1", -] +INTERNAL_IPS = ['127.0.0.1'] if DEBUG else [] INSTALLED_APPS = [ @@ -47,12 +44,14 @@ INSTALLED_APPS = [ 'apps.users', 'apps.rsform', + + 'drf_spectacular', ] REST_FRAMEWORK = { 'TEST_REQUEST_DEFAULT_FORMAT': 'json', - 'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.coreapi.AutoSchema', + 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', 'DEFAULT_AUTHENTICATION_CLASSES': [ 'rest_framework.authentication.SessionAuthentication', ], @@ -64,7 +63,7 @@ REST_FRAMEWORK = { ], } -# CORS_ORIGIN_ALLOW_ALL = True + CORS_ALLOW_CREDENTIALS = True CORS_ALLOWED_ORIGINS = os.environ.get('CORS_ALLOWED_ORIGINS', 'http://localhost:3000').split(';') CSRF_TRUSTED_ORIGINS = os.environ.get('CSRF_TRUSTED_ORIGINS', 'http://localhost:3000').split(';') @@ -74,8 +73,6 @@ if _domain != '': CSRF_COOKIE_DOMAIN = _domain SESSION_COOKIE_DOMAIN = _domain -# CSRF_HEADER_NAME = 'HTTP_X_CSRFTOKEN' - MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', @@ -93,6 +90,16 @@ LOGIN_URL = '/admin/login/' LOGIN_REDIRECT_URL = '/' LOGOUT_REDIRECT_URL = '/' + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/4.1/howto/static-files/ + +STATIC_ROOT = os.environ.get('STATIC_ROOT', os.path.join(BASE_DIR, 'static')) +STATIC_URL = 'static/' +MEDIA_ROOT = os.environ.get('MEDIA_ROOT', os.path.join(BASE_DIR, 'media')) +MEDIA_URL = 'media/' + + TEMPLATES = [ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', @@ -126,6 +133,15 @@ DATABASES = { } } +# drf-spectacular settings. API docs generator +# https://drf-spectacular.readthedocs.io/en/latest/settings.html +SPECTACULAR_SETTINGS = { + 'TITLE': 'ConceptPortal API', + 'DESCRIPTION': 'Портал для работы с экспликациями концептуальных схем', + 'VERSION': '0.1.0', + 'SERVE_INCLUDE_SCHEMA': False, +} + # Password validation # https://docs.djangoproject.com/en/4.1/ref/settings/#auth-password-validators @@ -156,14 +172,6 @@ USE_I18N = True USE_TZ = True -# Static files (CSS, JavaScript, Images) -# https://docs.djangoproject.com/en/4.1/howto/static-files/ - -STATIC_ROOT = os.environ.get('STATIC_ROOT', os.path.join(BASE_DIR, 'static')) -STATIC_URL = 'static/' -MEDIA_ROOT = os.environ.get('MEDIA_ROOT', os.path.join(BASE_DIR, 'media')) -MEDIA_URL = 'media/' - # Default primary key field type # https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field diff --git a/rsconcept/backend/project/urls.py b/rsconcept/backend/project/urls.py index 204b737d..f83ac539 100644 --- a/rsconcept/backend/project/urls.py +++ b/rsconcept/backend/project/urls.py @@ -1,15 +1,18 @@ ''' Main URL router ''' -from rest_framework.documentation import include_docs_urls from django.contrib import admin from django.shortcuts import redirect from django.urls import path, include from django.conf import settings from django.conf.urls.static import static +from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView + urlpatterns = [ - path('admin/', admin.site.urls), + path('admin', admin.site.urls), path('api/', include('apps.rsform.urls')), path('users/', include('apps.users.urls')), - path('docs/', include_docs_urls(title='ConceptPortal API'), name='docs'), - path('', lambda request: redirect('docs/', permanent=True)), + path('docs', SpectacularSwaggerView.as_view(), name='docs'), + path('schema', SpectacularAPIView.as_view(), name='schema'), + path('redoc', SpectacularRedocView.as_view()), + path('', lambda: redirect('docs', permanent=True)), ] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) diff --git a/rsconcept/backend/requirements.txt b/rsconcept/backend/requirements.txt index ee52da28..cd174f04 100644 --- a/rsconcept/backend/requirements.txt +++ b/rsconcept/backend/requirements.txt @@ -3,6 +3,7 @@ django djangorestframework django-cors-headers django-filter +drf-spectacular coreapi pymorphy2 pymorphy2-dicts-ru diff --git a/rsconcept/backend/requirements_dev.txt b/rsconcept/backend/requirements_dev.txt index 7a1cfcb7..9bbbb405 100644 --- a/rsconcept/backend/requirements_dev.txt +++ b/rsconcept/backend/requirements_dev.txt @@ -3,6 +3,7 @@ django djangorestframework django-cors-headers django-filter +drf-spectacular coreapi pymorphy2 pymorphy2-dicts-ru @@ -12,5 +13,4 @@ razdel mypy pylint coverage -django-stubs[compatible-mypy] djangorestframework-stubs[compatible-mypy] \ No newline at end of file diff --git a/rsconcept/frontend/src/models/language.ts b/rsconcept/frontend/src/models/language.ts index cb687616..21856263 100644 --- a/rsconcept/frontend/src/models/language.ts +++ b/rsconcept/frontend/src/models/language.ts @@ -1,80 +1,52 @@ // Module: Natural language model declarations. - -// ====== Morphology ======== +/** + * Represents single unit of language Morphology. +*/ export enum Grammeme { // Неизвестная граммема UNKN = 'UNKN', // Части речи - NOUN = 'NOUN', - ADJF = 'ADJF', - ADJS = 'ADJS', - COMP = 'COMP', - VERB = 'VERB', - INFN = 'INFN', - PRTF = 'PRTF', - PRTS = 'PRTS', - GRND = 'GRND', - NUMR = 'NUMR', - ADVB = 'ADVB', - NPRO = 'NPRO', - PRED = 'PRED', - PREP = 'PREP', - CONJ = 'CONJ', - PRCL = 'PRCL', + NOUN = 'NOUN', ADJF = 'ADJF', ADJS = 'ADJS', COMP = 'COMP', + VERB = 'VERB', INFN = 'INFN', PRTF = 'PRTF', PRTS = 'PRTS', + GRND = 'GRND', NUMR = 'NUMR', ADVB = 'ADVB', NPRO = 'NPRO', + PRED = 'PRED', PREP = 'PREP', CONJ = 'CONJ', PRCL = 'PRCL', INTJ = 'INTJ', // Одушевленность - anim = 'anim', - inan = 'inan', + anim = 'anim', inan = 'inan', // Род - masc = 'masc', - femn = 'femn', - neut = 'neut', + masc = 'masc', femn = 'femn', neut = 'neut', // Число - sing = 'sing', - plur = 'plur', + sing = 'sing', plur = 'plur', // Падеж (основные) - nomn = 'nomn', - gent = 'gent', - datv = 'datv', - accs = 'accs', - ablt = 'ablt', - loct = 'loct', + nomn = 'nomn', gent = 'gent', datv = 'datv', + accs = 'accs', ablt = 'ablt', loct = 'loct', // Совершенный / несовершенный вид - perf = 'perf', - impf = 'impf', + perf = 'perf', impf = 'impf', // Переходность - tran = 'tran', - intr = 'intr', + tran = 'tran', intr = 'intr', // Время - pres = 'pres', - past = 'past', - futr = 'futr', + pres = 'pres', past = 'past', futr = 'futr', // Лицо - per1 = '1per', - per2 = '2per', - per3 = '3per', + per1 = '1per', per2 = '2per', per3 = '3per', // Наклонение - indc = 'indc', - impr = 'impr', + indc = 'indc', impr = 'impr', // Включение говорящего в действие - incl = 'incl', - excl = 'excl', + incl = 'incl', excl = 'excl', // Залог - actv = 'actv', - pssv = 'pssv', + actv = 'actv', pssv = 'pssv', // Стиль речи Infr = 'Infr', // Неформальный @@ -86,6 +58,11 @@ export enum Grammeme { Abbr = 'Abbr' } +/** + * Represents part of speech language concept. + * + * Implemented as a list of mututally exclusive {@link Grammeme}s. +*/ export const PartOfSpeech = [ Grammeme.NOUN, Grammeme.ADJF, Grammeme.ADJS, Grammeme.COMP, Grammeme.VERB, Grammeme.INFN, Grammeme.PRTF, Grammeme.PRTS, @@ -93,40 +70,106 @@ export const PartOfSpeech = [ Grammeme.PREP, Grammeme.CONJ, Grammeme.PRCL, Grammeme.INTJ ]; +/** + * Represents gender language concept. + * + * Implemented as a list of mututally exclusive {@link Grammeme}s. +*/ export const Gender = [ Grammeme.masc, Grammeme.femn, Grammeme.neut ]; +/** + * Represents case language concept. + * + * Implemented as a list of mututally exclusive {@link Grammeme}s. +*/ export const Case = [ Grammeme.nomn, Grammeme.gent, Grammeme.datv, Grammeme.accs, Grammeme.ablt, Grammeme.loct ]; +/** + * Represents plurality language concept. + * + * Implemented as a list of mututally exclusive {@link Grammeme}s. +*/ export const Plurality = [ Grammeme.sing, Grammeme.plur ]; +/** + * Represents verb perfectivity language concept. + * + * Implemented as a list of mututally exclusive {@link Grammeme}s. +*/ export const Perfectivity = [ Grammeme.perf, Grammeme.impf ]; + +/** + * Represents verb transitivity language concept. + * + * Implemented as a list of mututally exclusive {@link Grammeme}s. +*/ export const Transitivity = [ Grammeme.tran, Grammeme.intr ]; + +/** + * Represents verb mood language concept. + * + * Implemented as a list of mututally exclusive {@link Grammeme}s. +*/ export const Mood = [ Grammeme.indc, Grammeme.impr ]; + +/** + * Represents verb self-inclusion language concept. + * + * Implemented as a list of mututally exclusive {@link Grammeme}s. +*/ export const Inclusion = [ Grammeme.incl, Grammeme.excl ]; + +/** + * Represents verb voice language concept. + * + * Implemented as a list of mututally exclusive {@link Grammeme}s. +*/ export const Voice = [ Grammeme.actv, Grammeme.pssv ]; +/** + * Represents verb tense language concept. + * + * Implemented as a list of mututally exclusive {@link Grammeme}s. +*/ export const Tense = [ Grammeme.pres, Grammeme.past, Grammeme.futr ]; +/** + * Represents verb person language concept. + * + * Implemented as a list of mututally exclusive {@link Grammeme}s. +*/ export const Person = [ Grammeme.per1, Grammeme.per2, Grammeme.per3 ]; +/** + * Represents complete list of language concepts. + * + * Implemented as a list of lists of {@link Grammeme}s. +*/ export const GrammemeGroups = [ PartOfSpeech, Gender, Case, Plurality, Perfectivity, Transitivity, Mood, Inclusion, Voice, Tense, Person ]; +/** + * Represents NOUN-ish list of language concepts. + * + * Represented concepts can be target of inflection or coalition in a sentence. + * + * Implemented as a list of lists of {@link Grammeme}s. +*/ export const NounGrams = [ Grammeme.NOUN, Grammeme.ADJF, Grammeme.ADJS, ...Gender, @@ -134,6 +177,13 @@ export const NounGrams = [ ...Plurality ]; +/** + * Represents VERB-ish list of language concepts. + * + * Represented concepts can be target of inflection or coalition in a sentence. + * + * Implemented as a list of lists of {@link Grammeme}s. +*/ export const VerbGrams = [ Grammeme.VERB, Grammeme.INFN, Grammeme.PRTF, Grammeme.PRTS, ...Perfectivity, @@ -145,18 +195,45 @@ export const VerbGrams = [ ...Person ]; -// Grammeme parse data +/** + * Represents {@link Grammeme} parse data. +*/ export interface IGramData { type: Grammeme data: string } -// Equality comparator for IGramData -export function matchGrammeme(value: IGramData, test: IGramData): boolean { - if (value.type !== test.type) { +/** + * Represents specific wordform attached to {@link Grammeme}s. +*/ +export interface IWordForm { + text: string + grams: IGramData[] +} + +/** + * Equality comparator for {@link IGramData}. Compares text data for unknown grammemes + */ +export function matchGrammeme(left: IGramData, right: IGramData): boolean { + if (left.type !== right.type) { return false; } - return value.type !== Grammeme.UNKN || value.data === test.data; + return left.type !== Grammeme.UNKN || left.data === right.data; +} + +/** + * Equality comparator for {@link IWordForm}. Compares a set of Grammemes attached to wordforms + */ +export function matchWordForm(left: IWordForm, right: IWordForm): boolean { + if (left.grams.length !== right.grams.length) { + return false; + } + for (let index = 0; index < left.grams.length; ++index) { + if (!matchGrammeme(left.grams[index], right.grams[index])) { + return false; + } + } + return true; } function parseSingleGrammeme(text: string): IGramData { @@ -173,6 +250,18 @@ function parseSingleGrammeme(text: string): IGramData { } } +export function sortGrammemes(input: TData[]): TData[] { + const result: TData[] = []; + Object.values(Grammeme).forEach( + gram => { + const item = input.find(data => data.type === gram); + if (item) { + result.push(item); + } + }); + return result; +} + export function parseGrammemes(termForm: string): IGramData[] { const result: IGramData[] = []; const chunks = termForm.split(','); @@ -182,12 +271,7 @@ export function parseGrammemes(termForm: string): IGramData[] { result.push(parseSingleGrammeme(chunk)); } }); - return result; -} - -export interface IWordForm { - text: string - grams: IGramData[] + return sortGrammemes(result); } // ====== Reference resolution ===== diff --git a/rsconcept/frontend/src/pages/RSFormPage/DlgEditTerm.tsx b/rsconcept/frontend/src/pages/RSFormPage/DlgEditTerm.tsx index 1a3f021e..c32b111c 100644 --- a/rsconcept/frontend/src/pages/RSFormPage/DlgEditTerm.tsx +++ b/rsconcept/frontend/src/pages/RSFormPage/DlgEditTerm.tsx @@ -7,8 +7,14 @@ import SelectMulti from '../../components/Common/SelectMulti'; import TextArea from '../../components/Common/TextArea'; import DataTable, { createColumnHelper } from '../../components/DataTable'; import { CheckIcon, ChevronDoubleUpIcon, ChevronUpIcon, CrossIcon } from '../../components/Icons'; -import { Grammeme, GrammemeGroups, IWordForm, NounGrams, parseGrammemes,VerbGrams } from '../../models/language'; +import { useConceptTheme } from '../../context/ThemeContext'; +import { + Grammeme, GrammemeGroups, IWordForm, + matchWordForm, NounGrams, parseGrammemes, + sortGrammemes, VerbGrams +} from '../../models/language'; import { IConstituenta, TermForm } from '../../models/rsform'; +import { colorfgGrammeme } from '../../utils/color'; import { labelGrammeme } from '../../utils/labels'; import { IGrammemeOption, SelectorGrammems } from '../../utils/selectors'; @@ -20,7 +26,8 @@ interface DlgEditTermProps { const columnHelper = createColumnHelper(); -function DlgEditTerm({ hideWindow, target, onSave }: DlgEditTermProps) { +function DlgEditTerm({ hideWindow, target, onSave }: DlgEditTermProps) { + const { colors } = useConceptTheme(); const [term, setTerm] = useState(''); const [inputText, setInputText] = useState(''); @@ -50,55 +57,73 @@ function DlgEditTerm({ hideWindow, target, onSave }: DlgEditTermProps) { })); setForms(initForms); setTerm(target.term_resolved); + setInputText(target.term_resolved); }, [target]); // Filter grammemes when input changes useEffect( () => { let newFilter: Grammeme[] = []; - inputGrams.forEach(({value: gram}) => { + inputGrams.forEach(({type: gram}) => { if (!newFilter.includes(gram)) { if (NounGrams.includes(gram)) { newFilter.push(...NounGrams); } if (VerbGrams.includes(gram)) { - newFilter.push(...NounGrams); + newFilter.push(...VerbGrams); } } }); - inputGrams.forEach(({value: gram}) => + inputGrams.forEach(({type: gram}) => GrammemeGroups.forEach(group => { if (group.includes(gram)) { newFilter = newFilter.filter(item => !group.includes(item) || item === gram); } })); - newFilter.push(...inputGrams.map(({value: gram}) => gram)); + newFilter.push(...inputGrams.map(({type: gram}) => gram)); if (newFilter.length === 0) { newFilter = [...VerbGrams, ...NounGrams]; } newFilter = [... new Set(newFilter)]; - setOptions(SelectorGrammems.filter(({value: gram}) => newFilter.includes(gram))); + setOptions(SelectorGrammems.filter(({type: gram}) => newFilter.includes(gram))); }, [inputGrams]); const handleSubmit = () => onSave(getData()); function handleAddForm() { + const newForm: IWordForm = { + text: inputText, + grams: inputGrams.map(item => ({ + type: item.type, + data: item.data + })) + }; setForms(forms => [ - ...forms, - { - text: inputText, - grams: inputGrams.map(item => ({ - type: item.value, data: item.value as string - })) - } + ...forms.filter(value => !matchWordForm(value, newForm)), + newForm ]); } + function handleDeleteRow(row: number) { + setForms( + (prev) => { + const newForms: IWordForm[] = []; + prev.forEach( + (form, index) => { + if (index !== row) { + newForms.push(form); + } + }); + return newForms; + }); + } + function handleResetForm() { - + setInputText(''); + setInputGrams([]); } function handleGenerateSelected() { @@ -106,6 +131,11 @@ function DlgEditTerm({ hideWindow, target, onSave }: DlgEditTermProps) { } function handleGenerateBasics() { + if (forms.length > 0) { + if (!window.confirm('Данное действие приведет к перезаписи словоформ при совпадении граммем. Продолжить?')) { + return; + } + } } @@ -116,7 +146,8 @@ function DlgEditTerm({ hideWindow, target, onSave }: DlgEditTermProps) { header: 'Текст', size: 350, minSize: 350, - maxSize: 350 + maxSize: 350, + cell: props =>
{props.getValue()}
}), columnHelper.accessor('grams', { id: 'grams', @@ -124,41 +155,42 @@ function DlgEditTerm({ hideWindow, target, onSave }: DlgEditTermProps) { size: 250, minSize: 250, maxSize: 250, - cell: props => { - return ( + cell: props =>
{ props.getValue().map( - data => (<> + gram =>
- {labelGrammeme(data)} + {labelGrammeme(gram)}
- {/* */} - ))} -
); - } - - // cell: props => - //
- // {props.getValue()} - //
+ )} + }), - // columnHelper.accessor(, { - - // }) - ], []); + columnHelper.display({ + id: 'actions', + size: 50, + minSize: 50, + maxSize: 50, + cell: props => +
+ } + noHover + onClick={() => handleDeleteRow(props.row.index)} + /> +
+ }) + ], [colors]); return ( } + icon={} + disabled={!inputText || inputGrams.length == 0} onClick={handleAddForm} /> } + icon={} + disabled={inputGrams.length == 0} onClick={handleGenerateSelected} /> setInputGrams(data.map(value => value))} + onChange={newValue => setInputGrams(sortGrammemes([...newValue]))} /> -
+

Список пуст

+

Добавьте словоформу

} diff --git a/rsconcept/frontend/src/utils/color.ts b/rsconcept/frontend/src/utils/color.ts index db386714..6643f1da 100644 --- a/rsconcept/frontend/src/utils/color.ts +++ b/rsconcept/frontend/src/utils/color.ts @@ -1,5 +1,6 @@ // =========== Modules contains all dynamic color definitions ========== +import { Grammeme, NounGrams, PartOfSpeech, VerbGrams } from '../models/language' import { CstClass, ExpressionStatus } from '../models/rsform' import { ISyntaxTreeNode, TokenID } from '../models/rslang' @@ -384,3 +385,29 @@ export function colorbgCstClass(cstClass: CstClass, colors: IColorTheme): string } } +export function colorfgGrammeme(gram: Grammeme, colors: IColorTheme): string { + if (PartOfSpeech.includes(gram)) { + return colors.fgBlue; + } + if (NounGrams.includes(gram)) { + return colors.fgGreen; + } + if (VerbGrams.includes(gram)) { + return colors.fgTeal; + } + return colors.fgDefault; +} + +export function colorbgGrammeme(gram: Grammeme, colors: IColorTheme): string { + if (PartOfSpeech.includes(gram)) { + return colors.bgBlue; + } + if (NounGrams.includes(gram)) { + return colors.bgGreen; + } + if (VerbGrams.includes(gram)) { + return colors.bgTeal; + } + return colors.bgInput; +} + diff --git a/rsconcept/frontend/src/utils/constants.ts b/rsconcept/frontend/src/utils/constants.ts index 9cec7c85..9bde6954 100644 --- a/rsconcept/frontend/src/utils/constants.ts +++ b/rsconcept/frontend/src/utils/constants.ts @@ -21,7 +21,7 @@ export const urls = { gitrepo: 'https://github.com/IRBorisov/ConceptPortal', mailportal: 'mailto:portal@acconcept.ru', - restapi: 'https://api.portal.acconcept.ru/docs/' + restapi: 'https://api.portal.acconcept.ru/docs' }; export const resources = { diff --git a/rsconcept/frontend/src/utils/selectors.ts b/rsconcept/frontend/src/utils/selectors.ts index 67381b53..c8ba561c 100644 --- a/rsconcept/frontend/src/utils/selectors.ts +++ b/rsconcept/frontend/src/utils/selectors.ts @@ -38,7 +38,7 @@ export const SelectorCstType = ( }) ); -export interface IGrammemeOption { +export interface IGrammemeOption extends IGramData { value: Grammeme label: string } @@ -63,6 +63,8 @@ export const SelectorGrammems: IGrammemeOption[] = Grammeme.pssv, Grammeme.actv, ].map( gram => ({ + type: gram, + data: gram as string, value: gram, label: labelGrammeme({type: gram, data: ''} as IGramData) }));