Add import/export, graph UX improvements, and contact management fixes.

Support vCard import and multi-format export, bulk delete with reliable local persistence, Ctrl+link relation creation, cluster-colored graph visualization, and right-click context menus for node details.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-23 11:36:13 +03:00
co-authored by Cursor
parent 2a32c61934
commit 35ec4c81ec
27 changed files with 1819 additions and 110 deletions
+84 -2
View File
@@ -237,14 +237,88 @@ def _normalize_rows(data):
return []
def _unfold_vcard_lines(text):
lines = text.replace('\r\n', '\n').replace('\r', '\n').split('\n')
unfolded = []
for line in lines:
if line.startswith((' ', '\t')) and unfolded:
unfolded[-1] += line[1:]
else:
unfolded.append(line)
return unfolded
def _unescape_vcard_value(value):
return (
str(value or '')
.replace('\\n', '\n')
.replace('\\N', '\n')
.replace('\\,', ',')
.replace('\\;', ';')
.replace('\\\\', '\\')
.strip()
)
def _name_from_vcard_n(value):
parts = _unescape_vcard_value(value).split(';')
family = (parts[0] if len(parts) > 0 else '').strip()
given = (parts[1] if len(parts) > 1 else '').strip()
additional = (parts[2] if len(parts) > 2 else '').strip()
return ' '.join(filter(None, [given, additional, family])).strip()
def _parse_vcf_rows(text):
props = None
rows = []
for line in _unfold_vcard_lines(text):
trimmed = line.strip()
if not trimmed:
continue
upper = trimmed.upper()
if upper == 'BEGIN:VCARD':
props = {}
continue
if upper == 'END:VCARD':
if props:
name = (
(props.get('FN') or [''])[0]
or _name_from_vcard_n((props.get('N') or [''])[0])
).strip()
org_raw = (props.get('ORG') or [''])[0]
org = org_raw.split(';')[0].strip() if org_raw else ''
email = (props.get('EMAIL') or [''])[0].replace('mailto:', '').strip()
phone = (props.get('TEL') or [''])[0].replace('tel:', '').strip()
rows.append({
'name': name,
'email': email,
'phone': phone,
'organization': org,
'position': (props.get('TITLE') or [''])[0].strip(),
'notes': '\n'.join(props.get('NOTE') or []).strip(),
})
props = None
continue
if props is None:
continue
if ':' not in trimmed:
continue
raw_key, value = trimmed.split(':', 1)
key = raw_key.split(';')[0].upper()
decoded = _unescape_vcard_value(value.replace('mailto:', '').replace('tel:', ''))
props.setdefault(key, []).append(decoded)
return rows
@api_view(['POST'])
def import_contacts(request):
"""
Импорт контактов из CSV или JSON.
Импорт контактов из CSV, JSON или vCard (.vcf).
CSV: name,email,phone,organization,position,notes
JSON (плоский): [{"name": "...", ...}, ...]
JSON (Monica CRM): {"contacts": [{"first_name": ..., "last_name": ...}, ...]}
vCard: экспорт из телефона / Google Contacts (.vcf)
"""
file = request.FILES.get('file')
if not file:
@@ -268,9 +342,17 @@ def import_contacts(request):
{'error': 'Не удалось распознать формат JSON. Ожидается массив контактов или экспорт Monica CRM.'},
status=status.HTTP_400_BAD_REQUEST,
)
elif filename.endswith('.vcf') or filename.endswith('.vcard'):
text = file.read().decode('utf-8-sig')
rows = _parse_vcf_rows(text)
if not rows:
return Response(
{'error': 'В файле vCard не найдено контактов.'},
status=status.HTTP_400_BAD_REQUEST,
)
else:
return Response(
{'error': 'Поддерживаются только CSV и JSON файлы.'},
{'error': 'Поддерживаются только CSV, JSON и vCard (.vcf) файлы.'},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e: