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
@@ -42,3 +42,9 @@ export async function deleteContact(id) {
payloadPatch: {},
})
}
export async function deleteContacts(ids = []) {
for (const id of ids) {
await deleteContact(id)
}
}
+1 -1
View File
@@ -16,7 +16,7 @@ function nodeFromContact(c) {
}
}
function edgeFromRelation(r) {
export function edgeFromRelation(r) {
return {
id: r.id,
from: r.source,
@@ -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) => {
@@ -0,0 +1,142 @@
<template>
<div v-if="open" class="modal-overlay" @click.self="onCancel">
<div class="modal">
<div class="modal-header">
<h3>Добавить связь</h3>
<button class="btn btn-secondary btn-sm" type="button" @click="onCancel"></button>
</div>
<div v-if="error" class="alert alert-error">{{ error }}</div>
<p class="text-muted relation-ends">
<strong style="color:var(--text)">{{ sourceName }}</strong>
<span class="relation-arrow"></span>
<strong style="color:var(--text)">{{ targetName }}</strong>
</p>
<button class="btn btn-secondary btn-sm swap-btn" type="button" @click="swapEnds">
Поменять направление
</button>
<div class="form-group">
<label>Тип связи</label>
<SearchableSelect
v-model="form.type"
:options="relationTypes"
placeholder="Тип связи..."
/>
</div>
<div class="form-group">
<label>Интенсивность общения</label>
<SearchableSelect
v-model="form.intensity"
:options="interactionIntensities"
placeholder="Интенсивность..."
/>
</div>
<div class="form-group">
<label>Описание (необязательно)</label>
<input v-model="form.description" class="form-control" placeholder="Например: знакомы с 2018 года" />
</div>
<div class="modal-footer">
<button class="btn btn-secondary" type="button" @click="onCancel">Отмена</button>
<button class="btn btn-primary" type="button" :disabled="saving" @click="submit">
{{ saving ? 'Создание...' : 'Создать связь' }}
</button>
</div>
</div>
</div>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { useContactsStore } from '../stores/contacts'
import SearchableSelect from './SearchableSelect.vue'
import { RELATION_TYPES, INTERACTION_INTENSITIES } from '../domain/networkChoices'
const props = defineProps({
open: { type: Boolean, default: false },
source: { type: Object, default: null },
target: { type: Object, default: null },
})
const emit = defineEmits(['close', 'created'])
const store = useContactsStore()
const saving = ref(false)
const error = ref('')
const swapped = ref(false)
const relationTypes = RELATION_TYPES
const interactionIntensities = INTERACTION_INTENSITIES
const form = ref({
type: 'acquaintance',
intensity: 'intense',
description: '',
})
const sourceContact = computed(() => (swapped.value ? props.target : props.source))
const targetContact = computed(() => (swapped.value ? props.source : props.target))
const sourceName = computed(() => sourceContact.value?.name || '—')
const targetName = computed(() => targetContact.value?.name || '—')
watch(
() => props.open,
(isOpen) => {
if (!isOpen) return
swapped.value = false
error.value = ''
form.value = {
type: 'acquaintance',
intensity: 'intense',
description: '',
}
}
)
function swapEnds() {
swapped.value = !swapped.value
}
function onCancel() {
emit('close')
}
async function submit() {
if (!sourceContact.value?.id || !targetContact.value?.id) return
saving.value = true
error.value = ''
try {
const created = await store.createRelation({
source: sourceContact.value.id,
target: targetContact.value.id,
relation_type: form.value.type,
description: form.value.description,
interaction_intensity: form.value.intensity,
})
emit('created', created)
emit('close')
} catch (e) {
error.value = e?.message || String(e)
} finally {
saving.value = false
}
}
</script>
<style scoped>
.relation-ends {
margin: 0 0 10px;
line-height: 1.5;
}
.relation-arrow {
margin: 0 8px;
color: var(--text-muted);
}
.swap-btn {
margin-bottom: 16px;
}
</style>
@@ -0,0 +1,121 @@
<template>
<Teleport to="body">
<div
v-if="open"
class="graph-node-menu-overlay"
@click="close"
@contextmenu.prevent="close"
/>
<div
v-if="open && node"
class="graph-node-menu"
:style="{ left: `${x}px`, top: `${y}px` }"
role="menu"
@click.stop
@contextmenu.prevent
>
<div class="graph-node-menu__title">{{ nodeLabel }}</div>
<button type="button" class="graph-node-menu__item" role="menuitem" @click="onInfo">
Информация
</button>
<RouterLink
:to="`/contacts/${node.id}`"
class="graph-node-menu__item graph-node-menu__link"
role="menuitem"
@click="close"
>
Открыть карточку
</RouterLink>
</div>
</Teleport>
</template>
<script setup>
import { computed, onMounted, onUnmounted, watch } from 'vue'
import { RouterLink } from 'vue-router'
const props = defineProps({
open: { type: Boolean, default: false },
node: { type: Object, default: null },
x: { type: Number, default: 0 },
y: { type: Number, default: 0 },
})
const emit = defineEmits(['close', 'info'])
const nodeLabel = computed(() => props.node?.label || props.node?.name || '')
function close() {
emit('close')
}
function onInfo() {
emit('info', props.node)
close()
}
function onKeyDown(event) {
if (event.key === 'Escape' && props.open) close()
}
watch(() => props.open, (isOpen) => {
if (isOpen) window.addEventListener('keydown', onKeyDown)
else window.removeEventListener('keydown', onKeyDown)
})
onMounted(() => {
if (props.open) window.addEventListener('keydown', onKeyDown)
})
onUnmounted(() => {
window.removeEventListener('keydown', onKeyDown)
})
</script>
<style scoped>
.graph-node-menu-overlay {
position: fixed;
inset: 0;
z-index: 900;
}
.graph-node-menu {
position: fixed;
z-index: 901;
min-width: 180px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
box-shadow: var(--shadow);
padding: 6px 0;
}
.graph-node-menu__title {
padding: 6px 14px 8px;
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
border-bottom: 1px solid var(--border);
margin-bottom: 4px;
max-width: 240px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.graph-node-menu__item {
display: block;
width: 100%;
text-align: left;
padding: 8px 14px;
font-size: 13px;
color: var(--text);
background: transparent;
border: none;
cursor: pointer;
text-decoration: none;
}
.graph-node-menu__item:hover {
background: var(--accent-dim);
}
.graph-node-menu__link {
color: var(--text);
}
</style>
@@ -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,
}
}
@@ -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,
}
}
@@ -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,
})
)
)
},
}
@@ -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,
@@ -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,
+100
View File
@@ -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),
}
}
+74
View File
@@ -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')
})
})
+46
View File
@@ -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]
}
+45
View File
@@ -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<nodeId, clusterIndex>.
* Связные компоненты из 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
}
+34
View File
@@ -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'))
})
})
+173
View File
@@ -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
}
+83
View File
@@ -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('Пётр Петров')
})
})
+10
View File
@@ -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)
})
}
+11
View File
@@ -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
)
})
})
+34 -3
View File
@@ -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 })
},
+210 -16
View File
@@ -2,16 +2,25 @@
<div>
<div class="page-header">
<h2>Контакты</h2>
<button class="btn btn-primary" @click="showCreate = true">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
Добавить
</button>
<div class="page-header__actions">
<button
v-if="selectedCount > 0"
class="btn btn-danger"
@click="confirmBulkDelete"
>
Удалить выбранные ({{ selectedCount }})
</button>
<button class="btn btn-primary" @click="showCreate = true">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
Добавить
</button>
</div>
</div>
<div class="page-content">
<!-- Search -->
<div v-if="store.error" class="alert alert-error">{{ store.error }}</div>
<div class="search-bar">
<svg class="search-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
@@ -24,6 +33,14 @@
/>
</div>
<div v-if="linkSelectionCount === 1" class="alert alert-info link-hint">
Выбран: <strong>{{ linkSelection[0].name }}</strong>.
Удерживайте Ctrl ( на Mac) и кликните по второму контакту.
</div>
<p v-else class="text-muted link-hint link-hint--static">
Ctrl+клик (+клик на Mac) по двум контактам создать связь.
</p>
<div v-if="store.loading" class="spinner"></div>
<div v-else-if="store.contacts.length === 0" class="empty-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
@@ -36,6 +53,15 @@
<table class="table">
<thead>
<tr>
<th class="col-check">
<input
type="checkbox"
:checked="allSelected"
:indeterminate="someSelected && !allSelected"
aria-label="Выбрать все"
@click.stop.prevent="toggleSelectAll"
/>
</th>
<th>Имя</th>
<th>Организация</th>
<th>Email</th>
@@ -44,7 +70,23 @@
</tr>
</thead>
<tbody>
<tr v-for="c in store.contacts" :key="c.id" @click="goTo(c.id)">
<tr
v-for="c in store.contacts"
:key="c.id"
:class="{
'is-selected': isSelected(c.id),
'is-link-selected': isLinkSelected(c.id),
}"
@click="onRowClick(c, $event)"
>
<td class="col-check" @click.stop>
<input
type="checkbox"
:checked="isSelected(c.id)"
:aria-label="`Выбрать ${c.name}`"
@click.stop.prevent="toggleSelect(c.id)"
/>
</td>
<td>
<div style="font-weight:500;">{{ c.name }}</div>
<div class="text-muted mt-1">{{ c.position }}</div>
@@ -64,7 +106,6 @@
</div>
</div>
<!-- Create modal -->
<div v-if="showCreate" class="modal-overlay" @click.self="showCreate = false">
<div class="modal">
<div class="modal-header">
@@ -75,7 +116,6 @@
</div>
</div>
<!-- Edit modal -->
<div v-if="editTarget" class="modal-overlay" @click.self="editTarget = null">
<div class="modal">
<div class="modal-header">
@@ -86,7 +126,6 @@
</div>
</div>
<!-- Delete confirm -->
<div v-if="deleteTarget" class="modal-overlay" @click.self="deleteTarget = null">
<div class="modal">
<div class="modal-header">
@@ -95,18 +134,47 @@
<p class="text-muted">Будет удалён контакт <strong style="color:var(--text)">{{ deleteTarget.name }}</strong> и все его связи.</p>
<div class="modal-footer">
<button class="btn btn-secondary" @click="deleteTarget = null">Отмена</button>
<button class="btn btn-danger" @click="doDelete">Удалить</button>
<button class="btn btn-danger" :disabled="deleting" @click="doDelete">
{{ deleting ? 'Удаление...' : 'Удалить' }}
</button>
</div>
</div>
</div>
<div v-if="bulkDeleteOpen" class="modal-overlay" @click.self="bulkDeleteOpen = false">
<div class="modal">
<div class="modal-header">
<h3>Удалить выбранные контакты?</h3>
</div>
<p class="text-muted">
Будет удалено контактов: <strong style="color:var(--text)">{{ selectedCount }}</strong>
и все их связи.
</p>
<div class="modal-footer">
<button class="btn btn-secondary" @click="bulkDeleteOpen = false">Отмена</button>
<button class="btn btn-danger" :disabled="bulkDeleting" @click="doBulkDelete">
{{ bulkDeleting ? 'Удаление...' : 'Удалить' }}
</button>
</div>
</div>
</div>
<CreateRelationModal
:open="relationModalOpen"
:source="relationPair?.[0]"
:target="relationPair?.[1]"
@close="closeRelationModal"
@created="onRelationCreated"
/>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useContactsStore } from '../stores/contacts'
import { useCtrlLinkSelection } from '../composables/useCtrlLinkSelection'
import ContactForm from '../components/ContactForm.vue'
import CreateRelationModal from '../components/CreateRelationModal.vue'
const store = useContactsStore()
const router = useRouter()
@@ -114,11 +182,85 @@ const search = ref('')
const showCreate = ref(false)
const editTarget = ref(null)
const deleteTarget = ref(null)
const bulkDeleteOpen = ref(false)
const bulkDeleting = ref(false)
const deleting = ref(false)
const selectedIds = ref([])
const relationModalOpen = ref(false)
const relationPair = ref(null)
const {
linkSelection,
linkSelectionCount,
isLinkSelected,
clearLinkSelection,
handleCtrlPick,
} = useCtrlLinkSelection({
onPairSelected(pair) {
relationPair.value = pair
relationModalOpen.value = true
},
})
const selectedCount = computed(() => selectedIds.value.length)
const allSelected = computed(() =>
store.contacts.length > 0 && store.contacts.every((c) => isSelected(c.id))
)
const someSelected = computed(() => selectedCount.value > 0)
function isSelected(id) {
const sid = String(id)
return selectedIds.value.includes(sid)
}
function toggleSelect(id) {
const sid = String(id)
if (selectedIds.value.includes(sid)) {
selectedIds.value = selectedIds.value.filter((item) => item !== sid)
} else {
selectedIds.value = [...selectedIds.value, sid]
}
}
function toggleSelectAll() {
if (allSelected.value) {
selectedIds.value = []
} else {
selectedIds.value = store.contacts.map((c) => String(c.id))
}
}
function pruneSelection() {
const visible = new Set(store.contacts.map((c) => String(c.id)))
selectedIds.value = selectedIds.value.filter((id) => visible.has(id))
}
let searchTimer = null
function onSearch() {
clearTimeout(searchTimer)
searchTimer = setTimeout(() => store.fetchContacts(search.value), 300)
searchTimer = setTimeout(async () => {
await store.fetchContacts(search.value)
pruneSelection()
}, 300)
}
function onRowClick(c, event) {
if (handleCtrlPick(c, event)) return
goTo(c.id)
}
function closeRelationModal() {
relationModalOpen.value = false
relationPair.value = null
clearLinkSelection()
}
function onRelationCreated() {
relationModalOpen.value = false
relationPair.value = null
clearLinkSelection()
}
function goTo(id) { router.push(`/contacts/${id}`) }
@@ -140,7 +282,59 @@ async function onUpdate(data) {
function confirmDelete(c) { deleteTarget.value = c }
async function doDelete() {
await store.deleteContact(deleteTarget.value.id)
deleteTarget.value = null
deleting.value = true
try {
await store.deleteContact(deleteTarget.value.id)
selectedIds.value = selectedIds.value.filter(
(id) => id !== String(deleteTarget.value.id)
)
deleteTarget.value = null
} finally {
deleting.value = false
}
}
function confirmBulkDelete() {
bulkDeleteOpen.value = true
}
async function doBulkDelete() {
bulkDeleting.value = true
try {
await store.deleteContacts([...selectedIds.value])
selectedIds.value = []
bulkDeleteOpen.value = false
} finally {
bulkDeleting.value = false
}
}
</script>
<style scoped>
.page-header__actions {
display: flex;
align-items: center;
gap: 8px;
}
.col-check {
width: 40px;
text-align: center;
}
.col-check input[type='checkbox'] {
cursor: pointer;
}
tr.is-selected {
background: color-mix(in srgb, var(--accent) 8%, transparent);
}
tr.is-link-selected {
background: color-mix(in srgb, var(--green) 12%, transparent);
box-shadow: inset 3px 0 0 var(--green);
}
.link-hint {
font-size: 12px;
margin-bottom: 12px;
}
.link-hint--static {
margin: 0 0 12px;
}
</style>
+223 -53
View File
@@ -9,6 +9,12 @@
/>
<div class="graph-view-toolbar">
<p class="graph-link-hint text-muted">
Ctrl+клик (+клик) по двум узлам создать связь.
<span v-if="linkSelectionCount === 1">
Выбран: <strong>{{ linkSelection[0].name }}</strong>.
</span>
</p>
<RelationTypeFilters
:relation-types="allRelationTypes"
:active-values="activeFilters"
@@ -61,18 +67,59 @@
</div>
</div>
</div>
<GraphNodeContextMenu
:open="contextMenuOpen"
:node="contextMenuNode"
:x="contextMenuX"
:y="contextMenuY"
@close="closeContextMenu"
@info="openNodeInfo"
/>
<CreateRelationModal
:open="relationModalOpen"
:source="relationPair?.[0]"
:target="relationPair?.[1]"
@close="closeRelationModal"
@created="onRelationCreated"
/>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
import { RouterLink } from 'vue-router'
import { Network, DataSet } from 'vis-network/standalone'
import { useContactsStore } from '../stores/contacts'
import { useCtrlLinkSelection } from '../composables/useCtrlLinkSelection'
import { RELATION_COLORS } from '../lib/graph/relationColors'
import { clusterColor } from '../lib/graph/clusterColors'
import { computeClusterMap } from '../lib/graph/clusters'
import { RELATION_TYPES } from '../domain/networkChoices'
import { fetchGraphBundle } from '../composables/useGraphData'
import { edgeFromRelation } from '../application/usecases/graph'
import GraphHeaderPanel from '../components/GraphHeaderPanel.vue'
import RelationTypeFilters from '../components/RelationTypeFilters.vue'
import CreateRelationModal from '../components/CreateRelationModal.vue'
import GraphNodeContextMenu from '../components/GraphNodeContextMenu.vue'
import { useGraphNodeContextMenu } from '../composables/useGraphNodeContextMenu.js'
let themeObserver = null
let detachContextHandler = null
const {
contextMenuOpen,
contextMenuNode,
contextMenuX,
contextMenuY,
closeContextMenu,
attachNodeContextHandlers,
} = useGraphNodeContextMenu()
function openNodeInfo(node) {
selectedNode.value = node || null
}
const store = useContactsStore()
const graphContainer = ref(null)
@@ -80,6 +127,20 @@ const loading = ref(true)
const network = ref(null)
const physicsEnabled = ref(true)
const selectedNode = ref(null)
const relationModalOpen = ref(false)
const relationPair = ref(null)
const {
linkSelection,
linkSelectionCount,
clearLinkSelection,
handleCtrlPickNode,
} = useCtrlLinkSelection({
onPairSelected(pair) {
relationPair.value = pair
relationModalOpen.value = true
},
})
let nodesDS = null
let edgesDS = null
@@ -87,12 +148,14 @@ let initRetryCount = 0
const INIT_RETRY_MAX = 40
let initRetryTimer = null
let resizeObserver = null
let themeObserver = null
const nodes = ref([])
const edges = ref([])
const allRelationTypes = ref([])
const activeFilters = ref([])
const clusterMap = ref(new Map())
const relationTypeLabels = Object.fromEntries(RELATION_TYPES.map((r) => [r.value, r.label]))
const selectedContact = computed(() =>
selectedNode.value ? store.contactById(selectedNode.value.id) : null
@@ -107,6 +170,7 @@ function graphPalette() {
return {
nodeBackground: cssVar('--surface-alt', '#22263a'),
nodeBorder: cssVar('--accent', '#5b8dee'),
nodeLinkBorder: cssVar('--green', '#4ecca3'),
nodeHighlightBackground: cssVar('--surface', '#1a1d27'),
nodeHighlightBorder: cssVar('--accent-hover', '#7aa5f5'),
nodeFont: cssVar('--text', '#e2e6f3'),
@@ -114,6 +178,130 @@ function graphPalette() {
}
}
function recomputeClusters() {
clusterMap.value = computeClusterMap(nodes.value, filteredEdges())
}
function nodeDegree(id) {
const sid = String(id)
return filteredEdges().filter(
(e) => String(e.from) === sid || String(e.to) === sid
).length
}
function isDarkTheme() {
return document.documentElement.getAttribute('data-theme') === 'dark'
}
function nodeVisColor(nodeId, linkIds) {
const sid = String(nodeId)
const palette = graphPalette()
if (linkIds.has(sid)) {
return {
background: palette.nodeBackground,
border: palette.nodeLinkBorder,
highlight: {
background: palette.nodeHighlightBackground,
border: palette.nodeHighlightBorder,
},
}
}
const cc = clusterColor(clusterMap.value.get(sid), isDarkTheme())
return {
background: cc.bg,
border: cc.border,
highlight: { background: cc.highlight, border: cc.border },
}
}
function mapGraphNodeToVis(n, linkIds = new Set()) {
const palette = graphPalette()
const degree = nodeDegree(n.id)
return {
id: String(n.id),
label: n.label || String(n.id),
title: n.title,
color: nodeVisColor(n.id, linkIds),
font: { color: palette.nodeFont, size: degree > 0 ? 13 : 11 },
shape: 'dot',
size: degree > 0 ? 12 + Math.min(degree, 6) * 2 : 9,
borderWidth: linkIds.has(String(n.id)) ? 3 : 2,
}
}
function refreshNodeStyles() {
if (!nodesDS) return
const linkIds = new Set(linkSelection.value.map((item) => String(item.id)))
nodesDS.update(nodes.value.map((n) => mapGraphNodeToVis(n, linkIds)))
network.value?.redraw()
}
function applyLinkHighlights() {
refreshNodeStyles()
}
function mapGraphEdgeToVis(e) {
const palette = graphPalette()
const rc = RELATION_COLORS[e.relation_type] || RELATION_COLORS.other
const typeLabel = relationTypeLabels[e.relation_type] || e.relation_type
const tip = [typeLabel, e.title !== e.relation_type ? e.title : ''].filter(Boolean).join(' — ')
return {
id: String(e.id),
from: String(e.from),
to: String(e.to),
title: tip || typeLabel,
relation_type: e.relation_type,
color: { color: rc.color, highlight: rc.highlight, opacity: 0.8 },
width: e.interaction_intensity === 'sparse' ? 1 : 2,
dashes: e.interaction_intensity === 'sparse' ? [6, 4] : false,
font: { color: palette.edgeFont, size: 10, align: 'middle' },
arrows: { to: { enabled: false } },
smooth: { type: 'dynamic' },
}
}
function appendRelationEdge(relation) {
if (!relation) return
const edge = edgeFromRelation(relation)
if (!edges.value.some((e) => String(e.id) === String(edge.id))) {
edges.value.push(edge)
}
if (!edgesDS) return
const nodeIds = new Set(nodes.value.map((n) => String(n.id)))
if (!nodeIds.has(String(edge.from)) || !nodeIds.has(String(edge.to))) return
if (
activeFilters.value.length < allRelationTypes.value.length &&
!activeFilters.value.includes(edge.relation_type)
) {
return
}
if (!edgesDS.get(String(edge.id))) {
edgesDS.add(mapGraphEdgeToVis(edge))
}
recomputeClusters()
refreshNodeStyles()
}
function closeRelationModal() {
relationModalOpen.value = false
relationPair.value = null
clearLinkSelection()
applyLinkHighlights()
}
function onRelationCreated(relation) {
relationModalOpen.value = false
relationPair.value = null
clearLinkSelection()
applyLinkHighlights()
appendRelationEdge(relation)
}
watch(linkSelection, () => {
applyLinkHighlights()
}, { deep: true })
async function loadGraph() {
loading.value = true
try {
@@ -127,6 +315,7 @@ async function loadGraph() {
}
// Контейнер #graph-container в DOM только когда loading=false и nodes.length > 0
if (nodes.value.length > 0) {
recomputeClusters()
await nextTick()
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))
initNetwork()
@@ -165,33 +354,9 @@ function initNetwork() {
initRetryTimer = null
}
const palette = graphPalette()
const nodeList = nodes.value.map((n) => ({
id: String(n.id),
label: n.label || String(n.id),
title: n.title,
...(n.group != null && { group: n.group }),
color: {
background: palette.nodeBackground,
border: palette.nodeBorder,
highlight: { background: palette.nodeHighlightBackground, border: palette.nodeHighlightBorder },
},
font: { color: palette.nodeFont, size: 13 },
shape: 'dot',
size: 14,
}))
const edgeList = filteredEdges().map((e) => ({
id: String(e.id),
from: String(e.from),
to: String(e.to),
label: e.label,
title: e.title,
relation_type: e.relation_type,
color: RELATION_COLORS[e.relation_type] || RELATION_COLORS.other,
font: { color: palette.edgeFont, size: 10, align: 'middle' },
arrows: { to: { enabled: false } },
smooth: { type: 'curvedCW', roundness: 0.1 },
}))
const linkIds = new Set(linkSelection.value.map((item) => String(item.id)))
const nodeList = nodes.value.map((n) => mapGraphNodeToVis(n, linkIds))
const edgeList = filteredEdges().map(mapGraphEdgeToVis)
nodesDS = new DataSet(nodeList)
edgesDS = new DataSet(edgeList)
@@ -201,27 +366,43 @@ function initNetwork() {
{
physics: {
enabled: true,
stabilization: { iterations: 150 },
barnesHut: { gravitationalConstant: -3000, springLength: 180 },
stabilization: { iterations: 250, fit: true },
barnesHut: {
gravitationalConstant: -12000,
centralGravity: 0.15,
springLength: 220,
springConstant: 0.035,
damping: 0.12,
avoidOverlap: 0.25,
},
},
interaction: {
tooltipDelay: 200,
hover: true,
hideEdgesOnDrag: true,
zoomView: true,
dragView: true,
dragNodes: true,
},
nodes: { borderWidth: 1.5 },
}
)
network.value.on('click', (params) => {
closeContextMenu()
if (params.nodes.length > 0) {
const id = params.nodes[0]
const node = nodes.value.find((n) => String(n.id) === id)
selectedNode.value = node || null
const domEvent = params.event?.srcEvent || params.event
if (domEvent && (domEvent.ctrlKey || domEvent.metaKey)) {
handleCtrlPickNode(node, domEvent)
}
}
})
detachContextHandler?.()
detachContextHandler = attachNodeContextHandlers(network.value, () => nodes.value)
network.value.once('stabilizationIterationsDone', () => {
network.value?.fit({ animation: { duration: 400 } })
setTimeout(() => {
@@ -250,34 +431,17 @@ function toggleFilter(type) {
activeFilters.value.push(type)
}
if (edgesDS) {
const palette = graphPalette()
recomputeClusters()
edgesDS.clear()
edgesDS.add(
filteredEdges().map((e) => ({
...e,
color: RELATION_COLORS[e.relation_type] || RELATION_COLORS.other,
font: { color: palette.edgeFont, size: 10, align: 'middle' },
arrows: { to: { enabled: false } },
smooth: { type: 'curvedCW', roundness: 0.1 },
}))
)
edgesDS.add(filteredEdges().map(mapGraphEdgeToVis))
refreshNodeStyles()
}
}
function applyThemeToNetwork() {
if (!nodesDS || !edgesDS || !network.value) return
refreshNodeStyles()
const palette = graphPalette()
nodesDS.update(
nodes.value.map((n) => ({
id: String(n.id),
color: {
background: palette.nodeBackground,
border: palette.nodeBorder,
highlight: { background: palette.nodeHighlightBackground, border: palette.nodeHighlightBorder },
},
font: { color: palette.nodeFont, size: 13 },
}))
)
edgesDS.update(
filteredEdges().map((e) => ({
id: String(e.id),
@@ -303,6 +467,8 @@ onMounted(() => {
})
onUnmounted(() => {
if (initRetryTimer) clearTimeout(initRetryTimer)
detachContextHandler?.()
closeContextMenu()
resizeObserver?.disconnect()
themeObserver?.disconnect()
network.value?.destroy()
@@ -321,6 +487,10 @@ onUnmounted(() => {
flex-shrink: 0;
padding: 0 28px 10px;
}
.graph-link-hint {
font-size: 12px;
margin: 0 0 8px;
}
.graph-area {
flex: 1;
min-height: 0;
+81 -3
View File
@@ -1,13 +1,13 @@
<template>
<div>
<div class="page-header">
<h2>Импорт контактов</h2>
<h2>Импорт и экспорт</h2>
</div>
<div class="page-content content-narrow">
<div class="card">
<h3 class="section-title">Загрузить файл</h3>
<p class="text-muted section-subtitle">
Поддерживаются форматы <strong style="color:var(--text)">CSV</strong> и <strong style="color:var(--text)">JSON</strong>.
Поддерживаются форматы <strong style="color:var(--text)">CSV</strong>, <strong style="color:var(--text)">JSON</strong> и <strong style="color:var(--text)">vCard (.vcf)</strong>.
</p>
<!-- Format examples -->
@@ -17,6 +17,13 @@
Иван Иванов,ivan@example.com,+7-900-000-0001,ООО Ромашка,Директор,</pre>
<div style="font-size:12px;color:var(--text-muted);margin:12px 0 8px;">Пример JSON:</div>
<pre style="font-size:12px;color:var(--green);overflow-x:auto;">[{"name":"Иван Иванов","email":"ivan@example.com","organization":"ООО Ромашка"}]</pre>
<div style="font-size:12px;color:var(--text-muted);margin:12px 0 8px;">Пример vCard (.vcf):</div>
<pre style="font-size:12px;color:var(--green);overflow-x:auto;">BEGIN:VCARD
FN:Иван Иванов
EMAIL:ivan@example.com
TEL:+79000000001
ORG:ООО Ромашка
END:VCARD</pre>
</div>
<!-- Drop zone -->
@@ -36,7 +43,7 @@
<div style="font-size:13px;color:var(--text-muted);">
{{ selectedFile ? selectedFile.name : 'Перетащите файл или нажмите для выбора' }}
</div>
<input ref="fileInput" type="file" accept=".csv,.json" style="display:none" @change="onFileSelect" />
<input ref="fileInput" type="file" accept=".csv,.json,.vcf,.vcard" style="display:none" @change="onFileSelect" />
</div>
<div v-if="result" class="alert" :class="result.error ? 'alert-error' : 'alert-success'" style="margin-top:16px;">
@@ -67,6 +74,41 @@
<button v-if="selectedFile" class="btn btn-secondary" style="margin-left:8px;" @click="reset">Сбросить</button>
</div>
<hr style="margin:18px 0;border:none;border-top:1px solid var(--border);" />
<h3 class="section-title">Экспорт контактов</h3>
<p class="text-muted section-subtitle">
Скачать все контакты в выбранном формате: <strong style="color:var(--text)">CSV</strong>,
<strong style="color:var(--text)">JSON</strong> или <strong style="color:var(--text)">vCard (.vcf)</strong>
совместимо с Nextcloud и другими адресными книгами.
</p>
<div class="export-row">
<div class="form-group" style="margin-bottom:0;flex:1;">
<label for="export-format">Формат файла</label>
<select id="export-format" v-model="exportFormat" class="form-control">
<option value="csv">CSV (.csv)</option>
<option value="json">JSON (.json)</option>
<option value="vcf">vCard (.vcf)</option>
</select>
</div>
<button
class="btn btn-primary"
:disabled="exporting || store.totalContacts === 0"
@click="doExportContacts"
>
{{ exporting ? 'Экспорт...' : 'Экспортировать' }}
</button>
</div>
<p v-if="store.totalContacts === 0" class="text-muted" style="font-size:12px;margin-top:8px;">
Нет контактов для экспорта.
</p>
<div v-if="exportResult" class="alert" :class="exportResult.error ? 'alert-error' : 'alert-success'" style="margin-top:12px;">
<span v-if="exportResult.error">{{ exportResult.error }}</span>
<span v-else>
Экспортировано контактов: <strong>{{ exportResult.count }}</strong>
({{ exportResult.formatLabel }}).
</span>
</div>
<hr style="margin:18px 0;border:none;border-top:1px solid var(--border);" />
<h3 class="section-title">Бэкап локальной базы</h3>
<p class="text-muted section-subtitle">
@@ -108,6 +150,15 @@ const importing = ref(false)
const result = ref(null)
const backupPassphrase = ref('')
const busyBackup = ref(false)
const exportFormat = ref('csv')
const exporting = ref(false)
const exportResult = ref(null)
const exportFormatLabels = {
csv: 'CSV',
json: 'JSON',
vcf: 'vCard',
}
function onFileSelect(e) {
selectedFile.value = e.target.files[0] || null
@@ -139,6 +190,28 @@ function reset() {
if (fileInput.value) fileInput.value.value = ''
}
async function doExportContacts() {
exporting.value = true
exportResult.value = null
try {
const { blob, filename, count, format } = await store.exportContacts(exportFormat.value)
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
link.click()
URL.revokeObjectURL(url)
exportResult.value = {
count,
formatLabel: exportFormatLabels[format] || format,
}
} catch (e) {
exportResult.value = { error: e?.message || 'Ошибка экспорта' }
} finally {
exporting.value = false
}
}
async function doExport() {
busyBackup.value = true
try {
@@ -199,5 +272,10 @@ async function onBackupFileSelect(e) {
border-color: var(--accent);
background: var(--accent-dim);
}
.export-row {
display: flex;
align-items: flex-end;
gap: 12px;
}
@keyframes spin { to { transform: rotate(360deg); } }
</style>
+138 -8
View File
@@ -16,6 +16,12 @@
</NetworkMapTopPanel>
<div class="network-map-body">
<p v-if="!loading && nodes.length > 0" class="map-link-hint text-muted">
Ctrl+клик (+клик) по двум контактам создать связь.
<span v-if="linkSelectionCount === 1">
Выбран: <strong>{{ linkSelection[0].name }}</strong>.
</span>
</p>
<div v-if="loading" class="spinner"></div>
<div v-else-if="nodes.length === 0" class="empty-state card">
<p>
@@ -54,14 +60,32 @@
</div>
</div>
</div>
<GraphNodeContextMenu
:open="contextMenuOpen"
:node="contextMenuNode"
:x="contextMenuX"
:y="contextMenuY"
@close="closeContextMenu"
@info="openNodeInfo"
/>
<CreateRelationModal
:open="relationModalOpen"
:source="relationPair?.[0]"
:target="relationPair?.[1]"
@close="closeRelationModal"
@created="onRelationCreated"
/>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
import { RouterLink } from 'vue-router'
import { Network, DataSet } from 'vis-network/standalone'
import { useContactsStore } from '../stores/contacts'
import { useCtrlLinkSelection } from '../composables/useCtrlLinkSelection'
import { RELATION_COLORS } from '../lib/graph/relationColors'
import {
SPHERE_ORDER,
@@ -72,8 +96,28 @@ import {
nodeXY,
} from '../lib/map/positioning'
import { fetchGraphBundle, fetchMapChoices } from '../composables/useGraphData'
import { edgeFromRelation } from '../application/usecases/graph'
import RelationTypeFilters from '../components/RelationTypeFilters.vue'
import NetworkMapTopPanel from '../components/NetworkMapTopPanel.vue'
import CreateRelationModal from '../components/CreateRelationModal.vue'
import GraphNodeContextMenu from '../components/GraphNodeContextMenu.vue'
import { useGraphNodeContextMenu } from '../composables/useGraphNodeContextMenu.js'
let themeObserver = null
let detachContextHandler = null
const {
contextMenuOpen,
contextMenuNode,
contextMenuX,
contextMenuY,
closeContextMenu,
attachNodeContextHandlers,
} = useGraphNodeContextMenu()
function openNodeInfo(node) {
selectedNode.value = node || null
}
const GRID_SCALE = 5
function nodeById(id) {
@@ -183,6 +227,21 @@ const graphContainer = ref(null)
const loading = ref(true)
const network = ref(null)
const selectedNode = ref(null)
const relationModalOpen = ref(false)
const relationPair = ref(null)
const {
linkSelection,
linkSelectionCount,
clearLinkSelection,
handleCtrlPickNode,
} = useCtrlLinkSelection({
onPairSelected(pair) {
relationPair.value = pair
relationModalOpen.value = true
},
})
const nodes = ref([])
const edges = ref([])
const allRelationTypes = ref([])
@@ -197,7 +256,6 @@ let resizeObserver = null
let initRetryTimer = null
let initRetryCount = 0
const INIT_RETRY_MAX = 40
let themeObserver = null
const selectedContact = computed(() =>
selectedNode.value ? store.contactById(selectedNode.value.id) : null
@@ -213,6 +271,7 @@ function mapPalette() {
return {
nodeBackground: cssVar('--surface-alt', '#22263a'),
nodeBorder: cssVar('--accent', '#5b8dee'),
nodeLinkBorder: cssVar('--green', '#4ecca3'),
nodeHighlightBackground: cssVar('--surface', '#1a1d27'),
nodeHighlightBorder: cssVar('--accent-hover', '#7aa5f5'),
nodeFont: cssVar('--text', '#e2e6f3'),
@@ -220,6 +279,67 @@ function mapPalette() {
}
}
function applyLinkHighlights() {
if (!nodesDS) return
const palette = mapPalette()
const linkIds = new Set(linkSelection.value.map((item) => String(item.id)))
nodesDS.update(
nodes.value.map((n) => ({
id: String(n.id),
color: {
background: palette.nodeBackground,
border: linkIds.has(String(n.id)) ? palette.nodeLinkBorder : palette.nodeBorder,
highlight: {
background: palette.nodeHighlightBackground,
border: palette.nodeHighlightBorder,
},
},
borderWidth: linkIds.has(String(n.id)) ? 3 : 1.5,
}))
)
network.value?.redraw()
}
function appendRelationEdge(relation) {
if (!relation) return
const edge = edgeFromRelation(relation)
if (!edges.value.some((e) => String(e.id) === String(edge.id))) {
edges.value.push(edge)
}
if (!edgesDS) return
const nodeIds = new Set(nodes.value.map((n) => String(n.id)))
if (!nodeIds.has(String(edge.from)) || !nodeIds.has(String(edge.to))) return
if (
activeFilters.value.length < allRelationTypes.value.length &&
!activeFilters.value.includes(edge.relation_type)
) {
return
}
if (!edgesDS.get(String(edge.id))) {
edgesDS.add(mapEdgeToVis(edge))
}
}
function closeRelationModal() {
relationModalOpen.value = false
relationPair.value = null
clearLinkSelection()
applyLinkHighlights()
}
function onRelationCreated(relation) {
relationModalOpen.value = false
relationPair.value = null
clearLinkSelection()
applyLinkHighlights()
appendRelationEdge(relation)
}
watch(linkSelection, () => {
applyLinkHighlights()
}, { deep: true })
/** Сетка в тех же мировых координатах, что и узлы (см. vis-network beforeDrawing после translate+scale). */
function drawPolarGuide(ctx) {
const palette = mapPalette()
@@ -419,13 +539,20 @@ function initNetwork() {
network.value.on('beforeDrawing', drawPolarGuide)
network.value.on('click', (params) => {
closeContextMenu()
if (params.nodes.length > 0) {
const id = params.nodes[0]
const node = nodes.value.find((n) => String(n.id) === id)
selectedNode.value = node || null
const domEvent = params.event?.srcEvent || params.event
if (domEvent && (domEvent.ctrlKey || domEvent.metaKey)) {
handleCtrlPickNode(node, domEvent)
}
}
})
detachContextHandler?.()
detachContextHandler = attachNodeContextHandlers(network.value, () => nodes.value)
network.value.on('dragEnd', async (params) => {
if (!params.nodes?.length) return
const id = params.nodes[0]
@@ -483,15 +610,11 @@ function refreshLabelsByZoom() {
function applyThemeToNetwork() {
if (!nodesDS || !network.value) return
applyLinkHighlights()
const palette = mapPalette()
nodesDS.update(
nodes.value.map((n) => ({
id: String(n.id),
color: {
background: palette.nodeBackground,
border: palette.nodeBorder,
highlight: { background: palette.nodeHighlightBackground, border: palette.nodeHighlightBorder },
},
font: { color: palette.nodeFont, size: 11 },
}))
)
@@ -563,6 +686,8 @@ onMounted(async () => {
onUnmounted(() => {
if (initRetryTimer) clearTimeout(initRetryTimer)
detachContextHandler?.()
closeContextMenu()
resizeObserver?.disconnect()
themeObserver?.disconnect()
network.value?.destroy()
@@ -585,6 +710,11 @@ onUnmounted(() => {
padding: 0 28px 20px;
position: relative;
}
.map-link-hint {
font-size: 12px;
margin: 10px 0 8px;
flex-shrink: 0;
}
.map-stack {
position: relative;
flex: 1;