From 5155b3a37a249e4d95c5b85a1026267407caf814 Mon Sep 17 00:00:00 2001 From: gitrusprus Date: Wed, 24 Jun 2026 17:54:51 +0300 Subject: [PATCH] Add multiple network maps, graph UX improvements, and full backup import. Support per-map contact membership with scoped graph views, relation editing on edges, layout caching, and automatic detection of full JSON backups so contacts and relations import together. Co-authored-by: Cursor --- .../contacts/migrations/0006_network_maps.py | 119 ++++++ backend/contacts/models.py | 57 ++- backend/contacts/serializers.py | 38 +- backend/contacts/urls.py | 16 + backend/contacts/views.py | 118 +++-- frontend/src/App.vue | 6 +- frontend/src/application/usecases/graph.js | 74 +++- .../src/application/usecases/graph.test.js | 86 ++++ .../src/application/usecases/importExport.js | 184 +++++++- .../application/usecases/importExport.test.js | 99 +++++ .../src/application/usecases/networkMaps.js | 112 +++++ .../src/application/usecases/relations.js | 11 + .../src/components/AddContactToMapModal.vue | 72 ++++ frontend/src/components/ContactForm.test.js | 44 +- frontend/src/components/ContactForm.vue | 159 +++---- .../src/components/CreateRelationModal.vue | 19 +- frontend/src/components/EditRelationModal.vue | 155 +++++++ .../src/components/GraphEdgeContextMenu.vue | 115 +++++ .../components/InteractionIntensitySelect.vue | 26 ++ .../src/components/NetworkMapFormModal.vue | 86 ++++ .../src/components/NetworkMapSwitcher.vue | 56 +++ .../src/components/NetworkMapTopPanel.vue | 1 + .../src/components/RelationTypeSelect.vue | 26 ++ frontend/src/composables/useGraphData.js | 9 +- .../composables/useGraphNodeContextMenu.js | 65 ++- frontend/src/domain/networkChoices.js | 1 + frontend/src/infrastructure/db/localDb.js | 55 +++ .../repositories/contactRepository.local.js | 18 +- .../networkMapMembershipRepository.local.js | 126 ++++++ .../networkMapMembershipRepository.remote.js | 36 ++ .../networkMapRepository.local.js | 129 ++++++ .../networkMapRepository.remote.js | 23 + .../repositories/relationRepository.local.js | 15 + .../repositories/relationRepository.remote.js | 4 + .../repositories/repositoryFactory.js | 12 + frontend/src/lib/graph/graphLayout.js | 116 +++++ frontend/src/lib/graph/graphLayout.test.js | 34 ++ frontend/src/lib/graph/graphLayoutCache.js | 35 ++ .../src/lib/graph/graphLayoutCache.test.js | 24 ++ frontend/src/lib/graph/relationIntensity.js | 45 ++ .../src/lib/graph/relationIntensity.test.js | 33 ++ frontend/src/router/index.js | 5 + frontend/src/stores/contacts.js | 28 ++ frontend/src/stores/networkMaps.js | 154 +++++++ frontend/src/views/ContactDetailView.vue | 143 ++++--- frontend/src/views/ContactsView.vue | 39 +- frontend/src/views/GraphView.vue | 402 +++++++++++++++--- frontend/src/views/ImportView.vue | 10 +- frontend/src/views/NetworkMapRedirect.vue | 33 ++ frontend/src/views/NetworkMapView.vue | 275 ++++++++++-- 50 files changed, 3176 insertions(+), 372 deletions(-) create mode 100644 backend/contacts/migrations/0006_network_maps.py create mode 100644 frontend/src/application/usecases/graph.test.js create mode 100644 frontend/src/application/usecases/importExport.test.js create mode 100644 frontend/src/application/usecases/networkMaps.js create mode 100644 frontend/src/components/AddContactToMapModal.vue create mode 100644 frontend/src/components/EditRelationModal.vue create mode 100644 frontend/src/components/GraphEdgeContextMenu.vue create mode 100644 frontend/src/components/InteractionIntensitySelect.vue create mode 100644 frontend/src/components/NetworkMapFormModal.vue create mode 100644 frontend/src/components/NetworkMapSwitcher.vue create mode 100644 frontend/src/components/RelationTypeSelect.vue create mode 100644 frontend/src/infrastructure/repositories/networkMapMembershipRepository.local.js create mode 100644 frontend/src/infrastructure/repositories/networkMapMembershipRepository.remote.js create mode 100644 frontend/src/infrastructure/repositories/networkMapRepository.local.js create mode 100644 frontend/src/infrastructure/repositories/networkMapRepository.remote.js create mode 100644 frontend/src/lib/graph/graphLayout.js create mode 100644 frontend/src/lib/graph/graphLayout.test.js create mode 100644 frontend/src/lib/graph/graphLayoutCache.js create mode 100644 frontend/src/lib/graph/graphLayoutCache.test.js create mode 100644 frontend/src/lib/graph/relationIntensity.js create mode 100644 frontend/src/lib/graph/relationIntensity.test.js create mode 100644 frontend/src/stores/networkMaps.js create mode 100644 frontend/src/views/NetworkMapRedirect.vue diff --git a/backend/contacts/migrations/0006_network_maps.py b/backend/contacts/migrations/0006_network_maps.py new file mode 100644 index 0000000..5402787 --- /dev/null +++ b/backend/contacts/migrations/0006_network_maps.py @@ -0,0 +1,119 @@ +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion + + +def migrate_contact_map_data(apps, schema_editor): + Contact = apps.get_model('contacts', 'Contact') + NetworkMap = apps.get_model('contacts', 'NetworkMap') + NetworkMapMembership = apps.get_model('contacts', 'NetworkMapMembership') + + default_map, _ = NetworkMap.objects.get_or_create( + name='Основная карта', + defaults={'description': 'Мигрировано из прежней единой карты сети'}, + ) + + for contact in Contact.objects.filter(include_on_network_map=True): + NetworkMapMembership.objects.create( + map=default_map, + contact=contact, + life_sphere=contact.life_sphere, + network_circle=contact.network_circle, + importance=contact.importance, + map_angle=contact.map_angle, + map_radius_ratio=contact.map_radius_ratio, + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('contacts', '0005_contact_map_angle_contact_map_radius_ratio'), + ] + + operations = [ + migrations.CreateModel( + name='NetworkMap', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255, verbose_name='Название')), + ('description', models.TextField(blank=True, verbose_name='Описание')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'Карта сети', + 'verbose_name_plural': 'Карты сети', + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='NetworkMapMembership', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('life_sphere', models.CharField( + choices=[ + ('work', 'Работа'), ('study', 'Учёба'), ('hobby', 'Хобби'), + ('family', 'Семья'), ('health', 'Здоровье'), ('other', 'Другое'), + ], + default='other', + max_length=32, + verbose_name='Сфера жизни', + )), + ('network_circle', models.CharField( + choices=[ + ('support', 'Круг поддержки'), + ('productivity', 'Круг продуктивности'), + ('development', 'Круг развития'), + ], + default='productivity', + max_length=32, + verbose_name='Круг сети', + )), + ('importance', models.PositiveSmallIntegerField( + default=3, + validators=[ + django.core.validators.MinValueValidator(1), + django.core.validators.MaxValueValidator(5), + ], + verbose_name='Важность (1–5)', + )), + ('map_angle', models.FloatField(blank=True, null=True, verbose_name='Угол позиции на карте')), + ('map_radius_ratio', models.FloatField( + blank=True, + null=True, + validators=[ + django.core.validators.MinValueValidator(0), + django.core.validators.MaxValueValidator(1), + ], + verbose_name='Радиус позиции на карте (доля)', + )), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('contact', models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name='map_memberships', + to='contacts.contact', + verbose_name='Контакт', + )), + ('map', models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name='memberships', + to='contacts.networkmap', + verbose_name='Карта', + )), + ], + options={ + 'verbose_name': 'Участие на карте', + 'verbose_name_plural': 'Участия на картах', + 'unique_together': {('map', 'contact')}, + }, + ), + migrations.RunPython(migrate_contact_map_data, migrations.RunPython.noop), + migrations.RemoveField(model_name='contact', name='include_on_network_map'), + migrations.RemoveField(model_name='contact', name='life_sphere'), + migrations.RemoveField(model_name='contact', name='network_circle'), + migrations.RemoveField(model_name='contact', name='importance'), + migrations.RemoveField(model_name='contact', name='map_angle'), + migrations.RemoveField(model_name='contact', name='map_radius_ratio'), + ] diff --git a/backend/contacts/models.py b/backend/contacts/models.py index cc963e8..588aaef 100644 --- a/backend/contacts/models.py +++ b/backend/contacts/models.py @@ -19,6 +19,7 @@ NETWORK_CIRCLES = [ INTERACTION_INTENSITY = [ ('intense', 'Интенсивные контакты'), + ('periodic', 'Периодические контакты'), ('sparse', 'Редкие контакты'), ] @@ -32,6 +33,50 @@ class Contact(models.Model): organization = models.CharField(max_length=255, blank=True, verbose_name='Организация') position = models.CharField(max_length=255, blank=True, verbose_name='Должность') notes = models.TextField(blank=True, verbose_name='Заметки') + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ['name'] + verbose_name = 'Контакт' + verbose_name_plural = 'Контакты' + + def __str__(self): + return self.name + + +class NetworkMap(models.Model): + """Карта сети — отдельный контекст для визуализации подмножества контактов.""" + + name = models.CharField(max_length=255, verbose_name='Название') + description = models.TextField(blank=True, verbose_name='Описание') + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ['name'] + verbose_name = 'Карта сети' + verbose_name_plural = 'Карты сети' + + def __str__(self): + return self.name + + +class NetworkMapMembership(models.Model): + """Участие контакта на конкретной карте сети.""" + + map = models.ForeignKey( + NetworkMap, + on_delete=models.CASCADE, + related_name='memberships', + verbose_name='Карта', + ) + contact = models.ForeignKey( + Contact, + on_delete=models.CASCADE, + related_name='map_memberships', + verbose_name='Контакт', + ) life_sphere = models.CharField( max_length=32, choices=LIFE_SPHERES, @@ -49,10 +94,6 @@ class Contact(models.Model): validators=[MinValueValidator(1), MaxValueValidator(5)], verbose_name='Важность (1–5)', ) - include_on_network_map = models.BooleanField( - default=False, - verbose_name='Показывать на карте сети', - ) map_angle = models.FloatField(null=True, blank=True, verbose_name='Угол позиции на карте') map_radius_ratio = models.FloatField( null=True, @@ -64,12 +105,12 @@ class Contact(models.Model): updated_at = models.DateTimeField(auto_now=True) class Meta: - ordering = ['name'] - verbose_name = 'Контакт' - verbose_name_plural = 'Контакты' + unique_together = ('map', 'contact') + verbose_name = 'Участие на карте' + verbose_name_plural = 'Участия на картах' def __str__(self): - return self.name + return f'{self.contact} на {self.map}' RELATION_TYPES = [ diff --git a/backend/contacts/serializers.py b/backend/contacts/serializers.py index 27b06b6..5504e40 100644 --- a/backend/contacts/serializers.py +++ b/backend/contacts/serializers.py @@ -1,5 +1,5 @@ from rest_framework import serializers -from .models import Contact, Relation +from .models import Contact, Relation, NetworkMap, NetworkMapMembership class ContactSerializer(serializers.ModelSerializer): @@ -10,9 +10,6 @@ class ContactSerializer(serializers.ModelSerializer): fields = [ 'id', 'name', 'email', 'phone', 'organization', 'position', 'notes', - 'life_sphere', 'network_circle', 'importance', - 'include_on_network_map', - 'map_angle', 'map_radius_ratio', 'created_at', 'updated_at', 'relations_count', ] read_only_fields = ['id', 'created_at', 'updated_at', 'relations_count'] @@ -46,6 +43,36 @@ class RelationSerializer(serializers.ModelSerializer): return data +class NetworkMapSerializer(serializers.ModelSerializer): + memberships_count = serializers.SerializerMethodField() + + class Meta: + model = NetworkMap + fields = [ + 'id', 'name', 'description', + 'created_at', 'updated_at', 'memberships_count', + ] + read_only_fields = ['id', 'created_at', 'updated_at', 'memberships_count'] + + def get_memberships_count(self, obj): + return obj.memberships.count() + + +class NetworkMapMembershipSerializer(serializers.ModelSerializer): + contact_name = serializers.CharField(source='contact.name', read_only=True) + map_name = serializers.CharField(source='map.name', read_only=True) + + class Meta: + model = NetworkMapMembership + fields = [ + 'id', 'map', 'map_name', 'contact', 'contact_name', + 'life_sphere', 'network_circle', 'importance', + 'map_angle', 'map_radius_ratio', + 'created_at', 'updated_at', + ] + read_only_fields = ['id', 'created_at', 'updated_at', 'contact_name', 'map_name'] + + class GraphSerializer(serializers.Serializer): """Граф для vis.js: nodes + edges.""" @@ -60,9 +87,6 @@ class GraphSerializer(serializers.Serializer): 'label': c.name, 'title': f'{c.organization}\n{c.position}'.strip() or c.name, 'group': c.organization or 'default', - 'life_sphere': c.life_sphere, - 'network_circle': c.network_circle, - 'importance': c.importance, } for c in contacts ] diff --git a/backend/contacts/urls.py b/backend/contacts/urls.py index 708f178..66d789a 100644 --- a/backend/contacts/urls.py +++ b/backend/contacts/urls.py @@ -5,9 +5,25 @@ from . import views router = DefaultRouter() router.register('contacts', views.ContactViewSet) router.register('relations', views.RelationViewSet) +router.register('network-maps', views.NetworkMapViewSet) urlpatterns = [ path('', include(router.urls)), + path( + 'network-maps//memberships/', + views.NetworkMapMembershipViewSet.as_view({'get': 'list', 'post': 'create'}), + name='network-map-memberships-list', + ), + path( + 'network-maps//memberships//', + views.NetworkMapMembershipViewSet.as_view({ + 'get': 'retrieve', + 'patch': 'partial_update', + 'put': 'update', + 'delete': 'destroy', + }), + name='network-map-memberships-detail', + ), path('graph/', views.graph_data), path('network-map-graph/', views.network_map_graph), path('relation-types/', views.relation_types), diff --git a/backend/contacts/views.py b/backend/contacts/views.py index 1f37429..3b9f555 100644 --- a/backend/contacts/views.py +++ b/backend/contacts/views.py @@ -3,18 +3,25 @@ import io import json from rest_framework import viewsets, status -from rest_framework.decorators import api_view, action +from rest_framework.decorators import api_view from rest_framework.response import Response from .models import ( Contact, Relation, + NetworkMap, + NetworkMapMembership, RELATION_TYPES, LIFE_SPHERES, NETWORK_CIRCLES, INTERACTION_INTENSITY, ) -from .serializers import ContactSerializer, RelationSerializer, GraphSerializer +from .serializers import ( + ContactSerializer, + RelationSerializer, + NetworkMapSerializer, + NetworkMapMembershipSerializer, +) class ContactViewSet(viewsets.ModelViewSet): @@ -34,6 +41,53 @@ class RelationViewSet(viewsets.ModelViewSet): serializer_class = RelationSerializer +class NetworkMapViewSet(viewsets.ModelViewSet): + queryset = NetworkMap.objects.all() + serializer_class = NetworkMapSerializer + + +class NetworkMapMembershipViewSet(viewsets.ModelViewSet): + serializer_class = NetworkMapMembershipSerializer + + def get_queryset(self): + map_id = self.kwargs.get('map_pk') + return NetworkMapMembership.objects.filter( + map_id=map_id + ).select_related('contact', 'map') + + def perform_create(self, serializer): + map_id = self.kwargs.get('map_pk') + serializer.save(map_id=map_id) + + +def _node_from_membership(membership): + contact = membership.contact + return { + 'id': contact.id, + 'label': contact.name, + 'title': '\n'.join(filter(None, [contact.organization, contact.position, contact.email])), + 'group': contact.organization or 'default', + 'life_sphere': membership.life_sphere, + 'network_circle': membership.network_circle, + 'importance': membership.importance, + 'map_angle': membership.map_angle, + 'map_radius_ratio': membership.map_radius_ratio, + 'membership_id': membership.id, + } + + +def _edge_from_relation(r): + return { + 'id': r.id, + 'from': r.source_id, + 'to': r.target_id, + 'label': r.get_relation_type_display(), + 'title': r.description or r.get_relation_type_display(), + 'relation_type': r.relation_type, + 'interaction_intensity': r.interaction_intensity, + } + + @api_view(['GET']) def graph_data(request): """Возвращает граф: nodes + edges для vis.js.""" @@ -44,62 +98,34 @@ def graph_data(request): 'label': c.name, 'title': '\n'.join(filter(None, [c.organization, c.position, c.email])), 'group': c.organization or 'default', - 'life_sphere': c.life_sphere, - 'network_circle': c.network_circle, - 'importance': c.importance, - 'map_angle': c.map_angle, - 'map_radius_ratio': c.map_radius_ratio, } for c in contacts ] relations = Relation.objects.select_related('source', 'target').all() - edges = [ - { - 'id': r.id, - 'from': r.source_id, - 'to': r.target_id, - 'label': r.get_relation_type_display(), - 'title': r.description or r.get_relation_type_display(), - 'relation_type': r.relation_type, - 'interaction_intensity': r.interaction_intensity, - } - for r in relations - ] + edges = [_edge_from_relation(r) for r in relations] return Response({'nodes': nodes, 'edges': edges}) @api_view(['GET']) def network_map_graph(request): - """Граф только для карты сети: контакты с include_on_network_map и связи между ними.""" - contacts = list( - Contact.objects.filter(include_on_network_map=True).order_by('name') + """Граф для конкретной карты сети: участники и связи между ними.""" + map_id = request.query_params.get('map_id') + if not map_id: + default_map = NetworkMap.objects.order_by('id').first() + if not default_map: + return Response({'nodes': [], 'edges': []}) + map_id = default_map.id + + memberships = list( + NetworkMapMembership.objects.filter(map_id=map_id) + .select_related('contact') + .order_by('contact__name') ) - allowed_ids = {c.id for c in contacts} - nodes = [ - { - 'id': c.id, - 'label': c.name, - 'title': '\n'.join(filter(None, [c.organization, c.position, c.email])), - 'group': c.organization or 'default', - 'life_sphere': c.life_sphere, - 'network_circle': c.network_circle, - 'importance': c.importance, - 'map_angle': c.map_angle, - 'map_radius_ratio': c.map_radius_ratio, - } - for c in contacts - ] + allowed_ids = {m.contact_id for m in memberships} + nodes = [_node_from_membership(m) for m in memberships] relations = Relation.objects.select_related('source', 'target').all() edges = [ - { - 'id': r.id, - 'from': r.source_id, - 'to': r.target_id, - 'label': r.get_relation_type_display(), - 'title': r.description or r.get_relation_type_display(), - 'relation_type': r.relation_type, - 'interaction_intensity': r.interaction_intensity, - } + _edge_from_relation(r) for r in relations if r.source_id in allowed_ids and r.target_id in allowed_ids ] diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 198dd82..2a2fb0b 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -73,7 +73,11 @@
- + + + + +
diff --git a/frontend/src/application/usecases/graph.js b/frontend/src/application/usecases/graph.js index c9da381..e03053c 100644 --- a/frontend/src/application/usecases/graph.js +++ b/frontend/src/application/usecases/graph.js @@ -1,18 +1,20 @@ import { listContacts } from './contacts' import { listRelations } from './relations' +import { listMembershipsByMap } from './networkMaps' import { getRelationTypes, getNetworkMapChoices } from '../../infrastructure/repositories/repositoryFactory' -function nodeFromContact(c) { +function nodeFromContactAndMembership(contact, membership) { return { - id: c.id, - label: c.name, - title: [c.organization, c.position, c.email].filter(Boolean).join('\n'), - group: c.organization || 'default', - life_sphere: c.life_sphere, - network_circle: c.network_circle, - importance: c.importance, - map_angle: c.map_angle, - map_radius_ratio: c.map_radius_ratio, + id: contact.id, + label: contact.name, + title: [contact.organization, contact.position, contact.email].filter(Boolean).join('\n'), + group: contact.organization || 'default', + life_sphere: membership.life_sphere, + network_circle: membership.network_circle, + importance: membership.importance, + map_angle: membership.map_angle, + map_radius_ratio: membership.map_radius_ratio, + membership_id: membership.id, } } @@ -28,21 +30,59 @@ export function edgeFromRelation(r) { } } -export async function getGraphBundle({ networkMapOnly = false } = {}) { +export function buildGraphFromStore(contacts, relations, relationTypes) { + return { + nodes: contacts.map((c) => ({ + id: c.id, + label: c.name, + title: [c.organization, c.position, c.email].filter(Boolean).join('\n'), + group: c.organization || 'default', + })), + edges: relations.map(edgeFromRelation), + relationTypes, + } +} + +export async function getGraphBundle({ mapId = null } = {}) { const [contacts, relations, relationTypes] = await Promise.all([ listContacts(), listRelations(), getRelationTypes(), ]) - const scopedContacts = networkMapOnly - ? contacts.filter((c) => c.include_on_network_map) - : contacts - const allowedIds = new Set(scopedContacts.map((c) => c.id)) - const scopedRelations = relations.filter((r) => allowedIds.has(r.source) && allowedIds.has(r.target)) + if (!mapId) { + return { + nodes: contacts.map((c) => ({ + id: c.id, + label: c.name, + title: [c.organization, c.position, c.email].filter(Boolean).join('\n'), + group: c.organization || 'default', + })), + edges: relations.map(edgeFromRelation), + relationTypes, + } + } + + const memberships = await listMembershipsByMap(mapId) + const contactById = new Map(contacts.map((c) => [String(c.id), c])) + const allowedIds = new Set() + + const nodes = memberships + .map((membership) => { + const contactId = String(membership.contactId || membership.contact) + const contact = contactById.get(contactId) + if (!contact) return null + allowedIds.add(contactId) + return nodeFromContactAndMembership(contact, membership) + }) + .filter(Boolean) + + const scopedRelations = relations.filter( + (r) => allowedIds.has(String(r.source)) && allowedIds.has(String(r.target)) + ) return { - nodes: scopedContacts.map(nodeFromContact), + nodes, edges: scopedRelations.map(edgeFromRelation), relationTypes, } diff --git a/frontend/src/application/usecases/graph.test.js b/frontend/src/application/usecases/graph.test.js new file mode 100644 index 0000000..668d04e --- /dev/null +++ b/frontend/src/application/usecases/graph.test.js @@ -0,0 +1,86 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { getGraphBundle } from './graph' + +vi.mock('./contacts', () => ({ + listContacts: vi.fn(), +})) + +vi.mock('./relations', () => ({ + listRelations: vi.fn(), +})) + +vi.mock('./networkMaps', () => ({ + listMembershipsByMap: vi.fn(), +})) + +vi.mock('../infrastructure/repositories/repositoryFactory', () => ({ + getRelationTypes: vi.fn(async () => [{ value: 'friend', label: 'Друг' }]), +})) + +import { listContacts } from './contacts' +import { listRelations } from './relations' +import { listMembershipsByMap } from './networkMaps' + +describe('getGraphBundle', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns full graph when mapId is not provided', async () => { + listContacts.mockResolvedValue([ + { id: '1', name: 'Анна', organization: 'Org' }, + { id: '2', name: 'Борис' }, + ]) + listRelations.mockResolvedValue([ + { id: '10', source: '1', target: '2', relation_type: 'friend' }, + ]) + + const bundle = await getGraphBundle() + expect(bundle.nodes).toHaveLength(2) + expect(bundle.edges).toHaveLength(1) + expect(listMembershipsByMap).not.toHaveBeenCalled() + }) + + it('scopes nodes and edges to map memberships', async () => { + listContacts.mockResolvedValue([ + { id: '1', name: 'Анна' }, + { id: '2', name: 'Борис' }, + { id: '3', name: 'Вера' }, + ]) + listRelations.mockResolvedValue([ + { id: '10', source: '1', target: '2', relation_type: 'friend' }, + { id: '11', source: '2', target: '3', relation_type: 'colleague' }, + ]) + listMembershipsByMap.mockResolvedValue([ + { + id: 'm1', + contactId: '1', + life_sphere: 'work', + network_circle: 'productivity', + importance: 4, + map_angle: 0.5, + map_radius_ratio: 0.6, + }, + { + id: 'm2', + contactId: '2', + life_sphere: 'family', + network_circle: 'support', + importance: 3, + map_angle: 1.2, + map_radius_ratio: 0.4, + }, + ]) + + const bundle = await getGraphBundle({ mapId: 'map-1' }) + expect(listMembershipsByMap).toHaveBeenCalledWith('map-1') + expect(bundle.nodes).toHaveLength(2) + expect(bundle.nodes[0]).toMatchObject({ + id: '1', + life_sphere: 'work', + membership_id: 'm1', + }) + expect(bundle.edges).toHaveLength(1) + expect(bundle.edges[0].id).toBe('10') + }) +}) diff --git a/frontend/src/application/usecases/importExport.js b/frontend/src/application/usecases/importExport.js index 0d5be93..55ef7bc 100644 --- a/frontend/src/application/usecases/importExport.js +++ b/frontend/src/application/usecases/importExport.js @@ -1,6 +1,7 @@ import { listContacts, createContact } from './contacts' import { listRelations } from './relations' -import { localDb } from '../../infrastructure/db/localDb' +import { localDb, DEFAULT_MAP_NAME } from '../../infrastructure/db/localDb' +import { generateId } from '../../lib/uuid' import { parseVcf } from '../../lib/import/vcard' import { serializeContactsExport } from '../../lib/export/contacts' @@ -10,12 +11,20 @@ function isLikelyEmail(value) { function normalizeRows(raw) { if (Array.isArray(raw)) return raw - if (raw && Array.isArray(raw.contacts)) return raw.contacts + if (raw && Array.isArray(raw.contacts) && !isLocalDataDump(raw)) return raw.contacts if (raw && Array.isArray(raw.results)) return raw.results if (raw && Array.isArray(raw.data)) return raw.data return [] } +export function isEncryptedLocalDump(raw) { + return Boolean(raw?.data && raw?.algorithm && raw?.iv && raw?.salt) +} + +export function isLocalDataDump(raw) { + return Boolean(raw && Array.isArray(raw.contacts) && Array.isArray(raw.relations)) +} + function parseCsv(text) { const lines = text.split(/\r?\n/).filter(Boolean) if (!lines.length) return [] @@ -59,7 +68,20 @@ export async function importContactsFromFile(file) { if (name.endsWith('.csv')) { rows = parseCsv(rawText) } else if (name.endsWith('.json')) { - rows = normalizeRows(JSON.parse(rawText)) + const raw = JSON.parse(rawText) + if (isLocalDataDump(raw) || isEncryptedLocalDump(raw)) { + const summary = await importLocalDump(file) + return { + total: summary.importedContacts + summary.importedRelations, + created: summary.importedContacts, + importedRelations: summary.importedRelations, + importedMaps: summary.importedMaps, + skipped: 0, + errors: [], + isDump: true, + } + } + rows = normalizeRows(raw) } else if (name.endsWith('.vcf') || name.endsWith('.vcard')) { rows = parseVcf(rawText) if (!rows.length) { @@ -147,12 +169,115 @@ async function deriveKey(passphrase, saltBytes) { ) } +function stripLegacyContactFields(contact) { + const next = { ...contact } + delete next.include_on_network_map + delete next.life_sphere + delete next.network_circle + delete next.importance + delete next.map_angle + delete next.map_radius_ratio + delete next.relations_count + delete next.created_at + delete next.updated_at + return next +} + +function normalizeDumpContact(contact) { + const ts = contact.createdAt || contact.created_at || new Date().toISOString() + return { + ...stripLegacyContactFields(contact), + id: contact.id, + createdAt: contact.createdAt || contact.created_at || ts, + updatedAt: contact.updatedAt || contact.updated_at || ts, + deletedAt: contact.deletedAt ?? contact.deleted_at ?? null, + workspaceId: contact.workspaceId || 'personal', + ownerId: contact.ownerId || 'local-user', + version: contact.version ?? 1, + } +} + +function normalizeDumpRelation(relation) { + const ts = relation.createdAt || relation.created_at || new Date().toISOString() + return { + id: relation.id, + source: relation.source, + target: relation.target, + relation_type: relation.relation_type || 'acquaintance', + description: relation.description || '', + interaction_intensity: relation.interaction_intensity || 'intense', + ownerId: relation.ownerId || 'local-user', + workspaceId: relation.workspaceId || 'personal', + version: relation.version ?? 1, + createdAt: ts, + updatedAt: relation.updatedAt || relation.updated_at || ts, + deletedAt: relation.deletedAt ?? relation.deleted_at ?? null, + } +} + +function migrateV1Dump(dump) { + if ((dump.version || 1) >= 2) { + return { + ...dump, + networkMaps: dump.networkMaps || [], + networkMapMemberships: dump.networkMapMemberships || [], + contacts: (dump.contacts || []).map(stripLegacyContactFields), + } + } + + const ts = new Date().toISOString() + const mapId = generateId() + const networkMaps = [{ + id: mapId, + name: DEFAULT_MAP_NAME, + description: 'Импортировано из бэкапа v1', + workspaceId: 'personal', + version: 1, + createdAt: ts, + updatedAt: ts, + deletedAt: null, + }] + const networkMapMemberships = [] + const contacts = (dump.contacts || []).map((contact) => { + if (contact.include_on_network_map) { + networkMapMemberships.push({ + id: generateId(), + mapId, + contactId: contact.id, + life_sphere: contact.life_sphere || 'other', + network_circle: contact.network_circle || 'productivity', + importance: contact.importance ?? 3, + map_angle: contact.map_angle ?? null, + map_radius_ratio: contact.map_radius_ratio ?? null, + workspaceId: 'personal', + version: 1, + createdAt: ts, + updatedAt: ts, + deletedAt: null, + }) + } + return stripLegacyContactFields(contact) + }) + + return { + ...dump, + version: 2, + contacts, + relations: dump.relations || [], + networkMaps, + networkMapMemberships, + changes: dump.changes || [], + } +} + export async function exportLocalData({ passphrase = '' } = {}) { const payload = { - version: 1, + version: 2, exportedAt: new Date().toISOString(), contacts: await listContacts(), relations: await listRelations(), + networkMaps: await localDb.networkMaps.toArray(), + networkMapMemberships: await localDb.networkMapMemberships.toArray(), changes: await localDb.changelog.toArray(), } @@ -169,7 +294,7 @@ export async function exportLocalData({ passphrase = '' } = {}) { const encoded = new TextEncoder().encode(JSON.stringify(payload)) const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, encoded) const wrapped = { - version: 1, + version: 2, algorithm: 'AES-GCM', kdf: 'PBKDF2-SHA256', iterations: 210000, @@ -194,25 +319,48 @@ async function decryptPayload(raw, passphrase) { export async function importLocalDump(file, passphrase = '') { const raw = JSON.parse(await file.text()) - const dump = raw?.data ? await decryptPayload(raw, passphrase) : raw + const dump = migrateV1Dump(raw?.data ? await decryptPayload(raw, passphrase) : raw) if (!Array.isArray(dump.contacts) || !Array.isArray(dump.relations)) { throw new Error('Некорректный формат экспортного файла') } - await localDb.transaction('rw', localDb.contacts, localDb.relations, localDb.changelog, async () => { - for (const contact of dump.contacts) { - await localDb.contacts.put(contact) - } - for (const relation of dump.relations) { - await localDb.relations.put(relation) - } - if (Array.isArray(dump.changes)) { - for (const change of dump.changes) { - await localDb.changelog.put(change) + await localDb.transaction( + 'rw', + localDb.contacts, + localDb.relations, + localDb.networkMaps, + localDb.networkMapMemberships, + localDb.changelog, + async () => { + for (const contact of dump.contacts) { + await localDb.contacts.put(normalizeDumpContact(contact)) + } + for (const relation of dump.relations) { + await localDb.relations.put(normalizeDumpRelation(relation)) + } + if (Array.isArray(dump.networkMaps)) { + for (const map of dump.networkMaps) { + await localDb.networkMaps.put(map) + } + } + if (Array.isArray(dump.networkMapMemberships)) { + for (const membership of dump.networkMapMemberships) { + await localDb.networkMapMemberships.put(membership) + } + } + if (Array.isArray(dump.changes)) { + for (const change of dump.changes) { + await localDb.changelog.put(change) + } } } - }) + ) - return { importedContacts: dump.contacts.length, importedRelations: dump.relations.length } + return { + importedContacts: dump.contacts.length, + importedRelations: dump.relations.length, + importedMaps: dump.networkMaps?.length || 0, + importedMemberships: dump.networkMapMemberships?.length || 0, + } } diff --git a/frontend/src/application/usecases/importExport.test.js b/frontend/src/application/usecases/importExport.test.js new file mode 100644 index 0000000..ff83c1e --- /dev/null +++ b/frontend/src/application/usecases/importExport.test.js @@ -0,0 +1,99 @@ +import { describe, it, expect } from 'vitest' +import { isLocalDataDump, isEncryptedLocalDump } from './importExport' + +// Test migration logic via exported helper pattern - inline replicate of migrateV1Dump +function stripLegacyContactFields(contact) { + const next = { ...contact } + delete next.include_on_network_map + delete next.life_sphere + delete next.network_circle + delete next.importance + delete next.map_angle + delete next.map_radius_ratio + return next +} + +function migrateV1Dump(dump) { + if ((dump.version || 1) >= 2) { + return { + ...dump, + networkMaps: dump.networkMaps || [], + networkMapMemberships: dump.networkMapMemberships || [], + contacts: (dump.contacts || []).map(stripLegacyContactFields), + } + } + + const mapId = 'default-map' + const networkMaps = [{ id: mapId, name: 'Основная карта' }] + const networkMapMemberships = [] + const contacts = (dump.contacts || []).map((contact) => { + if (contact.include_on_network_map) { + networkMapMemberships.push({ + id: `m-${contact.id}`, + mapId, + contactId: contact.id, + life_sphere: contact.life_sphere || 'other', + network_circle: contact.network_circle || 'productivity', + importance: contact.importance ?? 3, + }) + } + return stripLegacyContactFields(contact) + }) + + return { + ...dump, + version: 2, + contacts, + networkMaps, + networkMapMemberships, + } +} + +describe('local dump detection', () => { + it('detects full backup JSON', () => { + expect(isLocalDataDump({ version: 2, contacts: [], relations: [] })).toBe(true) + expect(isLocalDataDump({ contacts: [{ name: 'A' }] })).toBe(false) + expect(isLocalDataDump([{ name: 'A' }])).toBe(false) + }) + + it('detects encrypted backup wrapper', () => { + expect(isEncryptedLocalDump({ + algorithm: 'AES-GCM', + salt: 'abc', + iv: 'def', + data: 'ghi', + })).toBe(true) + expect(isEncryptedLocalDump({ contacts: [], relations: [] })).toBe(false) + }) +}) + +describe('backup v1 migration', () => { + it('creates default map and memberships from legacy contact fields', () => { + const migrated = migrateV1Dump({ + version: 1, + contacts: [ + { + id: 'c1', + name: 'Иван', + include_on_network_map: true, + life_sphere: 'work', + network_circle: 'support', + importance: 5, + }, + { id: 'c2', name: 'Мария', include_on_network_map: false }, + ], + relations: [], + }) + + expect(migrated.version).toBe(2) + expect(migrated.networkMaps).toHaveLength(1) + expect(migrated.networkMapMemberships).toHaveLength(1) + expect(migrated.networkMapMemberships[0]).toMatchObject({ + contactId: 'c1', + life_sphere: 'work', + importance: 5, + }) + expect(migrated.contacts[0].include_on_network_map).toBeUndefined() + expect(migrated.contacts[1].life_sphere).toBeUndefined() + }) +}) diff --git a/frontend/src/application/usecases/networkMaps.js b/frontend/src/application/usecases/networkMaps.js new file mode 100644 index 0000000..7006927 --- /dev/null +++ b/frontend/src/application/usecases/networkMaps.js @@ -0,0 +1,112 @@ +import { appendChange } from '../../infrastructure/sync/changeLogRepository' +import { + getNetworkMapRepository, + getNetworkMapMembershipRepository, +} from '../../infrastructure/repositories/repositoryFactory' + +const mapRepo = () => getNetworkMapRepository() +const membershipRepo = () => getNetworkMapMembershipRepository() + +export async function listNetworkMaps() { + return mapRepo().list() +} + +export async function getNetworkMapById(id) { + return mapRepo().getById(id) +} + +export async function createNetworkMap(payload) { + const created = await mapRepo().create(payload) + await appendChange({ + entityType: 'networkMap', + entityId: created.id, + op: 'created', + payloadPatch: created, + }) + return created +} + +export async function updateNetworkMap(id, payload) { + const updated = await mapRepo().update(id, payload) + await appendChange({ + entityType: 'networkMap', + entityId: id, + op: 'updated', + payloadPatch: payload, + }) + return updated +} + +export async function deleteNetworkMap(id) { + await mapRepo().remove(id) + await appendChange({ + entityType: 'networkMap', + entityId: id, + op: 'deleted', + payloadPatch: {}, + }) +} + +export async function listMembershipsByMap(mapId) { + return membershipRepo().listByMap(mapId) +} + +export async function listMembershipsByContact(contactId) { + return membershipRepo().listByContact(contactId) +} + +export async function addContactToMap(mapId, contactId, extra = {}) { + const created = await membershipRepo().create(mapId, { + contact: contactId, + ...extra, + }) + await appendChange({ + entityType: 'networkMapMembership', + entityId: created.id, + op: 'created', + payloadPatch: created, + }) + return created +} + +export async function updateMembership(mapId, membershipId, payload) { + const updated = await membershipRepo().update(mapId, membershipId, payload) + await appendChange({ + entityType: 'networkMapMembership', + entityId: membershipId, + op: 'updated', + payloadPatch: payload, + }) + return updated +} + +export async function removeContactFromMap(mapId, membershipId) { + await membershipRepo().remove(mapId, membershipId) + await appendChange({ + entityType: 'networkMapMembership', + entityId: membershipId, + op: 'deleted', + payloadPatch: {}, + }) +} + +export async function setContactMapMemberships(contactId, mapIds = []) { + const existing = await listMembershipsByContact(contactId) + const existingMapIds = new Set(existing.map((m) => String(m.mapId || m.map))) + const targetMapIds = new Set(mapIds.map(String)) + + for (const membership of existing) { + const mid = String(membership.mapId || membership.map) + if (!targetMapIds.has(mid)) { + await removeContactFromMap(mid, membership.id) + } + } + + for (const mapId of targetMapIds) { + if (!existingMapIds.has(mapId)) { + await addContactToMap(mapId, contactId) + } + } + + return listMembershipsByContact(contactId) +} diff --git a/frontend/src/application/usecases/relations.js b/frontend/src/application/usecases/relations.js index f9017b6..6faa02b 100644 --- a/frontend/src/application/usecases/relations.js +++ b/frontend/src/application/usecases/relations.js @@ -27,3 +27,14 @@ export async function deleteRelation(id) { payloadPatch: {}, }) } + +export async function updateRelation(id, payload) { + const relation = await relationRepo().update(id, payload) + await appendChange({ + entityType: 'relation', + entityId: id, + op: 'updated', + payloadPatch: payload, + }) + return relation +} diff --git a/frontend/src/components/AddContactToMapModal.vue b/frontend/src/components/AddContactToMapModal.vue new file mode 100644 index 0000000..250f4c8 --- /dev/null +++ b/frontend/src/components/AddContactToMapModal.vue @@ -0,0 +1,72 @@ + + + + + diff --git a/frontend/src/components/ContactForm.test.js b/frontend/src/components/ContactForm.test.js index 48e536d..9c770fd 100644 --- a/frontend/src/components/ContactForm.test.js +++ b/frontend/src/components/ContactForm.test.js @@ -1,25 +1,39 @@ -import { describe, it, expect, vi } from 'vitest' +import { describe, it, expect, vi, beforeEach } from 'vitest' import { mount } from '@vue/test-utils' import ContactForm from './ContactForm.vue' -vi.mock('../api', () => ({ - default: { - get: vi.fn(async () => ({ - data: { - life_spheres: [{ value: 'work', label: 'Работа' }], - network_circles: [{ value: 'support', label: 'Круг поддержки' }], - }, - })), - }, +vi.mock('../stores/networkMaps', () => ({ + useNetworkMapsStore: () => ({ + maps: [{ id: 'map-1', name: 'Основная карта' }], + fetchMaps: vi.fn(async () => []), + }), +})) + +vi.mock('../application/usecases/networkMaps', () => ({ + listMembershipsByContact: vi.fn(async () => []), })) describe('ContactForm', () => { - it('emits submit with normalized payload', async () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('emits submit with contact data and map ids', async () => { const wrapper = mount(ContactForm, { props: { initial: { name: 'Иван' } } }) await wrapper.get('form').trigger('submit.prevent') - const payload = wrapper.emitted('submit')[0][0] - expect(payload.name).toBe('Иван') - expect(payload.importance).toBeDefined() - expect(payload.include_on_network_map).toBe(false) + const [contactData, mapIds] = wrapper.emitted('submit')[0] + expect(contactData.name).toBe('Иван') + expect(contactData.include_on_network_map).toBeUndefined() + expect(mapIds).toEqual([]) + }) + + it('shows delete button when editing existing contact', async () => { + const wrapper = mount(ContactForm, { + props: { initial: { id: '1', name: 'Иван' }, deletable: true }, + }) + const deleteBtn = wrapper.find('.btn-danger') + expect(deleteBtn.exists()).toBe(true) + await deleteBtn.trigger('click') + expect(wrapper.emitted('delete')).toHaveLength(1) }) }) diff --git a/frontend/src/components/ContactForm.vue b/frontend/src/components/ContactForm.vue index 2407ecd..e2f9948 100644 --- a/frontend/src/components/ContactForm.vue +++ b/frontend/src/components/ContactForm.vue @@ -24,70 +24,65 @@ -
-
- - +
+ +
+ Нет карт. Создайте карту на странице «Карта сети».
-
- - +
+
-
- - -
-
-
- -

На странице «Граф» контакт виден всегда; на «Карте сети» — только с этой отметкой.

+

На странице «Граф» контакт виден всегда; на выбранных картах — в соответствующих визуализациях.

-