Add relations export and remote full backup/restore.
Expose GET/POST /api/v1/export/ for server dumps and add import UI for relations (CSV/JSON) plus remote backup controls. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -5,9 +5,12 @@ import { generateId } from '../../lib/uuid'
|
||||
import { createDefaultMapTypeRecord } from '../../domain/mapTypeDefaults'
|
||||
import { parseVcf } from '../../lib/import/vcard'
|
||||
import { serializeContactsExport } from '../../lib/export/contacts'
|
||||
import { serializeRelationsExport } from '../../lib/export/relations'
|
||||
import api from '../../api'
|
||||
import { localContactRepository } from '../../infrastructure/repositories/contactRepository.local'
|
||||
import { remoteContactRepository } from '../../infrastructure/repositories/contactRepository.remote'
|
||||
import { localRelationRepository } from '../../infrastructure/repositories/relationRepository.local'
|
||||
import { remoteRelationRepository } from '../../infrastructure/repositories/relationRepository.remote'
|
||||
|
||||
function isLikelyEmail(value) {
|
||||
return value.includes('@') && value.includes('.')
|
||||
@@ -157,11 +160,12 @@ async function parseContactRowsFromFile(file, { allowLocalDump = false } = {}) {
|
||||
throw new Error('Поддерживаются только CSV, JSON и vCard (.vcf) файлы.')
|
||||
}
|
||||
|
||||
const ALLOWED_EXPORT_FORMATS = new Set(['csv', 'json', 'vcf'])
|
||||
const ALLOWED_CONTACT_EXPORT_FORMATS = new Set(['csv', 'json', 'vcf'])
|
||||
const ALLOWED_RELATION_EXPORT_FORMATS = new Set(['csv', 'json'])
|
||||
|
||||
function contactsExportResult(contacts, format) {
|
||||
const normalized = String(format || 'csv').toLowerCase()
|
||||
if (!ALLOWED_EXPORT_FORMATS.has(normalized)) {
|
||||
if (!ALLOWED_CONTACT_EXPORT_FORMATS.has(normalized)) {
|
||||
throw new Error('Поддерживаются только CSV, JSON и vCard (.vcf).')
|
||||
}
|
||||
const { filename, mime, content } = serializeContactsExport(contacts, normalized)
|
||||
@@ -173,6 +177,20 @@ function contactsExportResult(contacts, format) {
|
||||
}
|
||||
}
|
||||
|
||||
function relationsExportResult(relations, format) {
|
||||
const normalized = String(format || 'csv').toLowerCase()
|
||||
if (!ALLOWED_RELATION_EXPORT_FORMATS.has(normalized)) {
|
||||
throw new Error('Поддерживаются только CSV и JSON.')
|
||||
}
|
||||
const { filename, mime, content } = serializeRelationsExport(relations, normalized)
|
||||
return {
|
||||
filename,
|
||||
blob: new Blob([content], { type: mime }),
|
||||
count: relations.length,
|
||||
format: normalized,
|
||||
}
|
||||
}
|
||||
|
||||
export async function importContactsToLocalFromFile(file) {
|
||||
const parsed = await parseContactRowsFromFile(file, { allowLocalDump: true })
|
||||
if (parsed.kind === 'dump') {
|
||||
@@ -213,9 +231,21 @@ export async function exportContactsFromRemote({ format = 'csv' } = {}) {
|
||||
return contactsExportResult(contacts, format)
|
||||
}
|
||||
|
||||
export async function exportRelationsFromLocal({ format = 'csv' } = {}) {
|
||||
const relations = await localRelationRepository.list()
|
||||
if (!relations.length) throw new Error('Нет связей в локальной базе.')
|
||||
return relationsExportResult(relations, format)
|
||||
}
|
||||
|
||||
export async function exportRelationsFromRemote({ format = 'csv' } = {}) {
|
||||
const relations = await remoteRelationRepository.list()
|
||||
if (!relations.length) throw new Error('Нет связей на сервере.')
|
||||
return relationsExportResult(relations, format)
|
||||
}
|
||||
|
||||
export async function exportContacts({ format = 'csv' } = {}) {
|
||||
const normalized = String(format || 'csv').toLowerCase()
|
||||
if (!ALLOWED_EXPORT_FORMATS.has(normalized)) {
|
||||
if (!ALLOWED_CONTACT_EXPORT_FORMATS.has(normalized)) {
|
||||
throw new Error('Поддерживаются только CSV, JSON и vCard (.vcf).')
|
||||
}
|
||||
|
||||
@@ -399,6 +429,38 @@ function migrateV1ToV2(dump) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function exportRemoteData() {
|
||||
const { data } = await api.get('/v1/export/', { timeout: 120000 })
|
||||
return {
|
||||
filename: `social-graph-export-${Date.now()}.json`,
|
||||
blob: new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }),
|
||||
}
|
||||
}
|
||||
|
||||
async function parseDumpFile(file, passphrase = '') {
|
||||
const raw = JSON.parse(await file.text())
|
||||
if (isEncryptedLocalDump(raw)) {
|
||||
if (!passphrase) {
|
||||
throw new Error('Для зашифрованного бэкапа укажите пароль.')
|
||||
}
|
||||
return decryptPayload(raw, passphrase)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
export async function importRemoteDump(file, { replace = true, passphrase = '' } = {}) {
|
||||
const dump = await parseDumpFile(file, passphrase)
|
||||
if (!isLocalDataDump(dump)) {
|
||||
throw new Error('Некорректный формат бэкапа: ожидаются массивы contacts и relations.')
|
||||
}
|
||||
const { data } = await api.post('/v1/export/', dump, {
|
||||
params: { replace: replace ? 'true' : 'false' },
|
||||
timeout: 300000,
|
||||
})
|
||||
if (data?.error) throw new Error(data.error)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function exportLocalData({ passphrase = '' } = {}) {
|
||||
const payload = {
|
||||
version: 2,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<select :id="inputId" :value="modelValue" class="form-control" @change="onChange">
|
||||
<option value="csv">CSV (.csv)</option>
|
||||
<option value="json">JSON (.json)</option>
|
||||
<option value="vcf">vCard (.vcf)</option>
|
||||
<option v-if="variant === 'contacts'" value="vcf">vCard (.vcf)</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
@@ -20,26 +20,29 @@
|
||||
<div v-if="result" class="alert" :class="result.error ? 'alert-error' : 'alert-success'" style="margin-top:12px;">
|
||||
<span v-if="result.error">{{ result.error }}</span>
|
||||
<span v-else>
|
||||
Экспортировано контактов: <strong>{{ result.count }}</strong>
|
||||
Экспортировано {{ entityLabel }}: <strong>{{ result.count }}</strong>
|
||||
({{ result.formatLabel }}).
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useId } from 'vue'
|
||||
import { computed, useId } from 'vue'
|
||||
|
||||
defineProps({
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: 'csv' },
|
||||
exporting: { type: Boolean, default: false },
|
||||
disabled: { type: Boolean, default: false },
|
||||
result: { type: Object, default: null },
|
||||
buttonLabel: { type: String, default: 'Экспортировать' },
|
||||
variant: { type: String, default: 'contacts' },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'export'])
|
||||
const inputId = useId()
|
||||
|
||||
const entityLabel = computed(() => (props.variant === 'relations' ? 'связей' : 'контактов'))
|
||||
|
||||
function onChange(event) {
|
||||
emit('update:modelValue', event.target.value)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
const CSV_HEADERS = [
|
||||
'source',
|
||||
'target',
|
||||
'source_name',
|
||||
'target_name',
|
||||
'relation_type',
|
||||
'description',
|
||||
'interaction_intensity',
|
||||
]
|
||||
|
||||
export function relationToExportRow(relation = {}) {
|
||||
return {
|
||||
source: relation.source ?? '',
|
||||
target: relation.target ?? '',
|
||||
source_name: String(relation.source_name || '').trim(),
|
||||
target_name: String(relation.target_name || '').trim(),
|
||||
relation_type: String(relation.relation_type || 'acquaintance').trim(),
|
||||
description: String(relation.description || '').trim(),
|
||||
interaction_intensity: String(relation.interaction_intensity || 'intense').trim(),
|
||||
}
|
||||
}
|
||||
|
||||
function escapeCsvField(value) {
|
||||
const text = String(value ?? '')
|
||||
if (/[",\n\r]/.test(text)) {
|
||||
return `"${text.replace(/"/g, '""')}"`
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
export function relationsToCsv(relations = []) {
|
||||
const rows = relations.map(relationToExportRow)
|
||||
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 relationsToJson(relations = []) {
|
||||
return JSON.stringify(relations.map(relationToExportRow), null, 2)
|
||||
}
|
||||
|
||||
const EXPORT_FORMATS = {
|
||||
csv: {
|
||||
ext: 'csv',
|
||||
mime: 'text/csv;charset=utf-8',
|
||||
serialize: relationsToCsv,
|
||||
},
|
||||
json: {
|
||||
ext: 'json',
|
||||
mime: 'application/json;charset=utf-8',
|
||||
serialize: relationsToJson,
|
||||
},
|
||||
}
|
||||
|
||||
export function serializeRelationsExport(relations = [], format = 'csv') {
|
||||
const config = EXPORT_FORMATS[format] || EXPORT_FORMATS.csv
|
||||
return {
|
||||
format,
|
||||
filename: `relations-export-${Date.now()}.${config.ext}`,
|
||||
mime: config.mime,
|
||||
content: config.serialize(relations),
|
||||
}
|
||||
}
|
||||
@@ -24,8 +24,12 @@ import {
|
||||
exportContacts as exportContactsUseCase,
|
||||
exportContactsFromLocal,
|
||||
exportContactsFromRemote,
|
||||
exportRelationsFromLocal,
|
||||
exportRelationsFromRemote,
|
||||
exportLocalData,
|
||||
exportRemoteData,
|
||||
importLocalDump,
|
||||
importRemoteDump,
|
||||
} from '../application/usecases/importExport'
|
||||
import { isLocalMode, isRemoteMode } from '../infrastructure/config/dataMode'
|
||||
import { syncPendingChanges } from '../application/usecases/sync'
|
||||
@@ -252,15 +256,36 @@ export const useContactsStore = defineStore('contacts', {
|
||||
return exportContactsFromRemote({ format })
|
||||
},
|
||||
|
||||
async exportLocalRelations(format = 'csv') {
|
||||
return exportRelationsFromLocal({ format })
|
||||
},
|
||||
|
||||
async exportRemoteRelations(format = 'csv') {
|
||||
return exportRelationsFromRemote({ format })
|
||||
},
|
||||
|
||||
async exportData(passphrase = '') {
|
||||
return exportLocalData({ passphrase })
|
||||
},
|
||||
|
||||
async exportRemoteData() {
|
||||
return exportRemoteData()
|
||||
},
|
||||
|
||||
async importDataDump(file, passphrase = '') {
|
||||
const result = await importLocalDump(file, passphrase)
|
||||
await Promise.all([this.fetchContacts(), this.fetchRelations()])
|
||||
this.bumpDataRevision()
|
||||
return result
|
||||
},
|
||||
|
||||
async importRemoteDataDump(file, { replace = true, passphrase = '' } = {}) {
|
||||
const result = await importRemoteDump(file, { replace, passphrase })
|
||||
if (isRemoteMode()) {
|
||||
await Promise.all([this.fetchContacts(), this.fetchRelations()])
|
||||
}
|
||||
this.bumpDataRevision()
|
||||
return result
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
<div class="card format-examples">
|
||||
<h3 class="section-title">Поддерживаемые форматы</h3>
|
||||
<p class="text-muted section-subtitle">
|
||||
CSV, JSON и vCard (.vcf). Полный бэкап приложения (контакты, связи, карты) — только JSON в разделе локальной базы.
|
||||
CSV, JSON и vCard (.vcf) для контактов; CSV и JSON для связей.
|
||||
Полный бэкап приложения (контакты, связи, карты) — JSON в разделах локальной и удалённой базы.
|
||||
</p>
|
||||
<pre class="format-pre">name,email,phone,organization,position,notes
|
||||
Иван Иванов,ivan@example.com,+7-900-000-0001,ООО Ромашка,Директор,</pre>
|
||||
@@ -54,6 +55,25 @@
|
||||
|
||||
<hr class="import-divider" />
|
||||
|
||||
<h4 class="subsection-title">Экспорт связей</h4>
|
||||
<ExportFormatRow
|
||||
v-model="localRelationsExportFormat"
|
||||
variant="relations"
|
||||
:exporting="localRelationsExporting"
|
||||
:disabled="localRelationCount === 0"
|
||||
:result="localRelationsExportResult"
|
||||
button-label="Экспортировать связи локально"
|
||||
@export="doLocalRelationsExport"
|
||||
/>
|
||||
<p v-if="localRelationCount === 0" class="text-muted import-empty-hint">
|
||||
В локальной базе нет связей.
|
||||
</p>
|
||||
<p v-else class="text-muted import-empty-hint">
|
||||
Связей в локальной базе: {{ localRelationCount }}.
|
||||
</p>
|
||||
|
||||
<hr class="import-divider" />
|
||||
|
||||
<h4 class="subsection-title">Полный бэкап</h4>
|
||||
<p class="text-muted section-subtitle">
|
||||
Экспорт или восстановление всей локальной базы (контакты, связи, карты). Пароль необязателен.
|
||||
@@ -124,6 +144,83 @@
|
||||
На сервере нет контактов.
|
||||
</p>
|
||||
|
||||
<hr class="import-divider" />
|
||||
|
||||
<h4 class="subsection-title">Экспорт связей</h4>
|
||||
<ExportFormatRow
|
||||
v-model="remoteRelationsExportFormat"
|
||||
variant="relations"
|
||||
:exporting="remoteRelationsExporting"
|
||||
:disabled="!canUseRemote || serverProbe.relationCount === 0"
|
||||
:result="remoteRelationsExportResult"
|
||||
button-label="Экспортировать связи с сервера"
|
||||
@export="doRemoteRelationsExport"
|
||||
/>
|
||||
<p v-if="canUseRemote && serverProbe.relationCount === 0" class="text-muted import-empty-hint">
|
||||
На сервере нет связей.
|
||||
</p>
|
||||
|
||||
<hr class="import-divider" />
|
||||
|
||||
<h4 class="subsection-title">Полный бэкап</h4>
|
||||
<p class="text-muted section-subtitle">
|
||||
Экспорт или восстановление всей серверной базы (контакты, связи, карты).
|
||||
При импорте с заменой существующие данные аккаунта удаляются.
|
||||
</p>
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input v-model="remoteBackupReplace" type="checkbox" />
|
||||
Заменить данные на сервере при импорте
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Пароль (только для зашифрованного .sgpkg)</label>
|
||||
<input
|
||||
v-model="remoteBackupPassphrase"
|
||||
type="password"
|
||||
class="form-control"
|
||||
placeholder="Оставьте пустым для обычного JSON"
|
||||
/>
|
||||
</div>
|
||||
<div class="import-actions">
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
type="button"
|
||||
:disabled="!canUseRemote || busyRemoteBackup"
|
||||
@click="doExportRemoteBackup"
|
||||
>
|
||||
{{ busyRemoteBackup ? 'Экспорт...' : 'Экспорт бэкапа' }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
type="button"
|
||||
:disabled="!canUseRemote || busyRemoteBackup"
|
||||
@click="$refs.remoteBackupInput.click()"
|
||||
>
|
||||
Импорт бэкапа
|
||||
</button>
|
||||
<input
|
||||
ref="remoteBackupInput"
|
||||
type="file"
|
||||
accept=".json,.sgpkg"
|
||||
style="display:none"
|
||||
@change="onRemoteBackupFileSelect"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="remoteBackupResult"
|
||||
class="alert"
|
||||
:class="remoteBackupResult.error ? 'alert-error' : 'alert-success'"
|
||||
style="margin-top:12px;"
|
||||
>
|
||||
<span v-if="remoteBackupResult.error">{{ remoteBackupResult.error }}</span>
|
||||
<span v-else>
|
||||
Импортировано контактов: <strong>{{ remoteBackupResult.importedContacts }}</strong>,
|
||||
связей: <strong>{{ remoteBackupResult.importedRelations }}</strong>,
|
||||
карт: <strong>{{ remoteBackupResult.importedMaps }}</strong>.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="text-muted migration-link">
|
||||
Перенос между локальной и серверной базой — в
|
||||
<RouterLink to="/settings">настройках</RouterLink>.
|
||||
@@ -143,6 +240,7 @@ import { isLocalMode, isRemoteMode } from '../infrastructure/config/dataMode'
|
||||
import { getStoredAccessToken } from '../stores/auth'
|
||||
import { probeRemoteServer } from '../application/usecases/dataMigration'
|
||||
import { localContactRepository } from '../infrastructure/repositories/contactRepository.local'
|
||||
import { localRelationRepository } from '../infrastructure/repositories/relationRepository.local'
|
||||
import ImportFileBlock from '../components/import/ImportFileBlock.vue'
|
||||
import ExportFormatRow from '../components/import/ExportFormatRow.vue'
|
||||
|
||||
@@ -156,14 +254,25 @@ const remoteImporting = ref(false)
|
||||
const localResult = ref(null)
|
||||
const remoteResult = ref(null)
|
||||
const backupPassphrase = ref('')
|
||||
const remoteBackupPassphrase = ref('')
|
||||
const remoteBackupReplace = ref(true)
|
||||
const busyBackup = ref(false)
|
||||
const busyRemoteBackup = ref(false)
|
||||
const remoteBackupResult = ref(null)
|
||||
const localExportFormat = ref('csv')
|
||||
const remoteExportFormat = ref('csv')
|
||||
const localRelationsExportFormat = ref('csv')
|
||||
const remoteRelationsExportFormat = ref('csv')
|
||||
const localExporting = ref(false)
|
||||
const remoteExporting = ref(false)
|
||||
const localRelationsExporting = ref(false)
|
||||
const remoteRelationsExporting = ref(false)
|
||||
const localExportResult = ref(null)
|
||||
const remoteExportResult = ref(null)
|
||||
const localRelationsExportResult = ref(null)
|
||||
const remoteRelationsExportResult = ref(null)
|
||||
const localContactCount = ref(0)
|
||||
const localRelationCount = ref(0)
|
||||
const serverProbeLoading = ref(true)
|
||||
const serverProbe = ref({ ok: false, message: '', contactCount: 0, relationCount: 0 })
|
||||
|
||||
@@ -177,8 +286,12 @@ const exportFormatLabels = {
|
||||
}
|
||||
|
||||
async function refreshLocalCount() {
|
||||
const contacts = await localContactRepository.list()
|
||||
const [contacts, relations] = await Promise.all([
|
||||
localContactRepository.list(),
|
||||
localRelationRepository.list(),
|
||||
])
|
||||
localContactCount.value = contacts.length
|
||||
localRelationCount.value = relations.length
|
||||
}
|
||||
|
||||
async function refreshServerProbe() {
|
||||
@@ -275,6 +388,41 @@ async function doRemoteExport() {
|
||||
}
|
||||
}
|
||||
|
||||
async function doLocalRelationsExport() {
|
||||
localRelationsExporting.value = true
|
||||
localRelationsExportResult.value = null
|
||||
try {
|
||||
const { blob, filename, count, format } = await store.exportLocalRelations(localRelationsExportFormat.value)
|
||||
downloadBlob(blob, filename)
|
||||
localRelationsExportResult.value = {
|
||||
count,
|
||||
formatLabel: exportFormatLabels[format] || format,
|
||||
}
|
||||
} catch (e) {
|
||||
localRelationsExportResult.value = { error: e?.message || 'Ошибка экспорта' }
|
||||
} finally {
|
||||
localRelationsExporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function doRemoteRelationsExport() {
|
||||
if (!canUseRemote.value) return
|
||||
remoteRelationsExporting.value = true
|
||||
remoteRelationsExportResult.value = null
|
||||
try {
|
||||
const { blob, filename, count, format } = await store.exportRemoteRelations(remoteRelationsExportFormat.value)
|
||||
downloadBlob(blob, filename)
|
||||
remoteRelationsExportResult.value = {
|
||||
count,
|
||||
formatLabel: exportFormatLabels[format] || format,
|
||||
}
|
||||
} catch (e) {
|
||||
remoteRelationsExportResult.value = { error: e?.message || 'Ошибка экспорта' }
|
||||
} finally {
|
||||
remoteRelationsExporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function downloadBlob(blob, filename) {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
@@ -318,6 +466,41 @@ async function onBackupFileSelect(e) {
|
||||
e.target.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function doExportRemoteBackup() {
|
||||
if (!canUseRemote.value) return
|
||||
busyRemoteBackup.value = true
|
||||
remoteBackupResult.value = null
|
||||
try {
|
||||
const { blob, filename } = await store.exportRemoteData()
|
||||
downloadBlob(blob, filename)
|
||||
} catch (error) {
|
||||
remoteBackupResult.value = { error: error?.message || 'Ошибка экспорта бэкапа' }
|
||||
} finally {
|
||||
busyRemoteBackup.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onRemoteBackupFileSelect(e) {
|
||||
const file = e.target.files[0]
|
||||
if (!file) return
|
||||
busyRemoteBackup.value = true
|
||||
remoteBackupResult.value = null
|
||||
try {
|
||||
const summary = await store.importRemoteDataDump(file, {
|
||||
replace: remoteBackupReplace.value,
|
||||
passphrase: remoteBackupPassphrase.value,
|
||||
})
|
||||
await mapsStore.fetchMaps()
|
||||
remoteBackupResult.value = summary
|
||||
await refreshServerProbe()
|
||||
} catch (error) {
|
||||
remoteBackupResult.value = { error: error?.message || 'Ошибка импорта бэкапа' }
|
||||
} finally {
|
||||
busyRemoteBackup.value = false
|
||||
e.target.value = ''
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -391,6 +574,13 @@ async function onBackupFileSelect(e) {
|
||||
font-size: 12px;
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.server-status {
|
||||
font-size: 13px;
|
||||
margin: 0 0 14px;
|
||||
|
||||
Reference in New Issue
Block a user