Implement termform editor

This commit is contained in:
IRBorisov 2023-09-25 14:17:52 +03:00
parent f7a7a1b173
commit 83242dfb69
23 changed files with 667 additions and 250 deletions

View File

@ -12,9 +12,9 @@ from django.core.exceptions import ValidationError
from django.urls import reverse
from apps.users.models import User
from cctext import Resolver, Entity, extract_entities
from cctext import Resolver, Entity, extract_entities, split_grams, TermForm
from .graph import Graph
from .utils import apply_mapping_pattern
from .utils import apply_pattern
_REF_ENTITY_PATTERN = re.compile(r'@{([^0-9\-].*?)\|.*?}')
@ -273,7 +273,14 @@ class RSForm:
''' Create resolver for text references based on schema terms. '''
result = Resolver({})
for cst in self.constituents():
entity = Entity(alias=cst.alias, nominal=cst.term_resolved, manual_forms=cst.term_forms)
entity = Entity(
alias=cst.alias,
nominal=cst.term_resolved,
manual_forms=[
TermForm(text=form['text'], grams=split_grams(form['tags']))
for form in cst.term_forms
]
)
result.context[cst.alias] = entity
return result
@ -314,7 +321,10 @@ class RSForm:
raise ValidationError('Invalid position: should be positive integer')
currentSize = self.constituents().count()
position = max(1, min(position, currentSize + 1))
update_list = Constituenta.objects.only('id', 'order', 'schema').filter(schema=self.item, order__gte=position)
update_list = \
Constituenta.objects \
.only('id', 'order', 'schema') \
.filter(schema=self.item, order__gte=position)
for cst in update_list:
cst.order += 1
Constituenta.objects.bulk_update(update_list, ['order'])
@ -424,19 +434,19 @@ class RSForm:
if change_aliases and cst.alias in mapping:
modified = True
cst.alias = mapping[cst.alias]
expression = apply_mapping_pattern(cst.definition_formal, mapping, _GLOBAL_ID_PATTERN)
expression = apply_pattern(cst.definition_formal, mapping, _GLOBAL_ID_PATTERN)
if expression != cst.definition_formal:
modified = True
cst.definition_formal = expression
convention = apply_mapping_pattern(cst.convention, mapping, _GLOBAL_ID_PATTERN)
convention = apply_pattern(cst.convention, mapping, _GLOBAL_ID_PATTERN)
if convention != cst.convention:
modified = True
cst.convention = convention
term = apply_mapping_pattern(cst.term_raw, mapping, _REF_ENTITY_PATTERN)
term = apply_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)
definition = apply_pattern(cst.definition_raw, mapping, _REF_ENTITY_PATTERN)
if definition != cst.definition_raw:
modified = True
cst.definition_raw = definition

View File

@ -148,18 +148,19 @@ class ConstituentaSerializer(serializers.ModelSerializer):
read_only_fields = ('id', 'order', 'alias', 'cst_type', 'definition_resolved', 'term_resolved')
def update(self, instance: Constituenta, validated_data) -> Constituenta:
data = validated_data # Note: create alias for better code readability
schema = RSForm(instance.schema)
definition: Optional[str] = validated_data['definition_raw'] if 'definition_raw' in validated_data else None
term: Optional[str] = validated_data['term_raw'] if 'term_raw' in validated_data else None
term_changed = False
definition: Optional[str] = data['definition_raw'] if 'definition_raw' in data else None
term: Optional[str] = data['term_raw'] if 'term_raw' in data else None
term_changed = 'term_forms' in data
if definition is not None and definition != instance.definition_raw :
validated_data['definition_resolved'] = schema.resolver().resolve(definition)
data['definition_resolved'] = schema.resolver().resolve(definition)
if term is not None and term != instance.term_raw:
validated_data['term_resolved'] = schema.resolver().resolve(term)
if validated_data['term_resolved'] != instance.term_resolved:
validated_data['term_forms'] = []
term_changed = validated_data['term_resolved'] != instance.term_resolved
result: Constituenta = super().update(instance, validated_data)
data['term_resolved'] = schema.resolver().resolve(term)
if data['term_resolved'] != instance.term_resolved and 'term_forms' not in data:
data['term_forms'] = []
term_changed = data['term_resolved'] != instance.term_resolved
result: Constituenta = super().update(instance, data)
if term_changed:
schema.on_term_change([result.alias])
result.refresh_from_db()

View File

@ -2,7 +2,7 @@
import unittest
import re
from apps.rsform.utils import apply_mapping_pattern, fix_old_references
from apps.rsform.utils import apply_pattern, fix_old_references
class TestUtils(unittest.TestCase):
@ -10,10 +10,10 @@ class TestUtils(unittest.TestCase):
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')
self.assertEqual(apply_pattern('', mapping, pattern), '')
self.assertEqual(apply_pattern('X20', mapping, pattern), 'X20')
self.assertEqual(apply_pattern('X101', mapping, pattern), 'X20')
self.assertEqual(apply_pattern('asdf X101 asdf', mapping, pattern), 'asdf X20 asdf')
def test_fix_old_references(self):
self.assertEqual(fix_old_references(''), '')

View File

