Refactor pyconcept interaction and minor UI fixes

This commit is contained in:
IRBorisov 2023-08-22 17:52:59 +03:00
parent 05645a29e8
commit cff07788e5
19 changed files with 493 additions and 336 deletions

View File

@ -430,7 +430,8 @@ disable=too-many-public-methods,
unused-argument, unused-argument,
missing-function-docstring, missing-function-docstring,
attribute-defined-outside-init, attribute-defined-outside-init,
ungrouped-imports ungrouped-imports,
abstract-method
# Enable the message, report, category or checker with the given id(s). You can # Enable the message, report, category or checker with the given id(s). You can
# either give multiple identifier separated by comma (,) or put this option # either give multiple identifier separated by comma (,) or put this option

View File

@ -3,6 +3,7 @@ from django.db import migrations
from apps.rsform import utils from apps.rsform import utils
from apps.rsform.models import RSForm from apps.rsform.models import RSForm
from apps.rsform.serializers import RSFormTRSSerializer
from apps.users.models import User from apps.users.models import User
@ -11,7 +12,10 @@ def load_initial_schemas(apps, schema_editor):
for subdir, dirs, files in os.walk(rootdir): for subdir, dirs, files in os.walk(rootdir):
for file in files: for file in files:
data = utils.read_trs(os.path.join(subdir, file)) data = utils.read_trs(os.path.join(subdir, file))
RSForm.create_from_trs(None, data) data['is_common'] = True
serializer = RSFormTRSSerializer(data=data, context={'load_meta': True})
serializer.is_valid(raise_exception=True)
serializer.save()
def load_initial_users(apps, schema_editor): def load_initial_users(apps, schema_editor):

View File

