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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user