diff --git a/README.md b/README.md index 7a0c04a..03b5ec7 100644 --- a/README.md +++ b/README.md @@ -92,13 +92,13 @@ social-graph/ | GET | `/api/network-map-graph/` | Граф карты сети | | GET | `/api/relation-types/` | Типы связей | | GET | `/api/network-map-choices/` | Справочники карты | -| POST | `/api/import/` | Импорт CSV/JSON (legacy) | +| POST | `/api/import/` | Импорт CSV/JSON/vCard (legacy) | В режиме `local` импорт и бэкап выполняются в браузере (экран **Импорт**). ## Импорт и бэкап (local-first) -- **CSV / JSON** — экран «Импорт», парсинг на клиенте. +- **CSV / JSON / vCard (.vcf)** — экран «Импорт», парсинг на клиенте. - **Экспорт / импорт бэкапа** — JSON или зашифрованный `.sgpkg` (WebCrypto, пароль опционален). ## Production @@ -153,5 +153,4 @@ docker compose exec frontend npm test -- --run - [ ] Синхронизация и shared-workspace - [ ] Поиск по организации и должности - [ ] История изменений контакта -- [ ] Импорт из vCard (.vcf) - [ ] Уведомления / дни рождения diff --git a/backend/contacts/views.py b/backend/contacts/views.py index c4cc838..1f37429 100644 --- a/backend/contacts/views.py +++ b/backend/contacts/views.py @@ -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: diff --git a/deploy/README.md b/deploy/README.md index 944c386..8d86c3b 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -121,7 +121,7 @@ sudo systemctl reload apache2 # или: sudo nginx -s reload | Источник | Действие | |----------|----------| -| CSV/JSON | UI → Импорт | +| CSV / JSON / **vCard (.vcf)** | UI → Импорт | | Старый Django SQLite | экспорт JSON → Импорт | | Локальный бэкап `.json` / `.sgpkg` | UI → «Импорт бэкапа» | diff --git a/frontend/src/application/usecases/contacts.js b/frontend/src/application/usecases/contacts.js index f0d7e84..933bbc2 100644 --- a/frontend/src/application/usecases/contacts.js +++ b/frontend/src/application/usecases/contacts.js @@ -42,3 +42,9 @@ export async function deleteContact(id) { payloadPatch: {}, }) } + +export async function deleteContacts(ids = []) { + for (const id of ids) { + await deleteContact(id) + } +} diff --git a/frontend/src/application/usecases/graph.js b/frontend/src/application/usecases/graph.js index cab55b9..c9da381 100644 --- a/frontend/src/application/usecases/graph.js +++ b/frontend/src/application/usecases/graph.js @@ -16,7 +16,7 @@ function nodeFromContact(c) { } } -function edgeFromRelation(r) { +export function edgeFromRelation(r) { return { id: r.id, from: r.source, diff --git a/frontend/src/application/usecases/importExport.js b/frontend/src/application/usecases/importExport.js index 78d5714..0d5be93 100644 --- a/frontend/src/application/usecases/importExport.js +++ b/frontend/src/application/usecases/importExport.js @@ -1,6 +1,8 @@ import { listContacts, createContact } from './contacts' -import { listRelations, createRelation } from './relations' +import { listRelations } from './relations' import { localDb } from '../../infrastructure/db/localDb' +import { parseVcf } from '../../lib/import/vcard' +import { serializeContactsExport } from '../../lib/export/contacts' function isLikelyEmail(value) { return value.includes('@') && value.includes('.') @@ -58,8 +60,13 @@ export async function importContactsFromFile(file) { rows = parseCsv(rawText) } else if (name.endsWith('.json')) { rows = normalizeRows(JSON.parse(rawText)) + } else if (name.endsWith('.vcf') || name.endsWith('.vcard')) { + rows = parseVcf(rawText) + if (!rows.length) { + throw new Error('В файле vCard не найдено контактов.') + } } else { - throw new Error('Поддерживаются только CSV и JSON файлы.') + throw new Error('Поддерживаются только CSV, JSON и vCard (.vcf) файлы.') } let created = 0 @@ -69,7 +76,7 @@ export async function importContactsFromFile(file) { const payload = toContactPayload(rows[i]) if (!payload.name) { skipped += 1 - errors.push(`Строка ${i + 1}: отсутствует поле "name"`) + errors.push(`Запись ${i + 1}: отсутствует имя контакта`) continue } await createContact(payload) @@ -79,6 +86,28 @@ export async function importContactsFromFile(file) { return { total: rows.length, created, skipped, errors } } +const ALLOWED_EXPORT_FORMATS = new Set(['csv', 'json', 'vcf']) + +export async function exportContacts({ format = 'csv' } = {}) { + const normalized = String(format || 'csv').toLowerCase() + if (!ALLOWED_EXPORT_FORMATS.has(normalized)) { + throw new Error('Поддерживаются только CSV, JSON и vCard (.vcf).') + } + + const contacts = await listContacts() + if (!contacts.length) { + throw new Error('Нет контактов для экспорта.') + } + + const { filename, mime, content } = serializeContactsExport(contacts, normalized) + return { + filename, + blob: new Blob([content], { type: mime }), + count: contacts.length, + format: normalized, + } +} + function uint8ToBase64(bytes) { let binary = '' bytes.forEach((b) => { diff --git a/frontend/src/components/CreateRelationModal.vue b/frontend/src/components/CreateRelationModal.vue new file mode 100644 index 0000000..8c6d4c6 --- /dev/null +++ b/frontend/src/components/CreateRelationModal.vue @@ -0,0 +1,142 @@ + + + + + diff --git a/frontend/src/components/GraphNodeContextMenu.vue b/frontend/src/components/GraphNodeContextMenu.vue new file mode 100644 index 0000000..d70c248 --- /dev/null +++ b/frontend/src/components/GraphNodeContextMenu.vue @@ -0,0 +1,121 @@ + + + + + diff --git a/frontend/src/composables/useCtrlLinkSelection.js b/frontend/src/composables/useCtrlLinkSelection.js new file mode 100644 index 0000000..c342a75 --- /dev/null +++ b/frontend/src/composables/useCtrlLinkSelection.js @@ -0,0 +1,69 @@ +import { ref, computed } from 'vue' + +export function useCtrlLinkSelection({ onPairSelected, maxSelection = 2 } = {}) { + const linkSelection = ref([]) + + const linkSelectionCount = computed(() => linkSelection.value.length) + + function isLinkSelected(id) { + const sid = String(id) + return linkSelection.value.some((item) => String(item.id) === sid) + } + + function clearLinkSelection() { + linkSelection.value = [] + } + + function pickContact(contact) { + if (!contact?.id) return + const sid = String(contact.id) + + if (isLinkSelected(sid)) { + linkSelection.value = linkSelection.value.filter((item) => String(item.id) !== sid) + return + } + + if (linkSelection.value.length >= maxSelection) { + linkSelection.value = [linkSelection.value[0], contact] + } else { + linkSelection.value = [...linkSelection.value, contact] + } + + if (linkSelection.value.length >= maxSelection) { + onPairSelected?.([...linkSelection.value]) + } + } + + function handleCtrlPick(contact, event) { + if (!event?.ctrlKey && !event?.metaKey) return false + event.preventDefault() + event.stopPropagation() + pickContact(contact) + return true + } + + function toContact(node) { + if (!node?.id) return null + return { + id: node.id, + name: node.name || node.label || String(node.id), + } + } + + function handleCtrlPickNode(node, event) { + const contact = toContact(node) + if (!contact) return false + return handleCtrlPick(contact, event) + } + + return { + linkSelection, + linkSelectionCount, + isLinkSelected, + clearLinkSelection, + pickContact, + handleCtrlPick, + handleCtrlPickNode, + toContact, + } +} diff --git a/frontend/src/composables/useGraphNodeContextMenu.js b/frontend/src/composables/useGraphNodeContextMenu.js new file mode 100644 index 0000000..c4d9f1f --- /dev/null +++ b/frontend/src/composables/useGraphNodeContextMenu.js @@ -0,0 +1,48 @@ +import { ref } from 'vue' + +export function useGraphNodeContextMenu() { + const contextMenuOpen = ref(false) + const contextMenuNode = ref(null) + const contextMenuX = ref(0) + const contextMenuY = ref(0) + + function openContextMenu(node, event) { + if (!node || !event) return + contextMenuNode.value = node + contextMenuX.value = event.clientX + contextMenuY.value = event.clientY + contextMenuOpen.value = true + } + + function closeContextMenu() { + contextMenuOpen.value = false + contextMenuNode.value = null + } + + function attachNodeContextHandlers(network, getNodes) { + const onContext = (params) => { + const domEvent = params.event?.srcEvent || params.event + domEvent?.preventDefault?.() + if (params.nodes?.length > 0) { + const id = params.nodes[0] + const node = getNodes().find((n) => String(n.id) === String(id)) + if (node) openContextMenu(node, domEvent) + return + } + closeContextMenu() + } + + network.on('oncontext', onContext) + return () => network.off('oncontext', onContext) + } + + return { + contextMenuOpen, + contextMenuNode, + contextMenuX, + contextMenuY, + openContextMenu, + closeContextMenu, + attachNodeContextHandlers, + } +} diff --git a/frontend/src/infrastructure/repositories/contactRepository.local.js b/frontend/src/infrastructure/repositories/contactRepository.local.js index fe945fe..cbb470e 100644 --- a/frontend/src/infrastructure/repositories/contactRepository.local.js +++ b/frontend/src/infrastructure/repositories/contactRepository.local.js @@ -1,4 +1,5 @@ import { localDb } from '../db/localDb' +import { generateId } from '../../lib/uuid' function nowIso() { return new Date().toISOString() @@ -24,12 +25,24 @@ function withDefaults(payload = {}) { } } +function sameId(a, b) { + return String(a) === String(b) +} + +async function findActiveContact(id) { + const direct = await localDb.contacts.get(id) + if (direct && !direct.deletedAt) return direct + const sid = String(id) + const all = await localDb.contacts.toArray() + return all.find((c) => !c.deletedAt && sameId(c.id, sid)) || null +} + async function relationsCount(id) { - const [sourceCount, targetCount] = await Promise.all([ - localDb.relations.where('source').equals(id).and((r) => !r.deletedAt).count(), - localDb.relations.where('target').equals(id).and((r) => !r.deletedAt).count(), - ]) - return sourceCount + targetCount + const sid = String(id) + const all = await localDb.relations.toArray() + return all.filter( + (r) => !r.deletedAt && (sameId(r.source, sid) || sameId(r.target, sid)) + ).length } async function hydrate(contact) { @@ -47,18 +60,19 @@ export const localContactRepository = { .filter((c) => !c.deletedAt) .filter((c) => c.name.toLowerCase().includes(String(search || '').toLowerCase())) .sort((a, b) => a.name.localeCompare(b.name, 'ru')) - return Promise.all(filtered.map(hydrate)) + const hydrated = await Promise.all(filtered.map(hydrate)) + return hydrated.filter(Boolean) }, async getById(id) { - const contact = await localDb.contacts.get(id) + const contact = await findActiveContact(id) return hydrate(contact) }, async create(payload) { const ts = nowIso() const record = withDefaults(payload) - const id = crypto.randomUUID() + const id = generateId() await localDb.contacts.put({ ...record, id, @@ -71,24 +85,42 @@ export const localContactRepository = { }, async update(id, payload) { - const existing = await localDb.contacts.get(id) - if (!existing || existing.deletedAt) { + const existing = await findActiveContact(id) + if (!existing) { throw new Error('Контакт не найден') } - await localDb.contacts.update(id, { + await localDb.contacts.update(existing.id, { ...payload, updatedAt: nowIso(), version: Number(existing.version || 1) + 1, }) - return this.getById(id) + return this.getById(existing.id) }, async remove(id) { + const contact = await findActiveContact(id) + if (!contact) { + throw new Error('Контакт не найден') + } + const key = contact.id + const sid = String(key) const ts = nowIso() - await localDb.contacts.update(id, { deletedAt: ts, updatedAt: ts }) + await localDb.contacts.update(key, { + deletedAt: ts, + updatedAt: ts, + version: Number(contact.version || 1) + 1, + }) const relations = await localDb.relations - .filter((r) => !r.deletedAt && (r.source === id || r.target === id)) + .filter((r) => !r.deletedAt && (sameId(r.source, sid) || sameId(r.target, sid))) .toArray() - await Promise.all(relations.map((r) => localDb.relations.update(r.id, { deletedAt: ts, updatedAt: ts }))) + await Promise.all( + relations.map((r) => + localDb.relations.update(r.id, { + deletedAt: ts, + updatedAt: ts, + version: Number(r.version || 1) + 1, + }) + ) + ) }, } diff --git a/frontend/src/infrastructure/repositories/relationRepository.local.js b/frontend/src/infrastructure/repositories/relationRepository.local.js index 020064f..b668965 100644 --- a/frontend/src/infrastructure/repositories/relationRepository.local.js +++ b/frontend/src/infrastructure/repositories/relationRepository.local.js @@ -1,4 +1,5 @@ import { localDb } from '../db/localDb' +import { generateId } from '../../lib/uuid' function nowIso() { return new Date().toISOString() @@ -30,7 +31,7 @@ export const localRelationRepository = { throw new Error('Нельзя создать связь контакта с самим собой.') } const ts = nowIso() - const id = crypto.randomUUID() + const id = generateId() await localDb.relations.put({ id, source: payload.source, diff --git a/frontend/src/infrastructure/sync/changeLogRepository.js b/frontend/src/infrastructure/sync/changeLogRepository.js index 193a9a4..183f16c 100644 --- a/frontend/src/infrastructure/sync/changeLogRepository.js +++ b/frontend/src/infrastructure/sync/changeLogRepository.js @@ -1,11 +1,12 @@ import { localDb } from '../db/localDb' +import { generateId } from '../../lib/uuid' function nowIso() { return new Date().toISOString() } export async function appendChange({ entityType, entityId, op, payloadPatch, workspaceId = 'personal' }) { - const id = crypto.randomUUID() + const id = generateId() await localDb.changelog.put({ id, entityType, diff --git a/frontend/src/lib/export/contacts.js b/frontend/src/lib/export/contacts.js new file mode 100644 index 0000000..f344be9 --- /dev/null +++ b/frontend/src/lib/export/contacts.js @@ -0,0 +1,100 @@ +const CSV_HEADERS = ['name', 'email', 'phone', 'organization', 'position', 'notes'] + +export function contactToExportRow(contact = {}) { + return { + name: String(contact.name || '').trim(), + email: String(contact.email || '').trim(), + phone: String(contact.phone || '').trim(), + organization: String(contact.organization || '').trim(), + position: String(contact.position || '').trim(), + notes: String(contact.notes || '').trim(), + } +} + +function escapeCsvField(value) { + const text = String(value ?? '') + if (/[",\n\r]/.test(text)) { + return `"${text.replace(/"/g, '""')}"` + } + return text +} + +export function contactsToCsv(contacts = []) { + const rows = contacts.map(contactToExportRow) + const lines = [CSV_HEADERS.join(',')] + for (const row of rows) { + lines.push(CSV_HEADERS.map((key) => escapeCsvField(row[key])).join(',')) + } + return `${lines.join('\n')}\n` +} + +export function contactsToJson(contacts = []) { + return JSON.stringify(contacts.map(contactToExportRow), null, 2) +} + +function escapeVcardValue(value) { + return String(value || '') + .replace(/\\/g, '\\\\') + .replace(/\r\n/g, '\n') + .replace(/\r/g, '\n') + .replace(/\n/g, '\\n') + .replace(/;/g, '\\;') + .replace(/,/g, '\\,') +} + +function vcardLine(key, value) { + if (!value) return null + return `${key}:${escapeVcardValue(value)}` +} + +function contactToVcard(contact) { + const row = contactToExportRow(contact) + if (!row.name) return null + + const lines = [ + 'BEGIN:VCARD', + 'VERSION:3.0', + vcardLine('FN', row.name), + vcardLine('EMAIL', row.email), + vcardLine('TEL', row.phone), + vcardLine('ORG', row.organization), + vcardLine('TITLE', row.position), + vcardLine('NOTE', row.notes), + 'END:VCARD', + ].filter(Boolean) + + return lines.join('\r\n') +} + +export function contactsToVcf(contacts = []) { + const cards = contacts.map(contactToVcard).filter(Boolean) + return cards.length ? `${cards.join('\r\n')}\r\n` : '' +} + +const EXPORT_FORMATS = { + csv: { + ext: 'csv', + mime: 'text/csv;charset=utf-8', + serialize: contactsToCsv, + }, + json: { + ext: 'json', + mime: 'application/json;charset=utf-8', + serialize: contactsToJson, + }, + vcf: { + ext: 'vcf', + mime: 'text/vcard;charset=utf-8', + serialize: contactsToVcf, + }, +} + +export function serializeContactsExport(contacts = [], format = 'csv') { + const config = EXPORT_FORMATS[format] || EXPORT_FORMATS.csv + return { + format, + filename: `contacts-export-${Date.now()}.${config.ext}`, + mime: config.mime, + content: config.serialize(contacts), + } +} diff --git a/frontend/src/lib/export/contacts.test.js b/frontend/src/lib/export/contacts.test.js new file mode 100644 index 0000000..0f09dba --- /dev/null +++ b/frontend/src/lib/export/contacts.test.js @@ -0,0 +1,74 @@ +import { describe, it, expect } from 'vitest' +import { + contactToExportRow, + contactsToCsv, + contactsToJson, + contactsToVcf, + serializeContactsExport, +} from './contacts' + +const sample = [ + { + name: 'Иван Иванов', + email: 'ivan@example.com', + phone: '+79000000001', + organization: 'ООО Ромашка', + position: 'Директор', + notes: 'Важный контакт', + }, + { + name: 'Петр, "Старший"', + email: '', + phone: '', + organization: 'Компания, ООО', + position: '', + notes: 'Строка\nс переносом', + }, +] + +describe('contacts export', () => { + it('normalizes contact fields', () => { + expect(contactToExportRow({ name: ' А ', email: null })).toEqual({ + name: 'А', + email: '', + phone: '', + organization: '', + position: '', + notes: '', + }) + }) + + it('exports CSV with escaped fields', () => { + const csv = contactsToCsv(sample) + expect(csv).toContain('name,email,phone,organization,position,notes') + expect(csv).toContain('Иван Иванов,ivan@example.com,+79000000001,ООО Ромашка,Директор,Важный контакт') + expect(csv).toContain('"Петр, ""Старший""",,"","Компания, ООО",,"Строка\nс переносом"') + }) + + it('exports JSON array compatible with import', () => { + const parsed = JSON.parse(contactsToJson(sample)) + expect(parsed).toHaveLength(2) + expect(parsed[0].name).toBe('Иван Иванов') + expect(parsed[1].organization).toBe('Компания, ООО') + }) + + it('exports vCard 3.0 cards', () => { + const vcf = contactsToVcf(sample) + expect(vcf).toContain('BEGIN:VCARD') + expect(vcf).toContain('VERSION:3.0') + expect(vcf).toContain('FN:Иван Иванов') + expect(vcf).toContain('EMAIL:ivan@example.com') + expect(vcf).toContain('ORG:ООО Ромашка') + expect(vcf).toContain('FN:Петр\\, "Старший"') + expect(vcf).toContain('ORG:Компания\\, ООО') + expect(vcf).toContain('NOTE:Строка\\nс переносом') + }) + + it('serializes selected format metadata', () => { + const result = serializeContactsExport(sample, 'vcf') + expect(result.format).toBe('vcf') + expect(result.filename).toMatch(/\.vcf$/) + expect(result.mime).toContain('vcard') + expect(result.content).toContain('END:VCARD') + }) +}) diff --git a/frontend/src/lib/graph/clusterColors.js b/frontend/src/lib/graph/clusterColors.js new file mode 100644 index 0000000..93b01b2 --- /dev/null +++ b/frontend/src/lib/graph/clusterColors.js @@ -0,0 +1,46 @@ +// Палитра для связных компонент графа (читаема на светлой и тёмной теме). +export const CLUSTER_PALETTE_LIGHT = [ + { bg: '#dce8fc', border: '#4a7ad9', highlight: '#b8cff5' }, + { bg: '#d4f5ea', border: '#3cb896', highlight: '#a8e8d4' }, + { bg: '#fde8d4', border: '#e0944a', highlight: '#f5d4b0' }, + { bg: '#f0d9f7', border: '#a86bc9', highlight: '#ddb8ef' }, + { bg: '#fce4ec', border: '#d96b8a', highlight: '#f5b8c8' }, + { bg: '#e0f2f4', border: '#4aabb8', highlight: '#b0dde4' }, + { bg: '#fef9dc', border: '#c9a83a', highlight: '#f5e8a8' }, + { bg: '#e8eaf6', border: '#6b74c9', highlight: '#c0c5ef' }, + { bg: '#e8f5e9', border: '#5a9e5c', highlight: '#b8dfb9' }, + { bg: '#fff3e0', border: '#c97f3a', highlight: '#f5d0a8' }, +] + +export const CLUSTER_PALETTE_DARK = [ + { bg: '#1e3354', border: '#5b8dee', highlight: '#2a4570' }, + { bg: '#1a3d34', border: '#4ecca3', highlight: '#255548' }, + { bg: '#3d2e1a', border: '#f4a261', highlight: '#524028' }, + { bg: '#352440', border: '#c49ae0', highlight: '#453055' }, + { bg: '#3d2430', border: '#e07a94', highlight: '#523040' }, + { bg: '#1a3538', border: '#5ec4d0', highlight: '#254548' }, + { bg: '#3d3818', border: '#d4b050', highlight: '#524a28' }, + { bg: '#252840', border: '#8890e0', highlight: '#323550' }, + { bg: '#1a3520', border: '#6ec072', highlight: '#254530' }, + { bg: '#3d3018', border: '#e0a050', highlight: '#524028' }, +] + +export const ISOLATE_CLUSTER_LIGHT = { + bg: '#f0f2f7', + border: '#a8afc4', + highlight: '#d8dce8', +} + +export const ISOLATE_CLUSTER_DARK = { + bg: '#2a2f42', + border: '#5a6078', + highlight: '#363c52', +} + +export function clusterColor(clusterIndex, isDark = false) { + if (clusterIndex == null || clusterIndex < 0) { + return isDark ? ISOLATE_CLUSTER_DARK : ISOLATE_CLUSTER_LIGHT + } + const palette = isDark ? CLUSTER_PALETTE_DARK : CLUSTER_PALETTE_LIGHT + return palette[clusterIndex % palette.length] +} diff --git a/frontend/src/lib/graph/clusters.js b/frontend/src/lib/graph/clusters.js new file mode 100644 index 0000000..4efb74b --- /dev/null +++ b/frontend/src/lib/graph/clusters.js @@ -0,0 +1,45 @@ +function find(parent, id) { + const sid = String(id) + if (!parent.has(sid)) parent.set(sid, sid) + if (parent.get(sid) !== sid) parent.set(sid, find(parent, parent.get(sid))) + return parent.get(sid) +} + +function union(parent, a, b) { + const ra = find(parent, a) + const rb = find(parent, b) + if (ra !== rb) parent.set(ra, rb) +} + +/** + * Возвращает Map. + * Связные компоненты из 2+ узлов получают уникальный индекс цвета, + * изолированные узлы — -1 (нейтральный цвет). + */ +export function computeClusterMap(nodes = [], edges = []) { + const parent = new Map() + for (const node of nodes) find(parent, node.id) + for (const edge of edges) union(parent, edge.from, edge.to) + + const componentSizes = new Map() + for (const node of nodes) { + const root = find(parent, node.id) + componentSizes.set(root, (componentSizes.get(root) || 0) + 1) + } + + const rootToCluster = new Map() + let nextCluster = 0 + for (const node of nodes) { + const root = find(parent, node.id) + if (rootToCluster.has(root)) continue + const size = componentSizes.get(root) || 1 + rootToCluster.set(root, size >= 2 ? nextCluster++ : -1) + } + + const map = new Map() + for (const node of nodes) { + const root = find(parent, node.id) + map.set(String(node.id), rootToCluster.get(root) ?? -1) + } + return map +} diff --git a/frontend/src/lib/graph/clusters.test.js b/frontend/src/lib/graph/clusters.test.js new file mode 100644 index 0000000..c3ebd1c --- /dev/null +++ b/frontend/src/lib/graph/clusters.test.js @@ -0,0 +1,34 @@ +import { describe, it, expect } from 'vitest' +import { computeClusterMap } from './clusters' + +describe('computeClusterMap', () => { + const nodes = [ + { id: 'a' }, + { id: 'b' }, + { id: 'c' }, + { id: 'd' }, + ] + + it('assigns same cluster to connected nodes', () => { + const map = computeClusterMap(nodes, [ + { from: 'a', to: 'b' }, + { from: 'b', to: 'c' }, + ]) + expect(map.get('a')).toBe(map.get('b')) + expect(map.get('b')).toBe(map.get('c')) + expect(map.get('a')).toBeGreaterThanOrEqual(0) + }) + + it('marks isolated nodes with -1', () => { + const map = computeClusterMap(nodes, [{ from: 'a', to: 'b' }]) + expect(map.get('d')).toBe(-1) + }) + + it('assigns different clusters to disconnected groups', () => { + const map = computeClusterMap(nodes, [ + { from: 'a', to: 'b' }, + { from: 'c', to: 'd' }, + ]) + expect(map.get('a')).not.toBe(map.get('c')) + }) +}) diff --git a/frontend/src/lib/import/vcard.js b/frontend/src/lib/import/vcard.js new file mode 100644 index 0000000..66208d7 --- /dev/null +++ b/frontend/src/lib/import/vcard.js @@ -0,0 +1,173 @@ +function joinContinuationLines(lines) { + const joined = [] + for (const line of lines) { + if ( + joined.length && + joined[joined.length - 1].endsWith('=') && + line && + !/^(BEGIN|END):/i.test(line.trim()) + ) { + joined[joined.length - 1] += line + } else { + joined.push(line) + } + } + return joined +} + +function normalizeVcardLines(text) { + return joinContinuationLines(unfoldLines(text)) +} + +function unfoldLines(text) { + const lines = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n') + const unfolded = [] + for (const line of lines) { + if (/^[ \t]/.test(line) && unfolded.length) { + unfolded[unfolded.length - 1] += line.slice(1) + } else { + unfolded.push(line) + } + } + return unfolded +} + +function decodeQuotedPrintable(value) { + const compact = String(value || '').replace(/=\r?\n/g, '').replace(/=$/, '') + const bytes = [] + for (let i = 0; i < compact.length; ) { + if (compact[i] === '=' && /^[0-9A-Fa-f]{2}/.test(compact.slice(i + 1, i + 3))) { + bytes.push(parseInt(compact.slice(i + 1, i + 3), 16)) + i += 3 + } else if (compact[i] === '=') { + i += 1 + } else { + bytes.push(compact.charCodeAt(i)) + i += 1 + } + } + return new TextDecoder('utf-8').decode(new Uint8Array(bytes)) +} + +function parsePropertyKey(rawKey) { + const parts = rawKey.split(';') + const name = (parts[0] || '').toUpperCase() + const params = {} + for (let i = 1; i < parts.length; i += 1) { + const segment = parts[i] + const eq = segment.indexOf('=') + if (eq === -1) continue + const key = segment.slice(0, eq).toUpperCase() + params[key] = segment.slice(eq + 1) + } + return { name, params } +} + +function unescapeValue(value) { + return String(value || '') + .replace(/\\n/gi, '\n') + .replace(/\\N/g, '\n') + .replace(/\\,/g, ',') + .replace(/\\;/g, ';') + .replace(/\\\\/g, '\\') + .trim() +} + +function decodePropertyValue(value, params) { + const encoding = String(params.ENCODING || '').toUpperCase() + if (encoding === 'QUOTED-PRINTABLE') { + return decodeQuotedPrintable(value).trim() + } + return unescapeValue(value) +} + +function stripMailto(value) { + return String(value || '').replace(/^mailto:/i, '').replace(/^tel:/i, '').trim() +} + +function pushValue(map, key, value) { + if (!value) return + if (!map[key]) map[key] = [] + map[key].push(value) +} + +function nameFromN(value) { + const parts = value.split(';') + const family = (parts[0] || '').trim() + const given = (parts[1] || '').trim() + const additional = (parts[2] || '').trim() + const suffix = (parts[4] || '').trim() + const fromStructured = [given, additional, family, suffix].filter(Boolean).join(' ').trim() + if (fromStructured) return fromStructured + return parts.map((p) => p.trim()).filter(Boolean).join(' ').trim() +} + +function nameFromCard(props) { + const fn = props.FN?.[0] + if (fn) return fn + const n = props.N?.[0] + if (n) return nameFromN(n) + return '' +} + +function first(props, key) { + return props[key]?.[0] || '' +} + +function cardToRow(props) { + const orgRaw = first(props, 'ORG') + const orgParts = orgRaw.split(';').map((p) => p.trim()).filter(Boolean) + const phones = (props.TEL || []).map(stripMailto).filter(Boolean) + const emails = (props.EMAIL || []).map(stripMailto).filter(Boolean) + const notes = [ + ...(props.NOTE || []), + ...(props.ADR || []).map((adr) => adr.split(';').map((p) => p.trim()).filter(Boolean).join(', ')), + ].filter(Boolean) + + return { + name: nameFromCard(props), + email: emails[0] || '', + phone: phones[0] || '', + organization: orgParts[0] || '', + position: first(props, 'TITLE'), + notes: notes.join('\n').trim(), + } +} + +export function parseVcf(text) { + const rows = [] + let props = null + + for (const line of normalizeVcardLines(text)) { + const trimmed = line.trim() + if (!trimmed) continue + + const upper = trimmed.toUpperCase() + if (upper === 'BEGIN:VCARD') { + props = {} + continue + } + if (upper === 'END:VCARD') { + if (props) rows.push(cardToRow(props)) + props = null + continue + } + if (!props) continue + + const colonIdx = trimmed.indexOf(':') + if (colonIdx === -1) continue + + const rawKey = trimmed.slice(0, colonIdx) + const value = trimmed.slice(colonIdx + 1) + const { name: key, params } = parsePropertyKey(rawKey) + if (!key) continue + + let decoded = decodePropertyValue(value, params) + if (['EMAIL', 'TEL', 'URL'].includes(key)) { + decoded = stripMailto(decoded) + } + pushValue(props, key, decoded) + } + + return rows +} diff --git a/frontend/src/lib/import/vcard.test.js b/frontend/src/lib/import/vcard.test.js new file mode 100644 index 0000000..c7486f1 --- /dev/null +++ b/frontend/src/lib/import/vcard.test.js @@ -0,0 +1,83 @@ +import { describe, it, expect } from 'vitest' +import { parseVcf } from './vcard' + +describe('parseVcf', () => { + it('parses a single vCard 3.0 contact', () => { + const text = `BEGIN:VCARD +VERSION:3.0 +FN:Иван Иванов +N:Иванов;Иван;;; +EMAIL;TYPE=INTERNET:ivan@example.com +TEL;TYPE=CELL:+79000000001 +ORG:ООО Ромашка;Отдел продаж +TITLE:Директор +NOTE:Знакомы с 2020 +END:VCARD` + + const rows = parseVcf(text) + expect(rows).toHaveLength(1) + expect(rows[0]).toEqual({ + name: 'Иван Иванов', + email: 'ivan@example.com', + phone: '+79000000001', + organization: 'ООО Ромашка', + position: 'Директор', + notes: 'Знакомы с 2020', + }) + }) + + it('parses vCard 2.1 username-only contacts', () => { + const text = `BEGIN:VCARD +VERSION:2.1 +N:;pumpkinelena;;; +FN:pumpkinelena +END:VCARD` + + const rows = parseVcf(text) + expect(rows[0].name).toBe('pumpkinelena') + }) + + it('decodes quoted-printable split across multiple lines', () => { + const text = `BEGIN:VCARD +VERSION:2.1 +FN;CHARSET=UTF-8;ENCODING=QUOTED-PRINTABLE:=D0=90=D1=80=D1=82=D0=B5=D0=BC=20=D0=91=D0=BE=D1=80=D0=BE=D0= +=B4=D0=B8=D0=BD +END:VCARD` + + const rows = parseVcf(text) + expect(rows[0].name).toBe('Артем Бородин') + }) + + it('decodes long quoted-printable FN with soft line breaks', () => { + const text = `BEGIN:VCARD +VERSION:2.1 +FN;CHARSET=UTF-8;ENCODING=QUOTED-PRINTABLE:=D0=90=D0=B1=D1=80=D0=B0=D0=BC=D0=B5=D0=BD=D0=BA=D0=BE +N;CHARSET=UTF-8;ENCODING=QUOTED-PRINTABLE:;=D0=90=D0=B1=D1=80=D0=B0=D0=BC=D0=B5=D0=BD=D0=BA=D0=BE;;; +TEL;CELL:89314045999 +END:VCARD` + + const rows = parseVcf(text) + expect(rows[0].name).toBe('Абраменко') + expect(rows[0].phone).toBe('89314045999') + }) + + it('parses multiple vCards and unfolded NOTE lines', () => { + const text = `BEGIN:VCARD +VERSION:3.0 +FN:Мария Сидорова +EMAIL:maria@example.com +NOTE:Первая строка + вторая строка +END:VCARD +BEGIN:VCARD +VERSION:3.0 +N:Петров;Пётр;;; +TEL:+79001112233 +END:VCARD` + + const rows = parseVcf(text) + expect(rows).toHaveLength(2) + expect(rows[0].name).toBe('Мария Сидорова') + expect(rows[1].name).toBe('Пётр Петров') + }) +}) diff --git a/frontend/src/lib/uuid.js b/frontend/src/lib/uuid.js new file mode 100644 index 0000000..b55a9c6 --- /dev/null +++ b/frontend/src/lib/uuid.js @@ -0,0 +1,10 @@ +export function generateId() { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID() + } + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0 + const v = c === 'x' ? r : (r & 0x3) | 0x8 + return v.toString(16) + }) +} diff --git a/frontend/src/lib/uuid.test.js b/frontend/src/lib/uuid.test.js new file mode 100644 index 0000000..1f215aa --- /dev/null +++ b/frontend/src/lib/uuid.test.js @@ -0,0 +1,11 @@ +import { describe, it, expect } from 'vitest' +import { generateId } from './uuid' + +describe('generateId', () => { + it('returns uuid-like string', () => { + const id = generateId() + expect(id).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + ) + }) +}) diff --git a/frontend/src/stores/contacts.js b/frontend/src/stores/contacts.js index 0bb29e1..f9fe67d 100644 --- a/frontend/src/stores/contacts.js +++ b/frontend/src/stores/contacts.js @@ -5,6 +5,7 @@ import { createContact as createContactUseCase, updateContact as updateContactUseCase, deleteContact as deleteContactUseCase, + deleteContacts as deleteContactsUseCase, } from '../application/usecases/contacts' import { listRelations, @@ -17,6 +18,7 @@ import { } from '../infrastructure/repositories/repositoryFactory' import { importContactsFromFile, + exportContacts as exportContactsUseCase, exportLocalData, importLocalDump, } from '../application/usecases/importExport' @@ -121,8 +123,25 @@ export const useContactsStore = defineStore('contacts', { async deleteContact(id) { return this.withLoading('contactsLoading', async () => { await deleteContactUseCase(id) - this.contacts = this.contacts.filter((c) => c.id !== id) - this.relations = this.relations.filter((r) => r.source !== id && r.target !== id) + const sid = String(id) + this.contacts = this.contacts.filter((c) => String(c.id) !== sid) + this.relations = this.relations.filter( + (r) => String(r.source) !== sid && String(r.target) !== sid + ) + await syncPendingChanges() + }) + }, + + async deleteContacts(ids = []) { + return this.withLoading('contactsLoading', async () => { + const uniqueIds = [...new Set(ids.map(String))] + if (!uniqueIds.length) return + await deleteContactsUseCase(uniqueIds) + const idSet = new Set(uniqueIds) + this.contacts = this.contacts.filter((c) => !idSet.has(String(c.id))) + this.relations = this.relations.filter( + (r) => !idSet.has(String(r.source)) && !idSet.has(String(r.target)) + ) await syncPendingChanges() }) }, @@ -131,7 +150,15 @@ export const useContactsStore = defineStore('contacts', { return this.withLoading('relationsLoading', async () => { const data = await createRelationUseCase(payload) this.relations.push(data) - await this.fetchContacts() + const sid = String(data.source) + const tid = String(data.target) + this.contacts = this.contacts.map((c) => { + const id = String(c.id) + if (id === sid || id === tid) { + return { ...c, relations_count: Number(c.relations_count || 0) + 1 } + } + return c + }) await syncPendingChanges() return data }) @@ -155,6 +182,10 @@ export const useContactsStore = defineStore('contacts', { }) }, + async exportContacts(format = 'csv') { + return exportContactsUseCase({ format }) + }, + async exportData(passphrase = '') { return exportLocalData({ passphrase }) }, diff --git a/frontend/src/views/ContactsView.vue b/frontend/src/views/ContactsView.vue index aaabdaa..78bf446 100644 --- a/frontend/src/views/ContactsView.vue +++ b/frontend/src/views/ContactsView.vue @@ -2,16 +2,25 @@
- +
{{ store.error }}
+ + +
@@ -36,6 +53,15 @@ + @@ -44,7 +70,23 @@ - + +
+ + Имя Организация Email
+ +
{{ c.name }}
{{ c.position }}
@@ -64,7 +106,6 @@ -