@ -1,7 +1,9 @@
''' Models: RSForms for conceptual schemas. ''' ''' Models: RSForms for conceptual schemas. '''
import json import json
from typing import Iterable, Optional from copy import deepcopy
import pyconcept import re
from typing import Iterable, Optional, cast
from django.db import transaction from django.db import transaction
from django.db.models import ( from django.db.models import (
CASCADE, SET_NULL, ForeignKey, Model, PositiveIntegerField, QuerySet, CASCADE, SET_NULL, ForeignKey, Model, PositiveIntegerField, QuerySet,
@ -10,9 +12,16 @@ from django.db.models import (
from django.core.validators import MinValueValidator from django.core.validators import MinValueValidator
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.urls import reverse from django.urls import reverse
import pyconcept
from apps.users.models import User from apps.users.models import User
from cctext import Resolver, Entity, extract_entities from cctext import Resolver, Entity, extract_entities
from .graph import Graph from .graph import Graph
from .utils import apply_mapping_pattern
_REF_ENTITY_PATTERN = re.compile(r'@{([^0-9\-].*?)\|.*?}')
_GLOBAL_ID_PATTERN = re.compile(r'([XCSADFPT][0-9]+)')
class CstType(TextChoices): class CstType(TextChoices):
@ -37,6 +46,26 @@ class Syntax(TextChoices):
def _empty_forms(): def _empty_forms():
return [] return []
def _get_type_prefix(cst_type: CstType) -> str:
''' Get alias prefix. '''
if cst_type == CstType.BASE:
return 'X'
if cst_type == CstType.CONSTANT:
return 'C'
if cst_type == CstType.STRUCTURED:
return 'S'
if cst_type == CstType.AXIOM:
return 'A'
if cst_type == CstType.TERM:
return 'D'
if cst_type == CstType.FUNCTION:
return 'F'
if cst_type == CstType.PREDICATE:
return 'P'
if cst_type == CstType.THEOREM:
return 'T'
return 'X'
class RSForm(Model): class RSForm(Model):
''' RSForm is a math form of capturing conceptual schema ''' ''' RSForm is a math form of capturing conceptual schema '''
@ -134,7 +163,7 @@ class RSForm(Model):
alias=alias, alias=alias,
cst_type=insert_type cst_type=insert_type
) )
self._update_order() self.update_order()
self.save() self.save()
result.refresh_from_db() result.refresh_from_db()
return result return result
@ -151,7 +180,7 @@ class RSForm(Model):
alias=alias, alias=alias,
cst_type=insert_type cst_type=insert_type
) )
self._update_order() self.update_order()
self.save() self.save()
result.refresh_from_db() result.refresh_from_db()
return result return result
@ -177,7 +206,7 @@ class RSForm(Model):
count_moved += 1 count_moved += 1
update_list.append(cst) update_list.append(cst)
Constituenta.objects.bulk_update(update_list, ['order']) Constituenta.objects.bulk_update(update_list, ['order'])
self._update_order() self.update_order()
self.save() self.save()
@transaction.atomic @transaction.atomic
@ -185,8 +214,8 @@ class RSForm(Model):
''' Delete multiple constituents. Do not check if listCst are from this schema ''' ''' Delete multiple constituents. Do not check if listCst are from this schema '''
for cst in listCst: for cst in listCst:
cst.delete() cst.delete()
self._update_order() self.update_order()
self._resolve_all_text() self.resolve_all_text()
self.save() self.save()
@transaction.atomic @transaction.atomic
@ -209,89 +238,61 @@ class RSForm(Model):
cst.refresh_from_db() cst.refresh_from_db()
return cst return cst
def _insert_new(self, data: dict, insert_after: Optional[str]=None) -> 'Constituenta': def reset_aliases(self):
if insert_after is not None: ''' Recreate all aliases based on cst order. '''
cstafter = Constituenta.objects.get(pk=insert_after) mapping = self._create_reset_mapping()
return self.insert_at(cstafter.order + 1, data['alias'], data['cst_type']) self._apply_mapping(mapping)
else:
return self.insert_last(data['alias'], data['cst_type']) def _create_reset_mapping(self) -> dict[str, str]:
bases = cast(dict[str, int], {})
mapping = cast(dict[str, str], {})
for cst_type in CstType.values:
bases[cst_type] = 1
cst_list = self.constituents().order_by('order')
for cst in cst_list:
alias = f'{_get_type_prefix(cst.cst_type)}{bases[cst.cst_type]}'
bases[cst.cst_type] += 1
if cst.alias != alias:
mapping[cst.alias] = alias
return mapping
@transaction.atomic @transaction.atomic
def load_trs(self, data: dict, sync_metadata: bool, skip_update: bool): def _apply_mapping(self, mapping: dict[str, str]):
if sync_metadata: cst_list = self.constituents().order_by('order')
self.title = data.get('title', 'Без названия') for cst in cst_list:
self.alias = data.get('alias', '') modified = False
self.comment = data.get('comment', '') if cst.alias in mapping:
order = 1 modified = True
prev_constituents = self.constituents() cst.alias = mapping[cst.alias]
loaded_ids = set() expression = apply_mapping_pattern(cst.definition_formal, mapping, _GLOBAL_ID_PATTERN)
for cst_data in data['items']: if expression != cst.definition_formal:
uid = int(cst_data['entityUID']) modified = True
if prev_constituents.filter(pk=uid).exists(): cst.definition_formal = expression
cst: Constituenta = prev_constituents.get(pk=uid) convention = apply_mapping_pattern(cst.convention, mapping, _GLOBAL_ID_PATTERN)
cst.order = order if convention != cst.convention:
cst.load_trs(cst_data) modified = True
cst.convention = convention
term = apply_mapping_pattern(cst.term_raw, mapping, _REF_ENTITY_PATTERN)
if term != cst.term_raw:
modified = True
cst.term_raw = term
definition = apply_mapping_pattern(cst.definition_raw, mapping, _REF_ENTITY_PATTERN)
if definition != cst.definition_raw:
modified = True
cst.definition_raw = definition
if modified:
cst.save() cst.save()
else:
cst = Constituenta.create_from_trs(cst_data, self, order)
cst.save()
uid = cst.pk
loaded_ids.add(uid)
order += 1
for prev_cst in prev_constituents:
if prev_cst.pk not in loaded_ids:
prev_cst.delete()
if not skip_update:
self._update_order()
self._resolve_all_text()
self.save()
@staticmethod
@transaction.atomic
def create_from_trs(owner: User, data: dict, is_common: bool = True) -> 'RSForm':
schema = RSForm.objects.create(
title=data.get('title', 'Без названия'),
owner=owner,
alias=data.get('alias', ''),
comment=data.get('comment', ''),
is_common=is_common
)
# pylint: disable=protected-access
schema._create_items_from_trs(data['items'])
return schema
def to_trs(self) -> dict:
''' Generate JSON string containing all data from RSForm '''
result = self._prepare_json_rsform()
items = self.constituents().order_by('order')
for cst in items:
result['items'].append(cst.to_trs())
return result
def __str__(self):
return self.title
def get_absolute_url(self):
return reverse('rsform-detail', kwargs={'pk': self.pk})
def _prepare_json_rsform(self: 'RSForm') -> dict:
return {
'type': 'rsform',
'title': self.title,
'alias': self.alias,
'comment': self.comment,
'items': []
}
@transaction.atomic @transaction.atomic
def _update_order(self): def update_order(self):
checked = json.loads(pyconcept.check_schema(json.dumps(self.to_trs()))) ''' Update constituents order. '''
checked = PyConceptAdapter(self).basic()
update_list = self.constituents().only('id', 'order') update_list = self.constituents().only('id', 'order')
if len(checked['items']) != update_list.count(): if len(checked['items']) != update_list.count():
raise ValidationError('Invalid constituents count') raise ValidationError('Invalid constituents count')
order = 1 order = 1
for cst in checked['items']: for cst in checked['items']:
cst_id = cst['entityUID'] cst_id = cst['id']
for oldCst in update_list: for oldCst in update_list:
if oldCst.pk == cst_id: if oldCst.pk == cst_id:
oldCst.order = order oldCst.order = order
@ -300,14 +301,8 @@ class RSForm(Model):
Constituenta.objects.bulk_update(update_list, ['order']) Constituenta.objects.bulk_update(update_list, ['order'])
@transaction.atomic @transaction.atomic
def _create_items_from_trs(self, items): def resolve_all_text(self):
order = 1 ''' Trigger reference resolution for all texts. '''
for cst in items:
cst_object = Constituenta.create_from_trs(cst, self, order)
cst_object.save()
order += 1
def _resolve_all_text(self):
graph_terms = self._term_graph() graph_terms = self._term_graph()
resolver = Resolver({}) resolver = Resolver({})
for alias in graph_terms.topological_order(): for alias in graph_terms.topological_order():
@ -323,6 +318,19 @@ class RSForm(Model):
cst.definition_resolved = resolved cst.definition_resolved = resolved
cst.save() cst.save()
def _insert_new(self, data: dict, insert_after: Optional[str]=None) -> 'Constituenta':
if insert_after is not None:
cstafter = Constituenta.objects.get(pk=insert_after)
return self.insert_at(cstafter.order + 1, data['alias'], data['cst_type'])
else:
return self.insert_last(data['alias'], data['cst_type'])
def __str__(self) -> str:
return f'{self.title}'
def get_absolute_url(self):
return reverse('rsform-detail', kwargs={'pk': self.pk})
def _term_graph(self) -> Graph: def _term_graph(self) -> Graph:
result = Graph() result = Graph()
cst_list = self.constituents().only('order', 'alias', 'term_raw').order_by('order') cst_list = self.constituents().only('order', 'alias', 'term_raw').order_by('order')
@ -333,7 +341,7 @@ class RSForm(Model):
if result.contains(alias): if result.contains(alias):
result.add_edge(id_from=alias, id_to=cst.alias) result.add_edge(id_from=alias, id_to=cst.alias)
return result return result
def _definition_graph(self) -> Graph: def _definition_graph(self) -> Graph:
result = Graph() result = Graph()
cst_list = self.constituents().only('order', 'alias', 'definition_raw').order_by('order') cst_list = self.constituents().only('order', 'alias', 'definition_raw').order_by('order')
@ -413,9 +421,9 @@ class Constituenta(Model):
''' URL access. ''' ''' URL access. '''
return reverse('constituenta-detail', kwargs={'pk': self.pk}) return reverse('constituenta-detail', kwargs={'pk': self.pk})
def __str__(self): def __str__(self) -> str:
return self.alias return f'{self.alias}'
def set_term_resolved(self, new_term: str): def set_term_resolved(self, new_term: str):
''' Set term and reset forms if needed. ''' ''' Set term and reset forms if needed. '''
if new_term == self.term_resolved: if new_term == self.term_resolved:
@ -423,61 +431,83 @@ class Constituenta(Model):
self.term_resolved = new_term self.term_resolved = new_term
self.term_forms = [] self.term_forms = []
@staticmethod class PyConceptAdapter:
def create_from_trs(data: dict, schema: RSForm, order: int) -> 'Constituenta': ''' RSForm adapter for interacting with pyconcept module. '''
''' Create constituenta from TRS json ''' def __init__(self, instance: RSForm):
cst = Constituenta( self.schema = instance
alias=data['alias'], self.data = self._prepare_request()
schema=schema, self._checked_data: Optional[dict] = None
order=order,
cst_type=data['cstType'],
)
# pylint: disable=protected-access
cst._load_texts(data)
return cst
def load_trs(self, data: dict): def basic(self) -> dict:
''' Load data from TRS json ''' ''' Check RSForm and return check results.
self.alias = data['alias'] Warning! Does not include texts. '''
self.cst_type = data['cstType'] self._produce_response()
self._load_texts(data) if self._checked_data is None:
raise ValueError('Invalid data response from pyconcept')
return self._checked_data
def _load_texts(self, data: dict): def full(self) -> dict:
self.convention = data.get('convention', '') ''' Check RSForm and return check results including initial texts. '''
if 'definition' in data: self._produce_response()
self.definition_formal = data['definition'].get('formal', '') if self._checked_data is None:
if 'text' in data['definition']: raise ValueError('Invalid data response from pyconcept')
self.definition_raw = data['definition']['text'].get('raw', '') return self._complete_rsform_details(self._checked_data)
self.definition_resolved = data['definition']['text'].get('resolved', '')
else:
self.definition_raw = ''
self.definition_resolved = ''
if 'term' in data:
self.term_raw = data['term'].get('raw', '')
self.term_resolved = data['term'].get('resolved', '')
self.term_forms = data['term'].get('forms', [])
else:
self.term_raw = ''
self.term_resolved = ''
self.term_forms = []
def to_trs(self) -> dict: def _complete_rsform_details(self, data: dict) -> dict:
return { result = deepcopy(data)
'entityUID': self.pk, result['id'] = self.schema.pk
'type': 'constituenta', result['alias'] = self.schema.alias
'cstType': self.cst_type, result['title'] = self.schema.title
'alias': self.alias, result['comment'] = self.schema.comment
'convention': self.convention, result['time_update'] = self.schema.time_update
'term': { result['time_create'] = self.schema.time_create
'raw': self.term_raw, result['is_common'] = self.schema.is_common
'resolved': self.term_resolved, result['owner'] = (self.schema.owner.pk if self.schema.owner is not None else None)
'forms': self.term_forms, for cst_data in result['items']:
}, cst = Constituenta.objects.get(pk=cst_data['id'])
'definition': { cst_data['convention'] = cst.convention
'formal': self.definition_formal, cst_data['term'] = {
'text': { 'raw': cst.term_raw,
'raw': self.definition_raw, 'resolved': cst.term_resolved,
'resolved': self.definition_resolved, 'forms': cst.term_forms
}, }
}, cst_data['definition']['text'] = {
'raw': cst.definition_raw,
'resolved': cst.definition_resolved,
}
return result
def _prepare_request(self) -> dict:
result: dict = {
'items': []
} }
items = self.schema.constituents().order_by('order')
for cst in items:
result['items'].append({
'entityUID': cst.pk,
'cstType': cst.cst_type,
'alias': cst.alias,
'definition': {
'formal': cst.definition_formal
}
})
return result
def _produce_response(self):
if self._checked_data is not None:
return
response = pyconcept.check_schema(json.dumps(self.data))
data = json.loads(response)
self._checked_data = {
'items': []
}
for cst in data['items']:
self._checked_data['items'].append({
'id': cst['entityUID'],
'cstType': cst['cstType'],
'alias': cst['alias'],
'definition': {
'formal': cst['definition']['formal']
},
'parse': cst['parse']
})