@ -1,5 +1,4 @@
''' Testing views '''
import json
import os
import io
from zipfile import ZipFile
@ -52,30 +51,43 @@ class TestConstituentaAPI(APITestCase):
self.assertEqual(response.data['convention'], self.cst1.convention)
def test_partial_update(self):
data = json.dumps({'convention': 'tt'})
response = self.client.patch(f'/api/constituents/{self.cst2.id}', data, content_type='application/json')
data = {'convention': 'tt'}
response = self.client.patch(
f'/api/constituents/{self.cst2.id}',
data=data, format='json'
)
self.assertEqual(response.status_code, 403)
self.client.logout()
response = self.client.patch(f'/api/constituents/{self.cst1.id}', data, content_type='application/json')
response = self.client.patch(
f'/api/constituents/{self.cst1.id}',
data=data, format='json'
)
self.assertEqual(response.status_code, 403)
self.client.force_authenticate(user=self.user)
response = self.client.patch(f'/api/constituents/{self.cst1.id}', data, content_type='application/json')
response = self.client.patch(
f'/api/constituents/{self.cst1.id}',
data=data, format='json'
)
self.assertEqual(response.status_code, 200)
self.cst1.refresh_from_db()
self.assertEqual(response.data['convention'], 'tt')
self.assertEqual(self.cst1.convention, 'tt')
response = self.client.patch(f'/api/constituents/{self.cst1.id}', data, content_type='application/json')
response = self.client.patch(
f'/api/constituents/{self.cst1.id}',
data=data,
format='json'
)
self.assertEqual(response.status_code, 200)
def test_update_resolved_norefs(self):
data = json.dumps({
data = {
'term_raw': 'New term',
'definition_raw': 'New def'
})
response = self.client.patch(f'/api/constituents/{self.cst3.id}', data, content_type='application/json')
}
response = self.client.patch(f'/api/constituents/{self.cst3.id}', data, format='json')
self.assertEqual(response.status_code, 200)
self.cst3.refresh_from_db()
self.assertEqual(response.data['term_resolved'], 'New term')
@ -84,11 +96,14 @@ class TestConstituentaAPI(APITestCase):
self.assertEqual(self.cst3.definition_resolved, 'New def')
def test_update_resolved_refs(self):
data = json.dumps({
data = {
'term_raw': '@{X1|nomn,sing}',
'definition_raw': '@{X1|nomn,sing} @{X1|sing,datv}'
})
response = self.client.patch(f'/api/constituents/{self.cst3.id}', data, content_type='application/json')
}
response = self.client.patch(
f'/api/constituents/{self.cst3.id}',
data=data, format='json'
)
self.assertEqual(response.status_code, 200)
self.cst3.refresh_from_db()
self.assertEqual(self.cst3.term_resolved, self.cst1.term_resolved)
@ -97,8 +112,11 @@ class TestConstituentaAPI(APITestCase):
self.assertEqual(response.data['definition_resolved'], f'{self.cst1.term_resolved} form1')
def test_readonly_cst_fields(self):
data = json.dumps({'alias': 'X33', 'order': 10})
response = self.client.patch(f'/api/constituents/{self.cst1.id}', data, content_type='application/json')
data = {'alias': 'X33', 'order': 10}
response = self.client.patch(
f'/api/constituents/{self.cst1.id}',
data=data, format='json'
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['alias'], 'X1')
self.assertEqual(response.data['alias'], self.cst1.alias)
@ -132,29 +150,33 @@ class TestLibraryViewset(APITestCase):
def test_create_anonymous(self):
self.client.logout()
data = json.dumps({'title': 'Title'})
response = self.client.post('/api/library', data=data, content_type='application/json')
data = {'title': 'Title'}
response = self.client.post('/api/library', data=data, format='json')
self.assertEqual(response.status_code, 403)
def test_create_populate_user(self):
data = json.dumps({'title': 'Title'})
response = self.client.post('/api/library', data=data, content_type='application/json')
data = {'title': 'Title'}
response = self.client.post('/api/library', data=data, format='json')
self.assertEqual(response.status_code, 201)
self.assertEqual(response.data['title'], 'Title')
self.assertEqual(response.data['owner'], self.user.id)
def test_update(self):
data = json.dumps({'id': self.owned.id, 'title': 'New title'})
response = self.client.patch(f'/api/library/{self.owned.id}',
data=data, content_type='application/json')
data = {'id': self.owned.id, 'title': 'New title'}
response = self.client.patch(
f'/api/library/{self.owned.id}',
data=data, format='json'
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['title'], 'New title')
self.assertEqual(response.data['alias'], self.owned.alias)
def test_update_unowned(self):
data = json.dumps({'id': self.unowned.id, 'title': 'New title'})
response = self.client.patch(f'/api/library/{self.unowned.id}',
data=data, content_type='application/json')
data = {'id': self.unowned.id, 'title': 'New title'}
response = self.client.patch(
f'/api/library/{self.unowned.id}',
data=data, format='json'
)
self.assertEqual(response.status_code, 403)
def test_destroy(self):
@ -304,8 +326,11 @@ class TestRSFormViewset(APITestCase):
def test_check(self):
schema = RSForm.create(title='Test')
schema.insert_at(1, 'X1', CstType.BASE)
data = json.dumps({'expression': 'X1=X1'})
response = self.client.post(f'/api/rsforms/{schema.item.id}/check', data=data, content_type='application/json')
data = {'expression': 'X1=X1'}
response = self.client.post(
f'/api/rsforms/{schema.item.id}/check',
data=data, format='json'
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['parseResult'], True)
self.assertEqual(response.data['syntax'], Syntax.MATH)
@ -318,8 +343,11 @@ class TestRSFormViewset(APITestCase):
x1 = schema.insert_at(1, 'X1', CstType.BASE)
x1.term_resolved = 'синий слон'
x1.save()
data = json.dumps({'text': '@{1|редкий} @{X1|plur,datv}'})
response = self.client.post(f'/api/rsforms/{schema.item.id}/resolve', data=data, content_type='application/json')
data = {'text': '@{1|редкий} @{X1|plur,datv}'}
response = self.client.post(
f'/api/rsforms/{schema.item.id}/resolve',
data=data, format='json'
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['input'], '@{1|редкий} @{X1|plur,datv}')
self.assertEqual(response.data['output'], 'редким синим слонам')
@ -362,24 +390,30 @@ class TestRSFormViewset(APITestCase):
self.assertIn('document.json', zipped_file.namelist())
def test_create_constituenta(self):
data = json.dumps({'alias': 'X3', 'cst_type': 'basic'})
response = self.client.post(f'/api/rsforms/{self.unowned.item.id}/cst-create',
data=data, content_type='application/json')
data = {'alias': 'X3', 'cst_type': 'basic'}
response = self.client.post(
f'/api/rsforms/{self.unowned.item.id}/cst-create',
data=data, format='json'
)
self.assertEqual(response.status_code, 403)
item = self.owned.item
Constituenta.objects.create(schema=item, alias='X1', cst_type='basic', order=1)
x2 = Constituenta.objects.create(schema=item, alias='X2', cst_type='basic', order=2)
response = self.client.post(f'/api/rsforms/{item.id}/cst-create',
data=data, content_type='application/json')
response = self.client.post(
f'/api/rsforms/{item.id}/cst-create',
data=data, format='json'
)
self.assertEqual(response.status_code, 201)
self.assertEqual(response.data['new_cst']['alias'], 'X3')
x3 = Constituenta.objects.get(alias=response.data['new_cst']['alias'])
self.assertEqual(x3.order, 3)
data = json.dumps({'alias': 'X4', 'cst_type': 'basic', 'insert_after': x2.id})
response = self.client.post(f'/api/rsforms/{item.id}/cst-create',
data=data, content_type='application/json')
data = {'alias': 'X4', 'cst_type': 'basic', 'insert_after': x2.id}
response = self.client.post(
f'/api/rsforms/{item.id}/cst-create',
data=data, format='json'
)
self.assertEqual(response.status_code, 201)
self.assertEqual(response.data['new_cst']['alias'], 'X4')
x4 = Constituenta.objects.get(alias=response.data['new_cst']['alias'])
@ -389,30 +423,39 @@ class TestRSFormViewset(APITestCase):
self.cst1 = Constituenta.objects.create(
alias='X1', schema=self.owned.item, order=1, convention='Test',
term_raw='Test1', term_resolved='Test1',
term_forms=[{'text':'form1', 'tags':'sing,datv'}])
term_forms=[{'text':'form1', 'tags':'sing,datv'}]
)
self.cst2 = Constituenta.objects.create(
alias='X2', schema=self.unowned.item, order=1, convention='Test1',
term_raw='Test2', term_resolved='Test2')
term_raw='Test2', term_resolved='Test2'
)
self.cst3 = Constituenta.objects.create(
alias='X3', schema=self.owned.item, order=2,
term_raw='Test3', term_resolved='Test3',
definition_raw='Test1', definition_resolved='Test2')
definition_raw='Test1', definition_resolved='Test2'
)
data = json.dumps({'alias': 'D2', 'cst_type': 'term', 'id': self.cst2.pk})
response = self.client.patch(f'/api/rsforms/{self.unowned.item.id}/cst-rename',
data=data, content_type='application/json')
data = {'alias': 'D2', 'cst_type': 'term', 'id': self.cst2.pk}
response = self.client.patch(
f'/api/rsforms/{self.unowned.item.id}/cst-rename',
data=data, format='json'
)
self.assertEqual(response.status_code, 403)
response = self.client.patch(f'/api/rsforms/{self.owned.item.id}/cst-rename',
data=data, content_type='application/json')
response = self.client.patch(
f'/api/rsforms/{self.owned.item.id}/cst-rename',
data=data, format='json'
)
self.assertEqual(response.status_code, 400)
data = json.dumps({'alias': self.cst1.alias, 'cst_type': 'term', 'id': self.cst1.pk})
response = self.client.patch(f'/api/rsforms/{self.owned.item.id}/cst-rename',
data=data, content_type='application/json')
data = {'alias': self.cst1.alias, 'cst_type': 'term', 'id': self.cst1.pk}
response = self.client.patch(
f'/api/rsforms/{self.owned.item.id}/cst-rename',
data=data, format='json'
)
self.assertEqual(response.status_code, 400)
data = json.dumps({'alias': 'D2', 'cst_type': 'term', 'id': self.cst1.pk})
data = {'alias': 'D2', 'cst_type': 'term', 'id': self.cst1.pk}
item = self.owned.item
d1 = Constituenta.objects.create(schema=item, alias='D1', cst_type='term', order=4)
d1.term_raw = '@{X1|plur}'
@ -423,8 +466,10 @@ class TestRSFormViewset(APITestCase):
self.assertEqual(self.cst1.order, 1)
self.assertEqual(self.cst1.alias, 'X1')
self.assertEqual(self.cst1.cst_type, CstType.BASE)
response = self.client.patch(f'/api/rsforms/{item.id}/cst-rename',
data=data, content_type='application/json')
response = self.client.patch(
f'/api/rsforms/{item.id}/cst-rename',
data=data, format='json'
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['new_cst']['alias'], 'D2')
self.assertEqual(response.data['new_cst']['cst_type'], 'term')
@ -438,17 +483,19 @@ class TestRSFormViewset(APITestCase):
self.assertEqual(self.cst1.cst_type, CstType.TERM)
def test_create_constituenta_data(self):
data = json.dumps({
data = {
'alias': 'X3',
'cst_type': 'basic',
'convention': '1',
'term_raw': '2',
'definition_formal': '3',
'definition_raw': '4'
})
}
item = self.owned.item
response = self.client.post(f'/api/rsforms/{item.id}/cst-create',
data=data, content_type='application/json')
response = self.client.post(
f'/api/rsforms/{item.id}/cst-create',
data=data, format='json'
)
self.assertEqual(response.status_code, 201)
self.assertEqual(response.data['new_cst']['alias'], 'X3')
self.assertEqual(response.data['new_cst']['cst_type'], 'basic')
@ -461,16 +508,20 @@ class TestRSFormViewset(APITestCase):
def test_delete_constituenta(self):
schema = self.owned
data = json.dumps({'items': [1337]})
response = self.client.patch(f'/api/rsforms/{schema.item.id}/cst-multidelete',
data=data, content_type='application/json')
data = {'items': [1337]}
response = self.client.patch(
f'/api/rsforms/{schema.item.id}/cst-multidelete',
data=data, format='json'
)
self.assertEqual(response.status_code, 400)
x1 = Constituenta.objects.create(schema=schema.item, alias='X1', cst_type='basic', order=1)
x2 = Constituenta.objects.create(schema=schema.item, alias='X2', cst_type='basic', order=2)
data = json.dumps({'items': [x1.id]})
response = self.client.patch(f'/api/rsforms/{schema.item.id}/cst-multidelete',
data=data, content_type='application/json')
data = {'items': [x1.id]}
response = self.client.patch(
f'/api/rsforms/{schema.item.id}/cst-multidelete',
data=data, format='json'
)
x2.refresh_from_db()
schema.item.refresh_from_db()
self.assertEqual(response.status_code, 202)
@ -480,23 +531,29 @@ class TestRSFormViewset(APITestCase):
self.assertEqual(x2.order, 1)
x3 = Constituenta.objects.create(schema=self.unowned.item, alias='X1', cst_type='basic', order=1)
data = json.dumps({'items': [x3.id]})
response = self.client.patch(f'/api/rsforms/{schema.item.id}/cst-multidelete',
data=data, content_type='application/json')
data = {'items': [x3.id]}
response = self.client.patch(
f'/api/rsforms/{schema.item.id}/cst-multidelete',
data=data, format='json'
)
self.assertEqual(response.status_code, 400)
def test_move_constituenta(self):
item = self.owned.item
data = json.dumps({'items': [1337], 'move_to': 1})
response = self.client.patch(f'/api/rsforms/{item.id}/cst-moveto',
data=data, content_type='application/json')
data = {'items': [1337], 'move_to': 1}
response = self.client.patch(
f'/api/rsforms/{item.id}/cst-moveto',
data=data, format='json'
)
self.assertEqual(response.status_code, 400)
x1 = Constituenta.objects.create(schema=item, alias='X1', cst_type='basic', order=1)
x2 = Constituenta.objects.create(schema=item, alias='X2', cst_type='basic', order=2)
data = json.dumps({'items': [x2.id], 'move_to': 1})
response = self.client.patch(f'/api/rsforms/{item.id}/cst-moveto',
data=data, content_type='application/json')
data = {'items': [x2.id], 'move_to': 1}
response = self.client.patch(
f'/api/rsforms/{item.id}/cst-moveto',
data=data, format='json'
)
x1.refresh_from_db()
x2.refresh_from_db()
self.assertEqual(response.status_code, 200)
@ -505,9 +562,11 @@ class TestRSFormViewset(APITestCase):
self.assertEqual(x2.order, 1)
x3 = Constituenta.objects.create(schema=self.unowned.item, alias='X1', cst_type='basic', order=1)
data = json.dumps({'items': [x3.id], 'move_to': 1})
response = self.client.patch(f'/api/rsforms/{item.id}/cst-moveto',
data=data, content_type='application/json')
data = {'items': [x3.id], 'move_to': 1}
response = self.client.patch(
f'/api/rsforms/{item.id}/cst-moveto',
data=data, format='json'
)
self.assertEqual(response.status_code, 400)
def test_reset_aliases(self):
@ -542,7 +601,10 @@ class TestRSFormViewset(APITestCase):
work_dir = os.path.dirname(os.path.abspath(__file__))
with open(f'{work_dir}/data/sample-rsform.trs', 'rb') as file:
data = {'file': file, 'load_metadata': False}
response = self.client.patch(f'/api/rsforms/{schema.item.id}/load-trs', data=data, format='multipart')
response = self.client.patch(
f'/api/rsforms/{schema.item.id}/load-trs',
data=data, format='multipart'
)
schema.item.refresh_from_db()
self.assertEqual(response.status_code, 200)
self.assertEqual(schema.item.title, 'Testt11')
@ -563,8 +625,11 @@ class TestRSFormViewset(APITestCase):
x1.save()
d1.save()
data = json.dumps({'title': 'Title'})
response = self.client.post(f'/api/library/{item.id}/clone', data=data, content_type='application/json')
data = {'title': 'Title'}
response = self.client.post(
f'/api/library/{item.id}/clone',
data=data, format='json'
)
self.assertEqual(response.status_code, 201)
self.assertEqual(response.data['title'], 'Title')
@ -586,7 +651,10 @@ class TestRSLanguageViews(APITestCase):
work_dir = os.path.dirname(os.path.abspath(__file__))
with open(f'{work_dir}/data/sample-rsform.trs', 'rb') as file:
data = {'file': file, 'title': 'Test123', 'comment': '123', 'alias': 'ks1'}
response = self.client.post('/api/rsforms/create-detailed', data=data, format='multipart')
response = self.client.post(
'/api/rsforms/create-detailed',
data=data, format='multipart'
)
self.assertEqual(response.status_code, 201)
self.assertEqual(response.data['owner'], self.user.pk)
self.assertEqual(response.data['title'], 'Test123')
@ -595,7 +663,10 @@ class TestRSLanguageViews(APITestCase):
def test_create_rsform_fallback(self):
data = {'title': 'Test123', 'comment': '123', 'alias': 'ks1'}
response = self.client.post('/api/rsforms/create-detailed', data=data)
response = self.client.post(
'/api/rsforms/create-detailed',
data=data, format='json'
)
self.assertEqual(response.status_code, 201)
self.assertEqual(response.data['owner'], self.user.pk)
self.assertEqual(response.data['title'], 'Test123')
@ -604,35 +675,50 @@ class TestRSLanguageViews(APITestCase):
def test_convert_to_ascii(self):
data = {'expression': '1=1'}
request = self.factory.post('/api/rslang/to-ascii', data)
request = self.factory.post(
'/api/rslang/to-ascii',
data=data, format='json'
)
response = convert_to_ascii(request)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['result'], r'1 \eq 1')
def test_convert_to_ascii_missing_data(self):
data = {'data': '1=1'}
request = self.factory.post('/api/rslang/to-ascii', data)
request = self.factory.post(
'/api/rslang/to-ascii',
data=data, format='json'
)
response = convert_to_ascii(request)
self.assertEqual(response.status_code, 400)
self.assertIsInstance(response.data['expression'][0], ErrorDetail)
def test_convert_to_math(self):
data = {'expression': r'1 \eq 1'}
request = self.factory.post('/api/rslang/to-math', data)
request = self.factory.post(
'/api/rslang/to-math',
data=data, format='json'
)
response = convert_to_math(request)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['result'], r'1=1')
def test_convert_to_math_missing_data(self):
data = {'data': r'1 \eq 1'}
request = self.factory.post('/api/rslang/to-math', data)
request = self.factory.post(
'/api/rslang/to-math',
data=data, format='json'
)
response = convert_to_math(request)
self.assertEqual(response.status_code, 400)
self.assertIsInstance(response.data['expression'][0], ErrorDetail)
def test_parse_expression(self):
data = {'expression': r'1=1'}
request = self.factory.post('/api/rslang/parse-expression', data)
request = self.factory.post(
'/api/rslang/parse-expression',
data=data, format='json'
)
response = parse_expression(request)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['parseResult'], True)
@ -641,7 +727,10 @@ class TestRSLanguageViews(APITestCase):
def test_parse_expression_missing_data(self):
data = {'data': r'1=1'}
request = self.factory.post('/api/rslang/parse-expression', data)
request = self.factory.post(
'/api/rslang/parse-expression',
data=data, format='json'
)
response = parse_expression(request)
self.assertEqual(response.status_code, 400)
self.assertIsInstance(response.data['expression'][0], ErrorDetail)
@ -657,21 +746,30 @@ class TestNaturalLanguageViews(APITestCase):
def test_parse_text(self):
data = {'text': 'синим слонам'}
request = self.factory.post('/api/cctext/parse', data)
request = self.factory.post(
'/api/cctext/parse',
data=data, format='json'
)
response = parse_text(request)
self.assertEqual(response.status_code, 200)
self._assert_tags(response.data['result'], 'datv,NOUN,plur,anim,masc')
def test_inflect(self):
data = {'text': 'синий слон', 'grams': 'plur,datv'}
request = self.factory.post('/api/cctext/inflect', data)
request = self.factory.post(
'/api/cctext/inflect',
data=data, format='json'
)
response = inflect(request)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['result'], 'синим слонам')
def test_generate_lexeme(self):
data = {'text': 'синий слон'}
request = self.factory.post('/api/cctext/generate-lexeme', data)
request = self.factory.post(
'/api/cctext/generate-lexeme',
data=data, format='json'
)
response = generate_lexeme(request)
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.data['items']), 12)

