diff --git a/frontend/src/application/usecases/importExport.js b/frontend/src/application/usecases/importExport.js
index 490ae61..8951c75 100644
--- a/frontend/src/application/usecases/importExport.js
+++ b/frontend/src/application/usecases/importExport.js
@@ -5,6 +5,9 @@ import { generateId } from '../../lib/uuid'
import { createDefaultMapTypeRecord } from '../../domain/mapTypeDefaults'
import { parseVcf } from '../../lib/import/vcard'
import { serializeContactsExport } from '../../lib/export/contacts'
+import api from '../../api'
+import { localContactRepository } from '../../infrastructure/repositories/contactRepository.local'
+import { remoteContactRepository } from '../../infrastructure/repositories/contactRepository.remote'
function isLikelyEmail(value) {
return value.includes('@') && value.includes('.')
@@ -109,8 +112,107 @@ export async function importContactsFromFile(file) {
return { total: rows.length, created, skipped, errors }
}
+async function importRowsWithCreator(rows, createFn) {
+ let created = 0
+ let skipped = 0
+ const errors = []
+ for (let i = 0; i < rows.length; i += 1) {
+ const payload = toContactPayload(rows[i])
+ if (!payload.name) {
+ skipped += 1
+ errors.push(`Запись ${i + 1}: отсутствует имя контакта`)
+ continue
+ }
+ await createFn(payload)
+ created += 1
+ }
+ return { total: rows.length, created, skipped, errors }
+}
+
+async function parseContactRowsFromFile(file, { allowLocalDump = false } = {}) {
+ if (!file) throw new Error('Файл не выбран')
+ const name = file.name.toLowerCase()
+ const rawText = await readText(file)
+
+ if (name.endsWith('.csv')) {
+ return { kind: 'rows', rows: parseCsv(rawText) }
+ }
+ if (name.endsWith('.json')) {
+ const raw = JSON.parse(rawText)
+ if (isLocalDataDump(raw) || isEncryptedLocalDump(raw)) {
+ if (!allowLocalDump) {
+ throw new Error('Полный бэкап приложения импортируйте в разделе «Локальная база».')
+ }
+ return { kind: 'dump', file }
+ }
+ return { kind: 'rows', rows: normalizeRows(raw) }
+ }
+ if (name.endsWith('.vcf') || name.endsWith('.vcard')) {
+ const rows = parseVcf(rawText)
+ if (!rows.length) {
+ throw new Error('В файле vCard не найдено контактов.')
+ }
+ return { kind: 'rows', rows }
+ }
+ throw new Error('Поддерживаются только CSV, JSON и vCard (.vcf) файлы.')
+}
+
const ALLOWED_EXPORT_FORMATS = new Set(['csv', 'json', 'vcf'])
+function contactsExportResult(contacts, format) {
+ const normalized = String(format || 'csv').toLowerCase()
+ if (!ALLOWED_EXPORT_FORMATS.has(normalized)) {
+ throw new Error('Поддерживаются только CSV, JSON и vCard (.vcf).')
+ }
+ const { filename, mime, content } = serializeContactsExport(contacts, normalized)
+ return {
+ filename,
+ blob: new Blob([content], { type: mime }),
+ count: contacts.length,
+ format: normalized,
+ }
+}
+
+export async function importContactsToLocalFromFile(file) {
+ const parsed = await parseContactRowsFromFile(file, { allowLocalDump: true })
+ if (parsed.kind === 'dump') {
+ const summary = await importLocalDump(parsed.file, '')
+ return {
+ total: summary.importedContacts + summary.importedRelations,
+ created: summary.importedContacts,
+ importedRelations: summary.importedRelations,
+ skipped: 0,
+ errors: [],
+ isDump: true,
+ }
+ }
+ return importRowsWithCreator(parsed.rows, (payload) => localContactRepository.create(payload))
+}
+
+export async function importContactsToRemoteFromFile(file) {
+ await parseContactRowsFromFile(file, { allowLocalDump: false })
+ const formData = new FormData()
+ formData.append('file', file)
+ const { data } = await api.post('/v1/import/', formData, {
+ headers: { 'Content-Type': 'multipart/form-data' },
+ timeout: 120000,
+ })
+ if (data?.error) throw new Error(data.error)
+ return data
+}
+
+export async function exportContactsFromLocal({ format = 'csv' } = {}) {
+ const contacts = await localContactRepository.list()
+ if (!contacts.length) throw new Error('Нет контактов в локальной базе.')
+ return contactsExportResult(contacts, format)
+}
+
+export async function exportContactsFromRemote({ format = 'csv' } = {}) {
+ const contacts = await remoteContactRepository.list()
+ if (!contacts.length) throw new Error('Нет контактов на сервере.')
+ return contactsExportResult(contacts, format)
+}
+
export async function exportContacts({ format = 'csv' } = {}) {
const normalized = String(format || 'csv').toLowerCase()
if (!ALLOWED_EXPORT_FORMATS.has(normalized)) {
diff --git a/frontend/src/components/import/ExportFormatRow.vue b/frontend/src/components/import/ExportFormatRow.vue
new file mode 100644
index 0000000..42a3840
--- /dev/null
+++ b/frontend/src/components/import/ExportFormatRow.vue
@@ -0,0 +1,58 @@
+
+
+
+ Формат файла
+
+ CSV (.csv)
+ JSON (.json)
+ vCard (.vcf)
+
+
+
+ {{ exporting ? 'Экспорт...' : buttonLabel }}
+
+
+
+ {{ result.error }}
+
+ Экспортировано контактов: {{ result.count }}
+ ({{ result.formatLabel }}).
+
+
+
+
+
+
+
diff --git a/frontend/src/components/import/ImportFileBlock.vue b/frontend/src/components/import/ImportFileBlock.vue
new file mode 100644
index 0000000..539fc16
--- /dev/null
+++ b/frontend/src/components/import/ImportFileBlock.vue
@@ -0,0 +1,141 @@
+
+
+
Импорт файла
+
+ CSV, JSON или vCard (.vcf).
+
+
+
+
+
+
+
+
+ {{ file?.name || 'Перетащите файл или нажмите для выбора' }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ importing ? 'Импорт...' : importLabel }}
+
+
+ Сбросить
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/import/ImportResultAlert.vue b/frontend/src/components/import/ImportResultAlert.vue
new file mode 100644
index 0000000..16b9afd
--- /dev/null
+++ b/frontend/src/components/import/ImportResultAlert.vue
@@ -0,0 +1,20 @@
+
+
+ {{ result.error }}
+
+ В файле: {{ result.total ?? result.created + result.skipped }} ,
+ импортировано: {{ result.created }} контактов, {{ result.importedRelations }} связей ,
+ пропущено: {{ result.skipped }}.
+ Ошибок: {{ result.errors.length }}.
+
+
+
+
+
+
diff --git a/frontend/src/stores/contacts.js b/frontend/src/stores/contacts.js
index a1c06c2..8b81f43 100644
--- a/frontend/src/stores/contacts.js
+++ b/frontend/src/stores/contacts.js
@@ -19,10 +19,15 @@ import {
} from '../infrastructure/repositories/repositoryFactory'
import {
importContactsFromFile,
+ importContactsToLocalFromFile,
+ importContactsToRemoteFromFile,
exportContacts as exportContactsUseCase,
+ exportContactsFromLocal,
+ exportContactsFromRemote,
exportLocalData,
importLocalDump,
} from '../application/usecases/importExport'
+import { isLocalMode, isRemoteMode } from '../infrastructure/config/dataMode'
import { syncPendingChanges } from '../application/usecases/sync'
export const useContactsStore = defineStore('contacts', {
@@ -211,10 +216,42 @@ export const useContactsStore = defineStore('contacts', {
})
},
+ async importContactsToLocal(file) {
+ return this.withLoading('contactsLoading', async () => {
+ const data = await importContactsToLocalFromFile(file)
+ if (isLocalMode()) {
+ await this.fetchContacts()
+ if (data.isDump) await this.fetchRelations()
+ }
+ if (isLocalMode()) await syncPendingChanges()
+ this.bumpDataRevision()
+ return data
+ })
+ },
+
+ async importContactsToRemote(file) {
+ return this.withLoading('contactsLoading', async () => {
+ const data = await importContactsToRemoteFromFile(file)
+ if (isRemoteMode()) {
+ await this.fetchContacts()
+ }
+ this.bumpDataRevision()
+ return data
+ })
+ },
+
async exportContacts(format = 'csv') {
return exportContactsUseCase({ format })
},
+ async exportLocalContacts(format = 'csv') {
+ return exportContactsFromLocal({ format })
+ },
+
+ async exportRemoteContacts(format = 'csv') {
+ return exportContactsFromRemote({ format })
+ },
+
async exportData(passphrase = '') {
return exportLocalData({ passphrase })
},
diff --git a/frontend/src/views/ImportView.vue b/frontend/src/views/ImportView.vue
index cd4e722..208c21a 100644
--- a/frontend/src/views/ImportView.vue
+++ b/frontend/src/views/ImportView.vue
@@ -3,135 +3,131 @@
-
-
-
Загрузить файл
+
+
-
-
-
-
-
-
-
-
- {{ selectedFile ? selectedFile.name : 'Перетащите файл или нажмите для выбора' }}
+
+
+
+
+
Локальная база
+ IndexedDB
-
-
+
+ Данные в браузере. Не зависит от выбранного режима в настройках.
+ Сейчас активен локальный режим.
+
-
- {{ result.error }}
-
- В файле: {{ result.total ?? result.created + result.skipped }} ,
- импортировано: {{ result.created }} контактов, {{ result.importedRelations }} связей ,
- пропущено: {{ result.skipped }}.
- Ошибок: {{ result.errors.length }}.
-
-
-
-
-
-
-
-
-
-
- {{ importing ? 'Импорт...' : 'Импортировать' }}
-
-
Сбросить
-
-
-
-
Экспорт контактов
-
- Скачать все контакты в выбранном формате: CSV ,
- JSON или vCard (.vcf)
- — совместимо с Nextcloud и другими адресными книгами.
-
-
-
- Формат файла
-
- CSV (.csv)
- JSON (.json)
- vCard (.vcf)
-
-
-
- {{ exporting ? 'Экспорт...' : 'Экспортировать' }}
-
-
-
- Нет контактов для экспорта.
-
-
- {{ exportResult.error }}
-
- Экспортировано контактов: {{ exportResult.count }}
- ({{ exportResult.formatLabel }}).
-
-
-
-
-
Бэкап локальной базы
-
- Экспортирует/импортирует локальные данные. Пароль для шифрования необязателен.
-
-
- Пароль шифрования (необязательно)
-
-
-
-
- {{ busyBackup ? 'Экспорт...' : 'Экспорт локальной БД' }}
-
-
- Импорт бэкапа
-
-
+
+
+
+
Экспорт контактов
+
+
+ В локальной базе нет контактов.
+
+
+ Контактов в локальной базе: {{ localContactCount }}.
+
+
+
+
+
Полный бэкап
+
+ Экспорт или восстановление всей локальной базы (контакты, связи, карты). Пароль необязателен.
+
+
+ Пароль шифрования (необязательно)
+
+
+
+
+ {{ busyBackup ? 'Экспорт...' : 'Экспорт бэкапа' }}
+
+
+ Импорт бэкапа
+
+
+
+
+
+
+
+
+
Удалённая база
+ Сервер
+
+
+ Django API. Нужен вход в аккаунт.
+ Сейчас активен серверный режим.
+
+
+
Проверка сервера…
+
+ Сервер доступен · контактов: {{ serverProbe.contactCount }} · связей: {{ serverProbe.relationCount }}
+
+
{{ serverProbe.message }}
+
+ Войдите в аккаунт для работы с серверной базой.
+
+
+
+
+
+
+
Экспорт контактов
+
+
+ На сервере нет контактов.
+
+
+
+ Перенос между локальной и серверной базой — в
+ настройках .
+
@@ -139,22 +135,40 @@ END:VCARD