View File

@ -1,35 +1,28 @@
''' Serializers for conceptual schema API. ''' ''' Serializers for conceptual schema API. '''
import json
from typing import Optional from typing import Optional
from rest_framework import serializers from rest_framework import serializers
from django.db import transaction
import pyconcept
from .models import Constituenta, RSForm from .models import Constituenta, RSForm
_CST_TYPE = 'constituenta'
_TRS_TYPE = 'rsform'
_TRS_VERSION_MIN = 16
_TRS_VERSION = 16
_TRS_HEADER = 'Exteor 4.8.13.1000 - 30/05/2022'
class FileSerializer(serializers.Serializer): class FileSerializer(serializers.Serializer):
''' Serializer: File input. ''' ''' Serializer: File input. '''
file = serializers.FileField(allow_empty_file=False) file = serializers.FileField(allow_empty_file=False)
def create(self, validated_data):
raise NotImplementedError('unexpected `create()` call')
def update(self, instance, validated_data):
raise NotImplementedError('unexpected `update()` call')
class ExpressionSerializer(serializers.Serializer): class ExpressionSerializer(serializers.Serializer):
''' Serializer: RSLang expression. ''' ''' Serializer: RSLang expression. '''
expression = serializers.CharField() expression = serializers.CharField()
def create(self, validated_data):
raise NotImplementedError('unexpected `create()` call')
def update(self, instance, validated_data): class RSFormMetaSerializer(serializers.ModelSerializer):
raise NotImplementedError('unexpected `update()` call')
class RSFormSerializer(serializers.ModelSerializer):
''' Serializer: General purpose RSForm data. ''' ''' Serializer: General purpose RSForm data. '''
class Meta: class Meta:
''' serializer metadata. ''' ''' serializer metadata. '''
@ -43,12 +36,6 @@ class RSFormUploadSerializer(serializers.Serializer):
file = serializers.FileField() file = serializers.FileField()
load_metadata = serializers.BooleanField() load_metadata = serializers.BooleanField()
def create(self, validated_data):
raise NotImplementedError('unexpected `create()` call')
def update(self, instance, validated_data):
raise NotImplementedError('unexpected `update()` call')
class RSFormContentsSerializer(serializers.ModelSerializer): class RSFormContentsSerializer(serializers.ModelSerializer):
''' Serializer: Detailed data for RSForm. ''' ''' Serializer: Detailed data for RSForm. '''
@ -57,35 +44,168 @@ class RSFormContentsSerializer(serializers.ModelSerializer):
model = RSForm model = RSForm
def to_representation(self, instance: RSForm): def to_representation(self, instance: RSForm):
result = RSFormSerializer(instance).data result = RSFormMetaSerializer(instance).data
result['items'] = [] result['items'] = []
for cst in instance.constituents().order_by('order'): for cst in instance.constituents().order_by('order'):
result['items'].append(ConstituentaSerializer(cst).data) result['items'].append(ConstituentaSerializer(cst).data)
return result return result
class RSFormDetailsSerlializer(serializers.BaseSerializer): class RSFormTRSSerializer(serializers.Serializer):
''' Serializer: Processed data for RSForm. ''' ''' Serializer: TRS file production and loading for RSForm. '''
class Meta: class Meta:
''' serializer metadata. ''' ''' serializer metadata. '''
model = RSForm model = RSForm
def to_representation(self, instance: RSForm): def to_representation(self, instance: RSForm) -> dict:
trs = pyconcept.check_schema(json.dumps(instance.to_trs())) result = self._prepare_json_rsform(instance)
trs = trs.replace('entityUID', 'id') items = instance.constituents().order_by('order')
result = json.loads(trs) for cst in items:
result['id'] = instance.pk result['items'].append(self._prepare_json_constituenta(cst))
result['time_update'] = instance.time_update
result['time_create'] = instance.time_create
result['is_common'] = instance.is_common
result['owner'] = (instance.owner.pk if instance.owner is not None else None)
return result return result
def create(self, validated_data): @staticmethod
raise NotImplementedError('unexpected `create()` call') def _prepare_json_rsform(schema: RSForm) -> dict:
return {
'type': _TRS_TYPE,
'title': schema.title,
'alias': schema.alias,
'comment': schema.comment,
'items': [],
'claimed': False,
'selection': [],
'version': _TRS_VERSION,
'versionInfo': _TRS_HEADER
}
def update(self, instance, validated_data): @staticmethod
raise NotImplementedError('unexpected `update()` call') def _prepare_json_constituenta(cst: Constituenta) -> dict:
return {
'entityUID': cst.pk,
'type': _CST_TYPE,
'cstType': cst.cst_type,
'alias': cst.alias,
'convention': cst.convention,
'term': {
'raw': cst.term_raw,
'resolved': cst.term_resolved,
'forms': cst.term_forms
},
'definition': {
'formal': cst.definition_formal,
'text': {
'raw': cst.definition_raw,
'resolved': cst.definition_resolved
},
},
}
def to_internal_value(self, data):
result = super().to_internal_value(data)
if 'owner' in data:
result['owner'] = data['owner']
if 'is_common' in data:
result['is_common'] = data['is_common']
result['items'] = data.get('items', [])
if self.context['load_meta']:
result['title'] = data.get('title', 'Без названия')
result['alias'] = data.get('alias', '')
result['comment']= data.get('comment', '')
if 'id' in data:
result['id'] = data['id']
self.instance = RSForm.objects.get(pk=result['id'])
return result
def validate(self, attrs: dict):
if 'version' not in self.initial_data \
or self.initial_data['version'] < _TRS_VERSION_MIN \
or self.initial_data['version'] > _TRS_VERSION:
raise serializers.ValidationError({
'version': 'Некорректная версия файла Экстеор. Пересохраните файл в новой версии'
})
return attrs
@transaction.atomic
def create(self, validated_data: dict) -> RSForm:
self.instance = RSForm(
owner=validated_data.get('owner', None),
alias=validated_data['alias'],
title=validated_data['title'],
comment=validated_data['comment'],
is_common=validated_data['is_common']
)
self.instance.save()
order = 1
for cst_data in validated_data['items']:
cst = Constituenta(
alias=cst_data['alias'],
schema=self.instance,
order=order,
cst_type=cst_data['cstType'],
)
self._load_cst_texts(cst, cst_data)
cst.save()
order += 1
self.instance.resolve_all_text()
return self.instance
@transaction.atomic
def update(self, instance: RSForm, validated_data) -> RSForm:
if 'alias' in validated_data:
instance.alias = validated_data['alias']
if 'title' in validated_data:
instance.title = validated_data['title']
if 'comment' in validated_data:
instance.comment = validated_data['comment']
order = 1
prev_constituents = instance.constituents()
loaded_ids = set()
for cst_data in validated_data['items']:
uid = int(cst_data['entityUID'])
if prev_constituents.filter(pk=uid).exists():
cst: Constituenta = prev_constituents.get(pk=uid)
cst.order = order
cst.alias = cst_data['alias']
cst.cst_type = cst_data['cstType']
self._load_cst_texts(cst, cst_data)
cst.save()
else:
cst = Constituenta(
alias=cst_data['alias'],
schema=instance,
order=order,
cst_type=cst_data['cstType'],
)
self._load_cst_texts(cst, cst_data)
cst.save()
uid = cst.pk
loaded_ids.add(uid)
order += 1
for prev_cst in prev_constituents:
if prev_cst.pk not in loaded_ids:
prev_cst.delete()
instance.update_order()
instance.resolve_all_text()
instance.save()
return instance
@staticmethod
def _load_cst_texts(cst: Constituenta, data: dict):
cst.convention = data.get('convention', '')
if 'definition' in data:
cst.definition_formal = data['definition'].get('formal', '')
if 'text' in data['definition']:
cst.definition_raw = data['definition']['text'].get('raw', '')
else:
cst.definition_raw = ''
if 'term' in data:
cst.term_raw = data['term'].get('raw', '')
cst.term_forms = data['term'].get('forms', [])
else:
cst.term_raw = ''
cst.term_forms = []
class ConstituentaSerializer(serializers.ModelSerializer): class ConstituentaSerializer(serializers.ModelSerializer):
@ -116,7 +236,7 @@ class ConstituentaSerializer(serializers.ModelSerializer):
return result return result
class StandaloneCstSerializer(serializers.ModelSerializer): class CstStandaloneSerializer(serializers.ModelSerializer):
''' Serializer: Constituenta in current context. ''' ''' Serializer: Constituenta in current context. '''
id = serializers.IntegerField() id = serializers.IntegerField()
@ -146,7 +266,7 @@ class CstCreateSerializer(serializers.ModelSerializer):
class CstListSerlializer(serializers.Serializer): class CstListSerlializer(serializers.Serializer):
''' Serializer: List of constituents from one origin. ''' ''' Serializer: List of constituents from one origin. '''
items = serializers.ListField( items = serializers.ListField(
child=StandaloneCstSerializer() child=CstStandaloneSerializer()
) )
def validate(self, attrs): def validate(self, attrs):
@ -161,19 +281,7 @@ class CstListSerlializer(serializers.Serializer):
attrs['constituents'] = cstList attrs['constituents'] = cstList
return attrs return attrs
def create(self, validated_data):
raise NotImplementedError('unexpected `create()` call')
def update(self, instance, validated_data):
raise NotImplementedError('unexpected `update()` call')
class CstMoveSerlializer(CstListSerlializer): class CstMoveSerlializer(CstListSerlializer):
''' Serializer: Change constituenta position. ''' ''' Serializer: Change constituenta position. '''
move_to = serializers.IntegerField() move_to = serializers.IntegerField()
def create(self, validated_data):
raise NotImplementedError('unexpected `create()` call')
def update(self, instance, validated_data):
raise NotImplementedError('unexpected `update()` call')

