Refactor backend using drf-spectacular

This commit is contained in:
IRBorisov 2023-09-21 23:09:51 +03:00
parent c322e2e8eb
commit f21a01bbc0
15 changed files with 445 additions and 133 deletions

View File

@ -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]
</pre>
</details>

View File

@ -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,

View File

@ -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. '''

View File

@ -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 {

View File

@ -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, )

View File

@ -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]

View File

@ -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

View File

@ -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)

View File

@ -3,6 +3,7 @@ django
djangorestframework
django-cors-headers
django-filter
drf-spectacular
coreapi
pymorphy2
pymorphy2-dicts-ru

View File

@ -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]

View File

@ -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<TData extends IGramData>(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 =====

View File

@ -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<IWordForm>();
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 => <div className='min-w-[20rem]'>{props.getValue()}</div>
}),
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 =>
<div className='flex justify-start gap-1 select-none'>
{ props.getValue().map(
data => (<>
gram =>
<div
className='min-w-[3rem] px-1 text-center rounded-md whitespace-nowrap border clr-border clr-input'
className='min-w-[3rem] px-1 text-sm text-center rounded-md whitespace-nowrap'
title=''
// style={{
// borderWidth: '1px',
// borderColor: getCstStatusFgColor(cst.status, colors),
// color: getCstStatusFgColor(cst.status, colors),
// fontWeight: 600,
// backgroundColor: isMockCst(cst) ? colors.bgWarning : colors.bgInput
// }}
style={{
borderWidth: '1px',
borderColor: colorfgGrammeme(gram.type, colors),
color: colorfgGrammeme(gram.type, colors),
fontWeight: 600,
backgroundColor: colors.bgInput
}}
>
{labelGrammeme(data)}
{labelGrammeme(gram)}
</div>
{/* <ConstituentaTooltip data={cst} anchor={`#${prefixes.cst_list}${cst.alias}`} /> */}
</>))}
</div>);
}
// cell: props =>
// <div style={{
// fontSize: 12,
// color: isMockCst(props.row.original) ? colors.fgWarning : undefined
// }}>
// {props.getValue()}
// </div>
)}
</div>
}),
// columnHelper.accessor(, {
// })
], []);
columnHelper.display({
id: 'actions',
size: 50,
minSize: 50,
maxSize: 50,
cell: props =>
<div>
<MiniButton
tooltip='Удалить словоформу'
icon={<CrossIcon size={4} color='text-warning'/>}
noHover
onClick={() => handleDeleteRow(props.row.index)}
/>
</div>
})
], [colors]);
return (
<Modal
@ -193,7 +225,8 @@ function DlgEditTerm({ hideWindow, target, onSave }: DlgEditTermProps) {
<div className='flex items-center justify-start'>
<MiniButton
tooltip='Добавить словоформу'
icon={<CheckIcon size={6} color='text-success'/>}
icon={<CheckIcon size={6} color={!inputText || inputGrams.length == 0 ? 'text-disabled' : 'text-success'}/>}
disabled={!inputText || inputGrams.length == 0}
onClick={handleAddForm}
/>
<MiniButton
@ -203,7 +236,8 @@ function DlgEditTerm({ hideWindow, target, onSave }: DlgEditTermProps) {
/>
<MiniButton
tooltip='Генерировать словоформу'
icon={<ChevronUpIcon size={6} color='text-primary'/>}
icon={<ChevronUpIcon size={6} color={inputGrams.length == 0 ? 'text-disabled' : 'text-primary'}/>}
disabled={inputGrams.length == 0}
onClick={handleGenerateSelected}
/>
<MiniButton
@ -219,11 +253,11 @@ function DlgEditTerm({ hideWindow, target, onSave }: DlgEditTermProps) {
placeholder='Выберите граммемы'
value={inputGrams}
onChange={data => setInputGrams(data.map(value => value))}
onChange={newValue => setInputGrams(sortGrammemes([...newValue]))}
/>
</div>
<div className='border overflow-y-auto max-h-[20rem]'>
<div className='border overflow-y-auto max-h-[17.4rem] min-h-[17.4rem]'>
<DataTable
data={forms}
columns={columns}
@ -232,6 +266,7 @@ function DlgEditTerm({ hideWindow, target, onSave }: DlgEditTermProps) {
noDataComponent={
<span className='flex flex-col justify-center p-2 text-center min-h-[2rem]'>
<p>Список пуст</p>
<p>Добавьте словоформу</p>
</span>
}

View File

@ -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;
}

View File

@ -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 = {

View File

@ -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)
}));