View File

@ -50,7 +50,7 @@ def write_trs(json_data: dict) -> bytes:
archive.writestr('document.json', data=data)
return content.getvalue()
def apply_mapping_pattern(text: str, mapping: dict[str, str], pattern: re.Pattern[str]) -> str:
def apply_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

View File

@ -202,7 +202,10 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
serializer = s.CstCreateSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
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=data['insert_after'] if 'insert_after' in data else None
)
schema.item.refresh_from_db()
response = Response(
status=c.HTTP_201_CREATED,
@ -251,7 +254,10 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
def cst_multidelete(self, request, pk):
''' Endpoint: Delete multiple constituents. '''
schema = self._get_schema()
serializer = s.CstListSerializer(data=request.data, context={'schema': schema})
serializer = s.CstListSerializer(
data=request.data,
context={'schema': schema}
)
serializer.is_valid(raise_exception=True)
schema.delete_cst(serializer.validated_data['constituents'])
schema.item.refresh_from_db()
@ -270,9 +276,15 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
def cst_moveto(self, request, pk):
''' Endpoint: Move multiple constituents. '''
schema = self._get_schema()
serializer = s.CstMoveSerializer(data=request.data, context={'schema': schema})
serializer = s.CstMoveSerializer(
data=request.data,
context={'schema': schema}
)
serializer.is_valid(raise_exception=True)
schema.move_cst(serializer.validated_data['constituents'], serializer.validated_data['move_to'])
schema.move_cst(
listCst=serializer.validated_data['constituents'],
target=serializer.validated_data['move_to']
)
schema.item.refresh_from_db()
return Response(
status=c.HTTP_200_OK,
@ -311,7 +323,10 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
data = utils.read_trs(request.FILES['file'].file)
data['id'] = schema.item.pk
serializer = s.RSFormTRSSerializer(data=data, context={'load_meta': load_metadata})
serializer = s.RSFormTRSSerializer(
data=data,
context={'load_meta': load_metadata}
)
serializer.is_valid(raise_exception=True)
schema = serializer.save()
return Response(
@ -427,7 +442,10 @@ class TrsImportView(views.APIView):
if owner.is_anonymous:
owner = None
_prepare_rsform_data(data, request, owner)
serializer = s.RSFormTRSSerializer(data=data, context={'load_meta': True})
serializer = s.RSFormTRSSerializer(
data=data,
context={'load_meta': True}
)
serializer.is_valid(raise_exception=True)
schema = serializer.save()
result = s.LibraryItemSerializer(schema.item)
@ -575,7 +593,7 @@ def inflect(request):
@extend_schema(
summary='basic set of wordforms',
summary='all wordforms for current lexeme',
tags=['NaturalLanguage'],
request=s.TextSerializer,
responses={200: s.MultiFormSerializer},
@ -583,7 +601,7 @@ def inflect(request):
)
@api_view(['POST'])
def generate_lexeme(request):
''' Endpoint: Generate basic set of wordforms. '''
''' Endpoint: Generate complete set of wordforms for lexeme. '''
serializer = s.TextSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
nominal = serializer.validated_data['text']
@ -595,7 +613,7 @@ def generate_lexeme(request):
@extend_schema(
summary='get all language parse variants',
summary='get likely parse grammemes',
tags=['NaturalLanguage'],
request=s.TextSerializer,
responses={200: s.ResultTextResponse},

View File

@ -1,12 +1,10 @@
''' Testing views '''
import json
from rest_framework.test import APITestCase, APIClient
from apps.users.models import User
from apps.rsform.models import LibraryItem, LibraryItemType
# TODO: test ACTIVE_USERS
class TestUserAPIViews(APITestCase):
def setUp(self):
self.username = 'UserTest'
@ -18,8 +16,11 @@ class TestUserAPIViews(APITestCase):
self.client = APIClient()
def test_login(self):
data = json.dumps({'username': self.username, 'password': self.password})
response = self.client.post('/users/api/login', data=data, content_type='application/json')
data = {'username': self.username, 'password': self.password}
response = self.client.post(
'/users/api/login',
data=data, format='json'
)
self.assertEqual(response.status_code, 202)
def test_logout(self):
@ -81,12 +82,15 @@ class TestUserUserProfileAPIView(APITestCase):
def test_patch_profile(self):
self.client.force_login(user=self.user)
data = json.dumps({
data = {
'email': '123@mail.ru',
'first_name': 'firstName',
'last_name': 'lastName',
})
response = self.client.patch('/users/api/profile', data=data, content_type='application/json')
}
response = self.client.patch(
'/users/api/profile',
data=data, format='json'
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['email'], '123@mail.ru')
self.assertEqual(response.data['first_name'], 'firstName')
@ -94,31 +98,52 @@ class TestUserUserProfileAPIView(APITestCase):
def test_edit_profile(self):
newmail = 'newmail@gmail.com'
data = json.dumps({'email': newmail})
response = self.client.patch('/users/api/profile', data, content_type='application/json')
data = {'email': newmail}
response = self.client.patch(
'/users/api/profile',
data=data, format='json'
)
self.assertEqual(response.status_code, 403)
self.client.force_login(user=self.user)
response = self.client.patch('/users/api/profile', data, content_type='application/json')
response = self.client.patch(
'/users/api/profile',
data=data, format='json'
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['username'], self.username)
self.assertEqual(response.data['email'], newmail)
def test_change_password(self):
newpassword = 'pw2'
data = json.dumps({'old_password': self.password, 'new_password': newpassword})
response = self.client.patch('/users/api/change-password', data, content_type='application/json')
data = {
'old_password': self.password,
'new_password': newpassword
}
response = self.client.patch(
'/users/api/change-password',
data=data, format='json'
)
self.assertEqual(response.status_code, 403)
self.assertFalse(self.client.login(username=self.user.username, password=newpassword))
self.assertTrue(self.client.login(username=self.user.username, password=self.password))
invalid = json.dumps({'old_password': 'invalid', 'new_password': newpassword})
response = self.client.patch('/users/api/change-password', invalid, content_type='application/json')
invalid = {
'old_password': 'invalid',
'new_password': newpassword
}
response = self.client.patch(
'/users/api/change-password',
data=invalid, format='json'
)
self.assertEqual(response.status_code, 400)
oldHash = self.user.password
self.client.force_login(user=self.user)
response = self.client.patch('/users/api/change-password', data, content_type='application/json')
response = self.client.patch(
'/users/api/change-password',
data=data, format='json'
)
self.user.refresh_from_db()
self.assertEqual(response.status_code, 204)
self.assertNotEqual(self.user.password, oldHash)
@ -131,15 +156,18 @@ class TestSignupAPIView(APITestCase):
self.client = APIClient()
def test_signup(self):
data = json.dumps({
data = {
'username': 'TestUser',
'email': 'email@mail.ru',
'password': 'Test@@123',
'password2': 'Test@@123',
'first_name': 'firstName',
'last_name': 'lastName',
})
response = self.client.post('/users/api/signup', data, content_type='application/json')
}
response = self.client.post(
'/users/api/signup',
data=data, format='json'
)
self.assertEqual(response.status_code, 201)
self.assertTrue('id' in response.data)
self.assertEqual(response.data['username'], 'TestUser')

View File

@ -1,24 +1,40 @@
''' Term context for reference resolution. '''
from typing import Iterable, Dict, Optional, TypedDict
from .conceptapi import inflect
from .ruparser import PhraseParser
from .rumodel import WordTag
parser = PhraseParser()
class TermForm(TypedDict):
''' Term in a specific form. '''
''' Represents term in a specific form. '''
text: str
tags: str
grams: Iterable[str]
def _search_form(query: str, data: Iterable[TermForm]) -> Optional[str]:
for tf in data:
if tf['tags'] == query:
return tf['text']
def _match_grams(query: Iterable[str], test: Iterable[str]) -> bool:
''' Check if grams from test fit query. '''
for gram in test:
if not gram in query:
if not gram in WordTag.PARTS_OF_SPEECH:
return False
for pos in WordTag.PARTS_OF_SPEECH:
if pos in query:
return False
return True
def _search_form(query: Iterable[str], data: Iterable[TermForm]) -> Optional[str]:
for form in data:
if _match_grams(query, form['grams']):
return form['text']
return None
class Entity:
''' Text entity. '''
''' Represents text entity. '''
def __init__(self, alias: str, nominal: str, manual_forms: Optional[Iterable[TermForm]]=None):
if manual_forms is None:
self.manual = []
@ -41,20 +57,28 @@ class Entity:
self.manual = []
self._cached = []
def get_form(self, form: str) -> str:
def get_form(self, grams: Iterable[str]) -> str:
''' Get specific term form. '''
if form == '':
if all(False for _ in grams):
return self._nominal
text = _search_form(form, self.manual)
if text is None:
text = _search_form(form, self._cached)
if text is None:
try:
text = inflect(self._nominal, form)
except ValueError as error:
text = f'!{error}!'.replace('Unknown grammeme', 'Неизвестная граммема')
self._cached.append({'text': text, 'tags': form})
text = _search_form(grams, self.manual)
if text is not None:
return text
text = _search_form(grams, self._cached)
if text is not None:
return text
model = parser.parse(self._nominal)
if model is None:
text = self._nominal
else:
try:
text = model.inflect(grams)
except ValueError as error:
text = f'!{error}!'.replace('Unknown grammeme', 'Неизвестная граммема')
self._cached.append({'text': text, 'grams': grams})
return text
# Term context for resolving entity references.
# Represents term context for resolving entity references.
TermContext = Dict[str, Entity]

View File

@ -3,6 +3,8 @@ import re
from typing import cast, Optional
from dataclasses import dataclass
from .rumodel import split_grams
from .conceptapi import inflect_dependant
from .context import TermContext
from .reference import EntityReference, SyntacticReference, parse_reference, Reference
@ -24,7 +26,8 @@ def resolve_entity(ref: EntityReference, context: TermContext) -> str:
alias = ref.entity
if alias not in context:
return f'!Неизвестная сущность: {alias}!'
resolved = context[alias].get_form(ref.form)
grams = split_grams(ref.form)
resolved = context[alias].get_form(grams)
if resolved == '':
return f'!Отсутствует термин: {alias}!'
else:

View File

@ -9,24 +9,24 @@ class TestEntity(unittest.TestCase):
self.alias = 'X1'
self.nominal = 'человек'
self.text1 = 'test1'
self.form1 = 'sing,datv'
self.entity = Entity(self.alias, self.nominal, [{'text': self.text1, 'tags': self.form1}])
self.form1 = ['sing','datv']
self.entity = Entity(self.alias, self.nominal, [{'text': self.text1, 'grams': self.form1}])
def test_attributes(self):
self.assertEqual(self.entity.alias, self.alias)
self.assertEqual(self.entity.get_nominal(), self.nominal)
self.assertEqual(self.entity.manual, [{'text': self.text1, 'tags': self.form1}])
self.assertEqual(self.entity.manual, [{'text': self.text1, 'grams': self.form1}])
def test_get_form(self):
self.assertEqual(self.entity.get_form(''), self.nominal)
self.assertEqual(self.entity.get_form([]), self.nominal)
self.assertEqual(self.entity.get_form(self.form1), self.text1)
self.assertEqual(self.entity.get_form('invalid tags'), '!Неизвестная граммема: invalid tags!')
self.assertEqual(self.entity.get_form('plur'), 'люди')
self.assertEqual(self.entity.get_form(['invalid tags']), '!Неизвестная граммема: invalid tags!')
self.assertEqual(self.entity.get_form(['plur']), 'люди')
def test_set_nominal(self):
new_nomial = 'TEST'
self.assertEqual(self.entity.get_form('plur'), 'люди')
self.assertEqual(self.entity.get_form(['plur']), 'люди')
self.entity.set_nominal(new_nomial)
self.assertEqual(self.entity.get_nominal(), new_nomial)
self.assertEqual(self.entity.get_form('plur'), new_nomial)
self.assertEqual(self.entity.get_form(['plur']), new_nomial)
self.assertEqual(self.entity.manual, [])

View File

@ -2,9 +2,11 @@
import unittest
from typing import cast
from django.test import tag
from cctext import (
EntityReference, TermContext, Entity, SyntacticReference,
Resolver, ResolvedReference, Position,
Resolver, ResolvedReference, Position, TermForm,
resolve_entity, resolve_syntactic, extract_entities
)
@ -88,3 +90,20 @@ class TestResolver(unittest.TestCase):
self.assertEqual(self.resolver.refs[1].pos_output, Position(9, 15))
self.assertEqual(self.resolver.refs[2].pos_input, Position(28, 38))
self.assertEqual(self.resolver.refs[2].pos_output, Position(16, 20))
def test_resolve_manual_forms(self):
self.context['X1'] = Entity(
alias='X1',
nominal='человек',
manual_forms=[
TermForm(text='тест1', grams='NOUN,sing'.split(',')),
TermForm(text='тест2', grams='NOUN,datv,plur'.split(','))
]
)
self.assertEqual(self.resolver.resolve('@{X1|NOUN,sing,nomn}'), 'тест1', 'Match subset')
self.assertEqual(self.resolver.resolve('@{X1|NOUN,sing}'), 'тест1', 'Match full')
self.assertEqual(self.resolver.resolve('@{X1|NOUN,datv,plur}'), 'тест2')
self.assertEqual(self.resolver.resolve('@{X1|NOUN,plur,datv}'), 'тест2', 'Match any order')
self.assertEqual(self.resolver.resolve('@{X1|datv,plur}'), 'тест2', 'Match missing POS')
self.assertEqual(self.resolver.resolve('@{X1|NOUN,datv,sing}'), 'тест1')
self.assertEqual(self.resolver.resolve('@{X1|VERB,datv,plur}'), 'человек')

View File

@ -150,7 +150,7 @@ export default function DataTable<TData extends RowData>({
style={conditionalRowStyles && getRowStyles(row)}
>
{enableRowSelection &&
<td className='pl-3 pr-1 border-y'>
<td key={`select-${row.id}`} className='pl-3 pr-1 border-y'>
<SelectRow row={row} />
</td>}
{row.getVisibleCells().map(

View File

@ -252,6 +252,22 @@ export function ArrowDownIcon(props: IconProps) {
);
}
export function ArrowLeftIcon(props: IconProps) {
return (
<IconSVG viewbox='0 0 24 24' {...props}>
<path d='M12.707 17.293L8.414 13H18v-2H8.414l4.293-4.293-1.414-1.414L4.586 12l6.707 6.707z' />
</IconSVG>
);
}
export function ArrowRightIcon(props: IconProps) {
return (
<IconSVG viewbox='0 0 24 24' {...props}>
<path d='M11.293 17.293l1.414 1.414L19.414 12l-6.707-6.707-1.414 1.414L15.586 11H6v2h9.586z' />
</IconSVG>
);
}
export function CloneIcon(props: IconProps) {
return (
<IconSVG viewbox='0 0 512 512' {...props}>
@ -407,6 +423,15 @@ export function ChevronDoubleUpIcon(props: IconProps) {
);
}
export function ChevronDoubleDownIcon(props: IconProps) {
return (
<IconSVG viewbox='0 0 24 24' {...props}>
<path d='M12 15.586l-4.293-4.293-1.414 1.414L12 18.414l5.707-5.707-1.414-1.414z' />
<path d='M17.707 7.707l-1.414-1.414L12 10.586 7.707 6.293 6.293 7.707 12 13.414z' />
</IconSVG>
);
}
export function CheckIcon(props: IconProps) {
return (
<IconSVG viewbox='0 0 24 24' {...props}>

View File

@ -0,0 +1,56 @@
import { useCallback, useState } from 'react'
import { ErrorInfo } from '../components/BackendError';
import { ILexemeData, ITextRequest, ITextResult, IWordFormPlain } from '../models/language';
import { DataCallback, postGenerateLexeme, postInflectText, postParseText } from '../utils/backendAPI';
function useConceptText() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<ErrorInfo>(undefined);
const inflect = useCallback(
(data: IWordFormPlain, onSuccess: DataCallback<ITextResult>) => {
setError(undefined);
postInflectText({
data: data,
showError: true,
setLoading,
onError: error => setError(error),
onSuccess: data => {
if (onSuccess) onSuccess(data);
}
});
}, []);
const parse = useCallback(
(data: ITextRequest, onSuccess: DataCallback<ITextResult>) => {
setError(undefined);
postParseText({
data: data,
showError: true,
setLoading,
onError: error => setError(error),
onSuccess: data => {
if (onSuccess) onSuccess(data);
}
});
}, []);
const generateLexeme = useCallback(
(data: ITextRequest, onSuccess: DataCallback<ILexemeData>) => {
setError(undefined);
postGenerateLexeme({
data: data,
showError: true,
setLoading,
onError: error => setError(error),
onSuccess: data => {
if (onSuccess) onSuccess(data);
}
});
}, []);
return { inflect, parse, generateLexeme, error, setError, loading };
}
export default useConceptText;

View File

@ -1,18 +1,18 @@
import { useCallback, useState } from 'react'
import { ErrorInfo } from '../components/BackendError';
import { IReferenceData } from '../models/language';
import { IResolutionData } from '../models/language';
import { IRSForm } from '../models/rsform';
import { DataCallback, postResolveText } from '../utils/backendAPI';
function useResolveText({ schema }: { schema?: IRSForm }) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<ErrorInfo>(undefined);
const [refsData, setRefsData] = useState<IReferenceData | undefined>(undefined);
const [refsData, setRefsData] = useState<IResolutionData | undefined>(undefined);
const resetData = useCallback(() => setRefsData(undefined), []);
function resolveText(text: string, onSuccess?: DataCallback<IReferenceData>) {
function resolveText(text: string, onSuccess?: DataCallback<IResolutionData>) {
setError(undefined);
postResolveText(String(schema!.id), {
data: { text: text },

View File

@ -1,5 +1,12 @@
// Module: Natural language model declarations.
/**
* Represents API result for text output.
*/
export interface ITextResult {
result: string
}
/**
* Represents single unit of language Morphology.
*/
@ -172,7 +179,6 @@ export const GrammemeGroups = [
*/
export const NounGrams = [
Grammeme.NOUN, Grammeme.ADJF, Grammeme.ADJS,
...Gender,
...Case,
...Plurality
];
@ -211,6 +217,21 @@ export interface IWordForm {
grams: IGramData[]
}
/**
* Represents wordform data used for backend communication.
*/
export interface IWordFormPlain {
text: string
grams: string
}
/**
* Represents lexeme response containing multiple {@link Wordform}s.
*/
export interface ILexemeData {
items: IWordFormPlain[]
}
/**
* Equality comparator for {@link IGramData}. Compares text data for unknown grammemes
*/
@ -275,30 +296,48 @@ export function parseGrammemes(termForm: string): IGramData[] {
}
// ====== Reference resolution =====
export interface IRefsText {
/**
* Represents text request.
*/
export interface ITextRequest {
text: string
}
/**
* Represents text reference type.
*/
export enum ReferenceType {
ENTITY = 'entity',
SYNTACTIC = 'syntax'
}
/**
* Represents entity reference payload.
*/
export interface IEntityReference {
entity: string
form: string
}
/**
* Represents syntactic reference payload.
*/
export interface ISyntacticReference {
offset: number
nominal: string
}
/**
* Represents text 0-indexed position inside another text.
*/
export interface ITextPosition {
start: number
finish: number
}
/**
* Represents single resolved reference data.
*/
export interface IResolvedReference {
type: ReferenceType
data: IEntityReference | ISyntacticReference
@ -306,7 +345,10 @@ export interface IResolvedReference {
pos_output: ITextPosition
}
export interface IReferenceData {
/**
* Represents resolved references data for the whole text.
*/
export interface IResolutionData {
input: string
output: string
refs: IResolvedReference[]

View File

@ -81,7 +81,8 @@ export interface ICstMovetoData extends IConstituentaList {
}
export interface ICstUpdateData
extends Pick<IConstituentaMeta, 'id' | 'alias' | 'convention' | 'definition_formal' | 'definition_raw' | 'term_raw'> {}
extends Pick<IConstituentaMeta, 'id'>,
Partial<Pick<IConstituentaMeta, | 'alias' | 'convention' | 'definition_formal' | 'definition_raw' | 'term_raw' | 'term_forms'>> {}
export interface ICstRenameData
extends Pick<IConstituentaMeta, 'id' | 'alias' | 'cst_type' > {}

View File

@ -6,17 +6,19 @@ import Modal from '../../components/Common/Modal';
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 { ArrowLeftIcon, ArrowRightIcon, CheckIcon, ChevronDoubleDownIcon, CrossIcon } from '../../components/Icons';
import { useConceptTheme } from '../../context/ThemeContext';
import useConceptText from '../../hooks/useConceptText';
import {
Grammeme, GrammemeGroups, IWordForm,
Grammeme, GrammemeGroups, ITextRequest, IWordForm,
IWordFormPlain,
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';
import { IGrammemeOption, SelectorGrammemesList, SelectorGrammems } from '../../utils/selectors';
interface DlgEditTermProps {
hideWindow: () => void
@ -27,6 +29,7 @@ interface DlgEditTermProps {
const columnHelper = createColumnHelper<IWordForm>();
function DlgEditTerm({ hideWindow, target, onSave }: DlgEditTermProps) {
const textProcessor = useConceptText();
const { colors } = useConceptTheme();
const [term, setTerm] = useState('');
@ -41,7 +44,7 @@ function DlgEditTerm({ hideWindow, target, onSave }: DlgEditTermProps) {
forms.forEach(
({text, grams}) => result.push({
text: text,
tags: grams.join(',')
tags: grams.map(gram => gram.data).join(',')
}));
return result;
}
@ -58,6 +61,7 @@ function DlgEditTerm({ hideWindow, target, onSave }: DlgEditTermProps) {
setForms(initForms);
setTerm(target.term_resolved);
setInputText(target.term_resolved);
setInputGrams([]);
}, [target]);
// Filter grammemes when input changes
@ -102,8 +106,8 @@ function DlgEditTerm({ hideWindow, target, onSave }: DlgEditTermProps) {
}))
};
setForms(forms => [
...forms.filter(value => !matchWordForm(value, newForm)),
newForm
newForm,
...forms.filter(value => !matchWordForm(value, newForm))
]);
}
@ -121,22 +125,54 @@ function DlgEditTerm({ hideWindow, target, onSave }: DlgEditTermProps) {
});
}
function handleRowClicked(form: IWordForm) {
setInputText(form.text);
setInputGrams(SelectorGrammems.filter(gram => form.grams.find(test => test.type === gram.type)));
}
function handleResetForm() {
setInputText('');
setInputGrams([]);
}
function handleGenerateSelected() {
function handleInflect() {
const data: IWordFormPlain = {
text: term,
grams: inputGrams.map(gram => gram.data).join(',')
}
textProcessor.inflect(data, response => setInputText(response.result));
}
function handleGenerateBasics() {
function handleParse() {
const data: ITextRequest = {
text: inputText
}
textProcessor.parse(data, response => {
const grams = parseGrammemes(response.result);
setInputGrams(SelectorGrammems.filter(gram => grams.find(test => test.type === gram.type)));
});
}
function handleGenerateLexeme() {
if (forms.length > 0) {
if (!window.confirm('Данное действие приведет к перезаписи словоформ при совпадении граммем. Продолжить?')) {
return;
}
}
const data: ITextRequest = {
text: inputText
}
textProcessor.generateLexeme(data, response => {
const newForms: IWordForm[] = response.items.map(
form => ({
text: form.text,
grams: parseGrammemes(form.grams).filter(gram => SelectorGrammemesList.find(item => item === gram.type))
}));
setForms(forms => [
...newForms,
...forms.filter(value => !newForms.find(test => matchWordForm(value, test))),
]);
});
}
const columns = useMemo(
@ -156,10 +192,11 @@ function DlgEditTerm({ hideWindow, target, onSave }: DlgEditTermProps) {
minSize: 250,
maxSize: 250,
cell: props =>
<div className='flex justify-start gap-1 select-none'>
<div className='flex flex-wrap justify-start gap-1 select-none'>
{ props.getValue().map(
gram =>
<div
key={`${props.cell.id}-${gram.type}`}
className='min-w-[3rem] px-1 text-sm text-center rounded-md whitespace-nowrap'
title=''
style={{
@ -217,34 +254,47 @@ function DlgEditTerm({ hideWindow, target, onSave }: DlgEditTermProps) {
<TextArea
placeholder='Введите текст'
rows={2}
dimensions='min-w-[20rem]'
dimensions='min-w-[20rem] min-h-[4.2rem]'
disabled={textProcessor.loading}
value={inputText}
onChange={event => setInputText(event.target.value)}
/>
<div className='flex items-center justify-start'>
<MiniButton
tooltip='Добавить словоформу'
icon={<CheckIcon size={6} color={!inputText || inputGrams.length == 0 ? 'text-disabled' : 'text-success'}/>}
disabled={!inputText || inputGrams.length == 0}
onClick={handleAddForm}
/>
<MiniButton
tooltip='Сбросить словоформу'
icon={<CrossIcon size={6} color='text-warning'/>}
onClick={handleResetForm}
/>
<MiniButton
tooltip='Генерировать словоформу'
icon={<ChevronUpIcon size={6} color={inputGrams.length == 0 ? 'text-disabled' : 'text-primary'}/>}
disabled={inputGrams.length == 0}
onClick={handleGenerateSelected}
/>
<MiniButton
tooltip='Генерировать базовые словоформы'
icon={<ChevronDoubleUpIcon size={6} color='text-primary'/>}
onClick={handleGenerateBasics}
/>
<div className='flex items-center justify-between'>
<div className='flex items-center justify-start'>
<MiniButton
tooltip='Добавить словоформу'
icon={<CheckIcon size={6} color={!inputText || inputGrams.length == 0 ? 'text-disabled' : 'text-success'}/>}
disabled={textProcessor.loading || !inputText || inputGrams.length == 0}
onClick={handleAddForm}
/>
<MiniButton
tooltip='Сбросить словоформу'
icon={<CrossIcon size={6} color='text-warning'/>}
disabled={textProcessor.loading}
onClick={handleResetForm}
/>
<MiniButton
tooltip='Генерировать все словоформы'
icon={<ChevronDoubleDownIcon size={6} color='text-primary'/>}
disabled={textProcessor.loading}
onClick={handleGenerateLexeme}
/>
</div>
<div className='flex items-center justify-start'>
<MiniButton
tooltip='Генерировать словоформу'
icon={<ArrowLeftIcon size={6} color={inputGrams.length == 0 ? 'text-disabled' : 'text-primary'}/>}
disabled={textProcessor.loading || inputGrams.length == 0}
onClick={handleInflect}
/>
<MiniButton
tooltip='Определить граммемы'
icon={<ArrowRightIcon size={6} color={!inputText ? 'text-disabled' : 'text-primary'}/>}
disabled={textProcessor.loading || !inputText}
onClick={handleParse}
/>
</div>
</div>
</div>
<SelectMulti
@ -253,6 +303,7 @@ function DlgEditTerm({ hideWindow, target, onSave }: DlgEditTermProps) {
placeholder='Выберите граммемы'
value={inputGrams}
isDisabled={textProcessor.loading}
onChange={newValue => setInputGrams(sortGrammemes([...newValue]))}
/>
</div>
@ -270,8 +321,7 @@ function DlgEditTerm({ hideWindow, target, onSave }: DlgEditTermProps) {
</span>
}
// onRowDoubleClicked={handleDoubleClick}
// onRowClicked={handleRowClicked}
onRowDoubleClicked={handleRowClicked}
/>
</div>
</div>

View File

@ -140,14 +140,15 @@ function EditorConstituenta({
<form onSubmit={handleSubmit} className='min-w-[50rem] max-w-min px-4 py-2 border-y border-r'>
<div className='relative w-full'>
<div className='absolute top-0 right-0 flex items-start justify-between w-full'>
{activeCst &&
<MiniButton
tooltip='Редактировать словоформы термина'
tooltip={`Редактировать словоформы термина: ${activeCst.term_forms.length}`}
disabled={!isEnabled}
dimensions='w-fit pl-[3.2rem] pt-[0.4rem]'
dimensions='w-fit ml-[3.2rem] pt-[0.4rem]'
noHover
onClick={onEditTerm}
icon={<PenIcon size={4} color={isEnabled ? 'text-primary' : ''} />}
/>
/>}
<div className='flex items-center justify-center w-full gap-1'>
<div className='font-semibold w-fit'>
<span className=''>Конституента </span>

View File

@ -14,7 +14,7 @@ import { useConceptNavigation } from '../../context/NagivationContext';
import { useRSForm } from '../../context/RSFormContext';
import { useConceptTheme } from '../../context/ThemeContext';
import useModificationPrompt from '../../hooks/useModificationPrompt';
import { ICstCreateData, ICstRenameData } from '../../models/rsform';
import { ICstCreateData, ICstRenameData, ICstUpdateData, TermForm } from '../../models/rsform';
import { SyntaxTree } from '../../models/rslang';
import { EXTEOR_TRS_FILE, prefixes, TIMEOUT_UI_REFRESH } from '../../utils/constants';
import { createAliasFor } from '../../utils/misc';
@ -57,7 +57,7 @@ function RSTabs() {
const search = useLocation().search;
const {
error, schema, loading, claim, download, isTracking,
cstCreate, cstDelete, cstRename, subscribe, unsubscribe
cstCreate, cstDelete, cstRename, subscribe, unsubscribe, cstUpdate
} = useRSForm();
const { destroySchema } = useLibrary();
const { setNoFooter } = useConceptTheme();
@ -304,6 +304,19 @@ function RSTabs() {
setShowEditTerm(true);
}, [isModified, activeCst]);
const handleSaveWordforms = useCallback(
(forms: TermForm[]) => {
if (!activeID) {
return;
}
const data: ICstUpdateData = {
id: activeID,
term_forms: forms
};
console.log(data);
cstUpdate(data, () => toast.success('Изменения сохранены'));
}, [cstUpdate, activeID]);
return (
<div className='w-full'>
{ loading && <ConceptLoader /> }
@ -344,7 +357,7 @@ function RSTabs() {
{showEditTerm &&
<DlgEditTerm
hideWindow={() => setShowEditTerm(false)}
onSave={() => {}} // TODO: implement cst update
onSave={handleSaveWordforms}
target={activeCst!}
/>}
<Tabs

View File

@ -1,24 +1,23 @@
import axios, { AxiosError, AxiosRequestConfig } from 'axios'
import { toast } from 'react-toastify'
import axios, { AxiosError, AxiosRequestConfig } from 'axios';
import { toast } from 'react-toastify';
import { type ErrorInfo } from '../components/BackendError'
import { IReferenceData } from '../models/language'
import { IRefsText } from '../models/language'
import { ICurrentUser } from '../models/library'
import { IUserLoginData } from '../models/library'
import { IUserSignupData } from '../models/library'
import { IUserProfile } from '../models/library'
import { IUserUpdateData } from '../models/library'
import { IUserInfo } from '../models/library'
import { IUserUpdatePassword } from '../models/library'
import { ILibraryItem } from '../models/library'
import { ILibraryUpdateData } from '../models/library'
import { type ErrorInfo } from '../components/BackendError';
import {
ILexemeData,
IResolutionData, ITextRequest,
ITextResult, IWordFormPlain
} from '../models/language';
import {
ICurrentUser, ILibraryItem, ILibraryUpdateData,
IUserInfo, IUserLoginData, IUserProfile, IUserSignupData,
IUserUpdateData, IUserUpdatePassword
} from '../models/library';
import {
IConstituentaList, IConstituentaMeta,
ICstCreateData, ICstCreatedResponse, ICstMovetoData, ICstRenameData, ICstUpdateData,
IRSFormCreateData, IRSFormData, IRSFormUploadData} from '../models/rsform'
import { IExpressionParse, IRSExpression } from '../models/rslang'
import { config } from './constants'
IRSFormCreateData, IRSFormData, IRSFormUploadData} from '../models/rsform';
import { IExpressionParse, IRSExpression } from '../models/rslang';
import { config } from './constants';
export function initBackend() {
axios.defaults.withCredentials = true;
@ -258,14 +257,6 @@ export function postCheckExpression(schema: string, request: FrontExchange<IRSEx
});
}
export function postResolveText(schema: string, request: FrontExchange<IRefsText, IReferenceData>) {
AxiosPost({
title: `Resolve text references for RSForm id=${schema}: ${request.data.text }`,
endpoint: `/api/rsforms/${schema}/resolve`,
request: request
});
}
export function patchResetAliases(target: string, request: FrontPull<IRSFormData>) {
AxiosPatch({
title: `Reset alias for RSForm id=${target}`,
@ -287,6 +278,38 @@ export function patchUploadTRS(target: string, request: FrontExchange<IRSFormUpl
});
}
export function postResolveText(schema: string, request: FrontExchange<ITextRequest, IResolutionData>) {
AxiosPost({
title: `Resolve text references for RSForm id=${schema}: ${request.data.text}`,
endpoint: `/api/rsforms/${schema}/resolve`,
request: request
});
}
export function postInflectText(request: FrontExchange<IWordFormPlain, ITextResult>) {
AxiosPost({
title: `Inflect text ${request.data.text} to ${request.data.grams}`,
endpoint: `/api/cctext/inflect`,
request: request
});
}
export function postParseText(request: FrontExchange<ITextRequest, ITextResult>) {
AxiosPost({
title: `Parse text ${request.data.text}`,
endpoint: `/api/cctext/parse`,
request: request
});
}
export function postGenerateLexeme(request: FrontExchange<ITextRequest, ILexemeData>) {
AxiosPost({
title: `Parse text ${request.data.text}`,
endpoint: `/api/cctext/generate-lexeme`,
request: request
});
}
// ============ Helper functions =============
function AxiosGet<ResponseData>({ endpoint, request, title, options }: IAxiosRequest<undefined, ResponseData>) {
console.log(`REQUEST: [[${title}]]`);

View File

@ -395,7 +395,10 @@ export function colorfgGrammeme(gram: Grammeme, colors: IColorTheme): string {
if (VerbGrams.includes(gram)) {
return colors.fgTeal;
}
return colors.fgDefault;
if (gram === Grammeme.UNKN) {
return colors.fgRed;
}
return colors.fgPurple;
}
export function colorbgGrammeme(gram: Grammeme, colors: IColorTheme): string {

View File

@ -39,12 +39,11 @@ export const SelectorCstType = (
);
export interface IGrammemeOption extends IGramData {
value: Grammeme
value: string
label: string
}
export const SelectorGrammems: IGrammemeOption[] =
[
export const SelectorGrammemesList = [
Grammeme.NOUN, Grammeme.VERB,
Grammeme.sing, Grammeme.plur,
@ -61,10 +60,13 @@ export const SelectorGrammems: IGrammemeOption[] =
Grammeme.impr, Grammeme.indc,
Grammeme.incl, Grammeme.excl,
Grammeme.pssv, Grammeme.actv,
].map(
];
export const SelectorGrammems: IGrammemeOption[] =
SelectorGrammemesList.map(
gram => ({
type: gram,
data: gram as string,
value: gram,
value: gram as string,
label: labelGrammeme({type: gram, data: ''} as IGramData)
}));