View File

@ -4,3 +4,4 @@ from .t_views import *
from .t_models import * from .t_models import *
from .t_serializers import * from .t_serializers import *
from .t_graph import * from .t_graph import *
from .t_utils import *

View File

@ -227,64 +227,30 @@ class TestRSForm(TestCase):
self.assertEqual(x1.order, 2) self.assertEqual(x1.order, 2)
self.assertEqual(x2.order, 1) self.assertEqual(x2.order, 1)
def test_to_trs(self): def test_reset_aliases(self):
schema = RSForm.objects.create(title='Test', alias='KS1', comment='Test') schema = RSForm.objects.create(title='Test')
x1 = schema.insert_at(4, 'X1', CstType.BASE) x1 = schema.insert_last('X11', CstType.BASE)
x2 = schema.insert_at(1, 'X2', CstType.BASE) x2 = schema.insert_last('X21', CstType.BASE)
expected = json.loads( d1 = schema.insert_last('D11', CstType.TERM)
f'{{"type": "rsform", "title": "Test", "alias": "KS1", ' x1.term_raw = 'человек'
f'"comment": "Test", "items": ' x1.term_resolved = 'человек'
f'[{{"entityUID": {x2.id}, "type": "constituenta", "cstType": "basic", "alias": "X2", "convention": "", ' d1.convention = 'D11 - cool'
f'"term": {{"raw": "", "resolved": "", "forms": []}}, ' d1.definition_formal = 'X21=X21'
f'"definition": {{"formal": "", "text": {{"raw": "", "resolved": ""}}}}}}, ' d1.term_raw = '@{X21|sing}'
f'{{"entityUID": {x1.id}, "type": "constituenta", "cstType": "basic", "alias": "X1", "convention": "", ' d1.definition_raw = '@{X11|datv}'
f'"term": {{"raw": "", "resolved": "", "forms": []}}, ' d1.definition_resolved = 'test'
f'"definition": {{"formal": "", "text": {{"raw": "", "resolved": ""}}}}}}]}}' d1.save()
) x1.save()
self.assertEqual(schema.to_trs(), expected)
def test_create_from_trs(self): schema.reset_aliases()
input = json.loads( x1.refresh_from_db()
'{"type": "rsform", "title": "Test", "alias": "KS1", '
'"comment": "Test", "items": '
'[{"entityUID": 1337, "type": "constituenta", "cstType": "basic", "alias": "X1", "convention": "", '
'"term": {"raw": "", "resolved": ""}, '
'"definition": {"formal": "123", "text": {"raw": "", "resolved": ""}}}, '
'{"entityUID": 55, "type": "constituenta", "cstType": "basic", "alias": "X2", "convention": "", '
'"term": {"raw": "", "resolved": ""}, '
'"definition": {"formal": "", "text": {"raw": "", "resolved": ""}}}]}'
)
schema = RSForm.create_from_trs(self.user1, input, False)
self.assertEqual(schema.owner, self.user1)
self.assertEqual(schema.title, 'Test')
self.assertEqual(schema.alias, 'KS1')
self.assertEqual(schema.is_common, False)
constituents = schema.constituents().order_by('order')
self.assertEqual(constituents.count(), 2)
self.assertEqual(constituents[0].alias, 'X1')
self.assertEqual(constituents[0].definition_formal, '123')
def test_load_trs(self):
schema = RSForm.objects.create(title='Test', owner=self.user1, alias='КС1')
x2 = schema.insert_last('X2', CstType.BASE)
schema.insert_last('X3', CstType.BASE)
input = json.loads(
'{"title": "Test1", "alias": "KS1", '
'"comment": "Test", "items": '
'[{"entityUID": "' + str(x2.id) + '", "cstType": "basic", "alias": "X1", "convention": "test", '
'"term": {"raw": "t1", "resolved": "t2"}, '
'"definition": {"formal": "123", "text": {"raw": "@{X1|datv}", "resolved": "t4"}}}]}'
)
schema.load_trs(input, sync_metadata=True, skip_update=True)
x2.refresh_from_db() x2.refresh_from_db()
self.assertEqual(schema.constituents().count(), 1) d1.refresh_from_db()
self.assertEqual(schema.title, input['title'])
self.assertEqual(schema.alias, input['alias']) self.assertEqual(x1.alias, 'X1')
self.assertEqual(schema.comment, input['comment']) self.assertEqual(x2.alias, 'X2')
self.assertEqual(x2.alias, input['items'][0]['alias']) self.assertEqual(d1.alias, 'D1')
self.assertEqual(x2.convention, input['items'][0]['convention']) self.assertEqual(d1.convention, 'D1 - cool')
self.assertEqual(x2.term_raw, input['items'][0]['term']['raw']) self.assertEqual(d1.term_raw, '@{X2|sing}')
self.assertEqual(x2.term_resolved, input['items'][0]['term']['raw']) self.assertEqual(d1.definition_raw, '@{X1|datv}')
self.assertEqual(x2.definition_formal, input['items'][0]['definition']['formal']) self.assertEqual(d1.definition_resolved, 'test')
self.assertEqual(x2.definition_raw, input['items'][0]['definition']['text']['raw'])
self.assertEqual(x2.definition_resolved, input['items'][0]['term']['raw'])

View File

@ -0,0 +1,16 @@
''' Unit tests: utils. '''
import unittest
import re
from apps.rsform.utils import apply_mapping_pattern
class TestUtils(unittest.TestCase):
''' Test various utilitiy functions. '''
def test_apply_mapping_patter(self):
mapping = {'X101': 'X20'}
pattern = re.compile(r'(X[0-9]+)')
self.assertEqual(apply_mapping_pattern('', mapping, pattern), '')
self.assertEqual(apply_mapping_pattern('X20', mapping, pattern), 'X20')
self.assertEqual(apply_mapping_pattern('X101', mapping, pattern), 'X20')
self.assertEqual(apply_mapping_pattern('asdf X101 asdf', mapping, pattern), 'asdf X20 asdf')

View File

@ -1,6 +1,7 @@
''' Utility functions ''' ''' Utility functions '''
import json import json
from io import BytesIO from io import BytesIO
import re
from zipfile import ZipFile from zipfile import ZipFile
from rest_framework.permissions import BasePermission from rest_framework.permissions import BasePermission
@ -35,13 +36,24 @@ def read_trs(file) -> dict:
def write_trs(json_data: dict) -> bytes: def write_trs(json_data: dict) -> bytes:
''' Write json data to TRS file including version info ''' ''' Write json data to TRS file including version info '''
json_data["claimed"] = False
json_data["selection"] = []
json_data["version"] = 16
json_data["versionInfo"] = "Exteor 4.8.13.1000 - 30/05/2022"
content = BytesIO() content = BytesIO()
data = json.dumps(json_data, indent=4, ensure_ascii=False) data = json.dumps(json_data, indent=4, ensure_ascii=False)
with ZipFile(content, 'w') as archive: with ZipFile(content, 'w') as archive:
archive.writestr('document.json', data=data) archive.writestr('document.json', data=data)
return content.getvalue() return content.getvalue()
def apply_mapping_pattern(text: str, mapping: dict[str, str], pattern: re.Pattern[str]) -> str:
''' Apply mapping to matching in regular expression patter subgroup 1. '''
if text == '' or pattern == '':
return text
pos_input: int = 0
output: str = ''
for segment in re.finditer(pattern, text):
entity = segment.group(1)
if entity in mapping:
output += text[pos_input : segment.start(1)]
output += mapping[entity]
output += text[segment.end(1) : segment.end(0)]
pos_input = segment.end(0)
output += text[pos_input : len(text)]
return output

View File

@ -17,7 +17,7 @@ from . import utils
class LibraryView(generics.ListAPIView): class LibraryView(generics.ListAPIView):
''' Endpoint: Get list of rsforms available for active user. ''' ''' Endpoint: Get list of rsforms available for active user. '''
permission_classes = (permissions.AllowAny,) permission_classes = (permissions.AllowAny,)
serializer_class = serializers.RSFormSerializer serializer_class = serializers.RSFormMetaSerializer
def get_queryset(self): def get_queryset(self):
user = self.request.user user = self.request.user
@ -45,7 +45,7 @@ class ConstituentAPIView(generics.RetrieveUpdateAPIView):
class RSFormViewSet(viewsets.ModelViewSet): class RSFormViewSet(viewsets.ModelViewSet):
''' Endpoint: RSForm operations. ''' ''' Endpoint: RSForm operations. '''
queryset = models.RSForm.objects.all() queryset = models.RSForm.objects.all()
serializer_class = serializers.RSFormSerializer serializer_class = serializers.RSFormMetaSerializer
filter_backends = (DjangoFilterBackend, filters.OrderingFilter) filter_backends = (DjangoFilterBackend, filters.OrderingFilter)
filterset_fields = ['owner', 'is_common'] filterset_fields = ['owner', 'is_common']
@ -80,10 +80,9 @@ class RSFormViewSet(viewsets.ModelViewSet):
data = serializer.validated_data data = serializer.validated_data
new_cst = schema.create_cst(data, data['insert_after'] if 'insert_after' in data else None) new_cst = schema.create_cst(data, data['insert_after'] if 'insert_after' in data else None)
schema.refresh_from_db() schema.refresh_from_db()
outSerializer = serializers.RSFormDetailsSerlializer(schema)
response = Response(status=201, data={ response = Response(status=201, data={
'new_cst': serializers.ConstituentaSerializer(new_cst).data, 'new_cst': serializers.ConstituentaSerializer(new_cst).data,
'schema': outSerializer.data 'schema': models.PyConceptAdapter(schema).full()
}) })
response['Location'] = new_cst.get_absolute_url() response['Location'] = new_cst.get_absolute_url()
return response return response
@ -96,8 +95,7 @@ class RSFormViewSet(viewsets.ModelViewSet):
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
schema.delete_cst(serializer.validated_data['constituents']) schema.delete_cst(serializer.validated_data['constituents'])
schema.refresh_from_db() schema.refresh_from_db()
outSerializer = serializers.RSFormDetailsSerlializer(schema) return Response(status=202, data=models.PyConceptAdapter(schema).full())
return Response(status=202, data=outSerializer.data)
@action(detail=True, methods=['patch'], url_path='cst-moveto') @action(detail=True, methods=['patch'], url_path='cst-moveto')
def cst_moveto(self, request, pk): def cst_moveto(self, request, pk):
@ -107,17 +105,14 @@ class RSFormViewSet(viewsets.ModelViewSet):
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
schema.move_cst(serializer.validated_data['constituents'], serializer.validated_data['move_to']) schema.move_cst(serializer.validated_data['constituents'], serializer.validated_data['move_to'])
schema.refresh_from_db() schema.refresh_from_db()
outSerializer = serializers.RSFormDetailsSerlializer(schema) return Response(status=200, data=models.PyConceptAdapter(schema).full())
return Response(status=200, data=outSerializer.data)
@action(detail=True, methods=['patch'], url_path='reset-aliases') @action(detail=True, methods=['patch'], url_path='reset-aliases')
def reset_aliases(self, request, pk): def reset_aliases(self, request, pk):
''' Endpoint: Recreate all aliases based on order. ''' ''' Endpoint: Recreate all aliases based on order. '''
schema = self._get_schema() schema = self._get_schema()
result = json.loads(pyconcept.reset_aliases(json.dumps(schema.to_trs()))) schema.reset_aliases()
schema.load_trs(data=result, sync_metadata=False, skip_update=True) return Response(status=200, data=models.PyConceptAdapter(schema).full())
outSerializer = serializers.RSFormDetailsSerlializer(schema)
return Response(status=200, data=outSerializer.data)
@action(detail=True, methods=['patch'], url_path='load-trs') @action(detail=True, methods=['patch'], url_path='load-trs')
def load_trs(self, request, pk): def load_trs(self, request, pk):
@ -127,25 +122,30 @@ class RSFormViewSet(viewsets.ModelViewSet):
schema = self._get_schema() schema = self._get_schema()
load_metadata = serializer.validated_data['load_metadata'] load_metadata = serializer.validated_data['load_metadata']
data = utils.read_trs(request.FILES['file'].file) data = utils.read_trs(request.FILES['file'].file)
schema.load_trs(data, load_metadata, skip_update=False) data['id'] = schema.pk
outSerializer = serializers.RSFormDetailsSerlializer(schema)
return Response(status=200, data=outSerializer.data) serializer = serializers.RSFormTRSSerializer(data=data, context={'load_meta': load_metadata})
serializer.is_valid(raise_exception=True)
schema = serializer.save()
return Response(status=200, data=models.PyConceptAdapter(schema).full())
@action(detail=True, methods=['post'], url_path='clone') @action(detail=True, methods=['post'], url_path='clone')
def clone(self, request, pk): def clone(self, request, pk):
''' Endpoint: Clone RSForm constituents and create new schema using new metadata. ''' ''' Endpoint: Clone RSForm constituents and create new schema using new metadata. '''
serializer = serializers.RSFormSerializer(data=request.data) serializer = serializers.RSFormMetaSerializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
new_schema = models.RSForm.objects.create(
title=serializer.validated_data['title'], clone_data = serializers.RSFormTRSSerializer(self._get_schema()).data
owner=self.request.user, clone_data['owner'] = self.request.user
alias=serializer.validated_data.get('alias', ''), clone_data['title'] = serializer.validated_data['title']
comment=serializer.validated_data.get('comment', ''), clone_data['alias'] = serializer.validated_data.get('alias', '')
is_common=serializer.validated_data.get('is_common', False), clone_data['comment'] = serializer.validated_data.get('comment', '')
) clone_data['is_common'] = serializer.validated_data.get('is_common', False)
new_schema.load_trs(data=self._get_schema().to_trs(), sync_metadata=False, skip_update=True)
outSerializer = serializers.RSFormDetailsSerlializer(new_schema) clone = serializers.RSFormTRSSerializer(data=clone_data, context={'load_meta': True})
return Response(status=201, data=outSerializer.data) clone.is_valid(raise_exception=True)
new_schema = clone.save()
return Response(status=201, data=models.PyConceptAdapter(new_schema).full())
@action(detail=True, methods=['post']) @action(detail=True, methods=['post'])
def claim(self, request, pk=None): def claim(self, request, pk=None):
@ -156,7 +156,7 @@ class RSFormViewSet(viewsets.ModelViewSet):
else: else:
schema.owner = self.request.user schema.owner = self.request.user
schema.save() schema.save()
return Response(status=200, data=serializers.RSFormSerializer(schema).data) return Response(status=200, data=serializers.RSFormMetaSerializer(schema).data)
@action(detail=True, methods=['get']) @action(detail=True, methods=['get'])
def contents(self, request, pk): def contents(self, request, pk):
@ -166,25 +166,25 @@ class RSFormViewSet(viewsets.ModelViewSet):
@action(detail=True, methods=['get']) @action(detail=True, methods=['get'])
def details(self, request, pk): def details(self, request, pk):
''' Endpoint: Detailed schema view including statuses. ''' ''' Endpoint: Detailed schema view including statuses and parse. '''
schema = self._get_schema() schema = self._get_schema()
serializer = serializers.RSFormDetailsSerlializer(schema) serializer = models.PyConceptAdapter(schema)
return Response(serializer.data) return Response(serializer.full())
@action(detail=True, methods=['post']) @action(detail=True, methods=['post'])
def check(self, request, pk): def check(self, request, pk):
''' Endpoint: Check RSLang expression against schema context. ''' ''' Endpoint: Check RSLang expression against schema context. '''
schema = self._get_schema().to_trs() schema = models.PyConceptAdapter(self._get_schema())
serializer = serializers.ExpressionSerializer(data=request.data) serializer = serializers.ExpressionSerializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
expression = serializer.validated_data['expression'] expression = serializer.validated_data['expression']
result = pyconcept.check_expression(json.dumps(schema), expression) result = pyconcept.check_expression(json.dumps(schema.data), expression)
return Response(json.loads(result)) return Response(json.loads(result))
@action(detail=True, methods=['get'], url_path='export-trs') @action(detail=True, methods=['get'], url_path='export-trs')
def export_trs(self, request, pk): def export_trs(self, request, pk):
''' Endpoint: Download Exteor compatible file. ''' ''' Endpoint: Download Exteor compatible file. '''
schema = self._get_schema().to_trs() schema = serializers.RSFormTRSSerializer(self._get_schema()).data
trs = utils.write_trs(schema) trs = utils.write_trs(schema)
filename = self._get_schema().alias filename = self._get_schema().alias
if filename == '' or not filename.isascii(): if filename == '' or not filename.isascii():
@ -207,8 +207,11 @@ class TrsImportView(views.APIView):
owner = self.request.user owner = self.request.user
if owner.is_anonymous: if owner.is_anonymous:
owner = None owner = None
schema = models.RSForm.create_from_trs(owner, data) _prepare_rsform_data(data, request, owner)
result = serializers.RSFormSerializer(schema) serializer = serializers.RSFormTRSSerializer(data=data, context={'load_meta': True})
serializer.is_valid(raise_exception=True)
schema = serializer.save()
result = serializers.RSFormMetaSerializer(schema)
return Response(status=201, data=result.data) return Response(status=201, data=result.data)
@ -219,7 +222,7 @@ def create_rsform(request):
if owner.is_anonymous: if owner.is_anonymous:
owner = None owner = None
if 'file' not in request.FILES: if 'file' not in request.FILES:
serializer = serializers.RSFormSerializer(data=request.data) serializer = serializers.RSFormMetaSerializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
schema = models.RSForm.objects.create( schema = models.RSForm.objects.create(
title=serializer.validated_data['title'], title=serializer.validated_data['title'],
@ -230,21 +233,27 @@ def create_rsform(request):
) )
else: else:
data = utils.read_trs(request.FILES['file'].file) data = utils.read_trs(request.FILES['file'].file)
if 'title' in request.data and request.data['title'] != '': _prepare_rsform_data(data, request, owner)
data['title'] = request.data['title'] serializer = serializers.RSFormTRSSerializer(data=data, context={'load_meta': True})
if data['title'] == '': serializer.is_valid(raise_exception=True)
data['title'] = 'Без названия ' + request.FILES['file'].fileName schema = serializer.save()
if 'alias' in request.data and request.data['alias'] != '': result = serializers.RSFormMetaSerializer(schema)
data['alias'] = request.data['alias']
if 'comment' in request.data and request.data['comment'] != '':
data['comment'] = request.data['comment']
is_common = True
if 'is_common' in request.data:
is_common = request.data['is_common'] == 'true'
schema = models.RSForm.create_from_trs(owner, data, is_common)
result = serializers.RSFormSerializer(schema)
return Response(status=201, data=result.data) return Response(status=201, data=result.data)
def _prepare_rsform_data(data: dict, request, owner: models.User):
if 'title' in request.data and request.data['title'] != '':
data['title'] = request.data['title']
if data['title'] == '':
data['title'] = 'Без названия ' + request.FILES['file'].fileName
if 'alias' in request.data and request.data['alias'] != '':
data['alias'] = request.data['alias']
if 'comment' in request.data and request.data['comment'] != '':
data['comment'] = request.data['comment']
is_common = True
if 'is_common' in request.data:
is_common = request.data['is_common'] == 'true'
data['is_common'] = is_common
data['owner'] = owner
@api_view(['POST']) @api_view(['POST'])
def parse_expression(request): def parse_expression(request):

View File

@ -1,4 +1,3 @@
''' Tests. ''' ''' Tests. '''
# flake8: noqa
from .t_views import * from .t_views import *
from .t_serializers import * from .t_serializers import *

View File

@ -43,7 +43,7 @@ function App () {
/> />
<div className='overflow-auto' style={{maxHeight: scrollWindowSize}}> <div className='overflow-auto' style={{maxHeight: scrollWindowSize}}>
<main className='px-2' style={{minHeight: mainSize}}> <main style={{minHeight: mainSize}}>
<Routes> <Routes>
<Route path='/' element={ <HomePage/>} /> <Route path='/' element={ <HomePage/>} />

View File

@ -1,14 +1,20 @@
interface SubmitButtonProps { interface SubmitButtonProps
text: string extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'className' | 'children' | 'title'> {
text?: string
tooltip?: string
loading?: boolean loading?: boolean
disabled?: boolean
icon?: React.ReactNode icon?: React.ReactNode
widthClass?: string
} }
function SubmitButton({ text = 'ОК', icon, disabled, loading = false }: SubmitButtonProps) { function SubmitButton({
text = 'ОК', icon, disabled, tooltip, loading,
widthClass = 'w-fit h-fit'
}: SubmitButtonProps) {
return ( return (
<button type='submit' <button type='submit'
className={`px-4 py-2 inline-flex items-center gap-2 align-middle justify-center font-bold select-none disabled:cursor-not-allowed border rounded clr-btn-primary ${loading ? ' cursor-progress' : ''}`} title={tooltip}
className={`px-4 py-2 inline-flex items-center gap-2 align-middle justify-center font-bold select-none disabled:cursor-not-allowed border rounded clr-btn-primary ${widthClass} ${loading ? ' cursor-progress' : ''}`}
disabled={disabled ?? loading} disabled={disabled ?? loading}
> >
{icon && <span>{icon}</span>} {icon && <span>{icon}</span>}

View File

@ -7,7 +7,7 @@ interface TextURLProps {
function TextURL({ text, href }: TextURLProps) { function TextURL({ text, href }: TextURLProps) {
return ( return (
<Link className='font-bold hover:underline clr-text' to={href}> <Link className='font-bold hover:underline text-url' to={href}>
{text} {text}
</Link> </Link>
); );

View File

@ -68,8 +68,9 @@ function ViewLibrary({ schemas }: ViewLibraryProps) {
highlightOnHover highlightOnHover
pointerOnHover pointerOnHover
noDataComponent={<span className='flex flex-col justify-center p-2 text-center'> noDataComponent={
<p>Список схем пуст</p> <div className='flex flex-col gap-4 justify-center p-2 text-center min-h-[10rem]'>
<p><b>Список схем пуст</b></p>
<p> <p>
<TextURL text='Создать схему' href='/rsform-create'/> <TextURL text='Создать схему' href='/rsform-create'/>
<span> | </span> <span> | </span>
@ -79,7 +80,7 @@ function ViewLibrary({ schemas }: ViewLibraryProps) {
<b>Очистить фильтр</b> <b>Очистить фильтр</b>
</span> </span>
</p> </p>
</span>} </div>}
pagination pagination
paginationPerPage={50} paginationPerPage={50}

View File

@ -39,7 +39,7 @@ function LoginPage() {
} }
return ( return (
<div className='w-full py-2'> { user <div className='w-full py-4'> { user
? <b>{`Вы вошли в систему как ${user.username}`}</b> ? <b>{`Вы вошли в систему как ${user.username}`}</b>
: <Form title='Ввод данных пользователя' onSubmit={handleSubmit} widthClass='w-[21rem]'> : <Form title='Ввод данных пользователя' onSubmit={handleSubmit} widthClass='w-[21rem]'>
<TextInput id='username' <TextInput id='username'
@ -58,11 +58,15 @@ function LoginPage() {
onChange={event => { setPassword(event.target.value); }} onChange={event => { setPassword(event.target.value); }}
/> />
<div className='flex items-center justify-between mt-4'> <div className='flex justify-center w-full gap-2 mt-4'>
<SubmitButton text='Вход' loading={loading}/> <SubmitButton
<TextURL text='Восстановить пароль...' href='/restore-password' /> text='Вход'
widthClass='w-[7rem]'
loading={loading}
/>
</div> </div>
<div className='mt-2'> <div className='flex flex-col mt-2 text-sm'>
<TextURL text='Восстановить пароль...' href='/restore-password' />
<TextURL text='Нет аккаунта? Зарегистрируйтесь...' href='/signup' /> <TextURL text='Нет аккаунта? Зарегистрируйтесь...' href='/signup' />
</div> </div>
{ error && <BackendError error={error} />} { error && <BackendError error={error} />}

View File

@ -217,7 +217,7 @@ function RSTabs() {
defaultFocus={true} defaultFocus={true}
selectedTabClassName='font-bold' selectedTabClassName='font-bold'
> >
<TabList className='flex items-start select-none w-fit clr-bg-pop'> <TabList className='flex items-start px-2 select-none w-fit clr-bg-pop'>
<RSTabsMenu <RSTabsMenu
onDestroy={onDestroySchema} onDestroy={onDestroySchema}
showCloneDialog={() => setShowClone(true)} showCloneDialog={() => setShowClone(true)}
@ -232,7 +232,7 @@ function RSTabs() {
<ConceptTab>Граф термов</ConceptTab> <ConceptTab>Граф термов</ConceptTab>
</TabList> </TabList>
<TabPanel className='flex items-start w-full gap-2'> <TabPanel className='flex items-start w-full gap-2 px-2'>
<EditorRSForm <EditorRSForm
onDestroy={onDestroySchema} onDestroy={onDestroySchema}
/> />
@ -247,7 +247,7 @@ function RSTabs() {
/> />
</TabPanel> </TabPanel>
<TabPanel> <TabPanel className='pl-2'>
<EditorConstituenta <EditorConstituenta
activeID={activeID} activeID={activeID}
onOpenEdit={onOpenCst} onOpenEdit={onOpenCst}
@ -257,7 +257,7 @@ function RSTabs() {
/> />
</TabPanel> </TabPanel>
<TabPanel> <TabPanel className='pl-2'>
<EditorTermGraph <EditorTermGraph
onOpenEdit={onOpenCst} onOpenEdit={onOpenCst}
onCreateCst={promptCreateCst} onCreateCst={promptCreateCst}

View File

@ -21,7 +21,7 @@ function DependencyModePicker({ value, onChange }: DependencyModePickerProps) {
}, [pickerMenu, onChange]); }, [pickerMenu, onChange]);
return ( return (
<div ref={pickerMenu.ref}> <div ref={pickerMenu.ref} className='h-full'>
<span <span
className='text-sm font-semibold underline cursor-pointer select-none whitespace-nowrap' className='text-sm font-semibold underline cursor-pointer select-none whitespace-nowrap'
tabIndex={-1} tabIndex={-1}

View File

@ -21,7 +21,7 @@ function MatchModePicker({ value, onChange }: MatchModePickerProps) {
}, [pickerMenu, onChange]); }, [pickerMenu, onChange]);
return ( return (
<div ref={pickerMenu.ref}> <div ref={pickerMenu.ref} className='h-full'>
<span <span
className='text-sm font-semibold underline cursor-pointer select-none whitespace-nowrap' className='text-sm font-semibold underline cursor-pointer select-none whitespace-nowrap'
tabIndex={-1} tabIndex={-1}

View File

@ -152,13 +152,13 @@ function ViewSideConstituents({ expression, baseHeight, activeID, onOpenEdit }:
}, [noNavigation, baseHeight]); }, [noNavigation, baseHeight]);
return (<> return (<>
<div className='sticky top-0 left-0 right-0 z-10 flex items-center justify-between w-full gap-1 px-2 py-1 bg-white border-b rounded clr-bg-pop clr-border'> <div className='sticky top-0 left-0 right-0 z-10 flex items-start justify-between w-full gap-1 px-2 py-1 bg-white border-b rounded clr-bg-pop clr-border'>
<MatchModePicker <MatchModePicker
value={filterMatch} value={filterMatch}
onChange={setFilterMatch} onChange={setFilterMatch}
/> />
<input type='text' <input type='text'
className='w-full px-2 bg-white outline-none select-none hover:text-clip clr-bg-pop clr-border' className='w-full px-2 bg-white outline-none select-none hover:text-clip clr-bg-pop'
placeholder='наберите текст фильтра' placeholder='наберите текст фильтра'
value={filterText} value={filterText}
onChange={event => setFilterText(event.target.value)} onChange={event => setFilterText(event.target.value)}