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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + 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 @@ -
-
-

Загрузить файл

+
+
+

Поддерживаемые форматы

- Поддерживаются форматы CSV, JSON и vCard (.vcf). + CSV, JSON и vCard (.vcf). Полный бэкап приложения (контакты, связи, карты) — только JSON в разделе локальной базы.

- - -
-
Пример CSV:
-
name,email,phone,organization,position,notes
+        
name,email,phone,organization,position,notes
 Иван Иванов,ivan@example.com,+7-900-000-0001,ООО Ромашка,Директор,
-
Пример JSON:
-
[{"name":"Иван Иванов","email":"ivan@example.com","organization":"ООО Ромашка"}]
-
Пример vCard (.vcf):
-
BEGIN:VCARD
-FN:Иван Иванов
-EMAIL:ivan@example.com
-TEL:+79000000001
-ORG:ООО Ромашка
-END:VCARD
-
+
- -
- - - - - -
- {{ selectedFile ? selectedFile.name : 'Перетащите файл или нажмите для выбора' }} +
+ +
+
+

Локальная база

+ IndexedDB
- -
+

+ Данные в браузере. Не зависит от выбранного режима в настройках. + Сейчас активен локальный режим. +

-
- {{ result.error }} - - В файле: {{ result.total ?? result.created + result.skipped }}, - импортировано: {{ result.created }} контактов, - пропущено: {{ result.skipped }}. - Ошибок: {{ result.errors.length }}. - -
- -
-
{{ e }}
-
- -
- - -
- -
-

Экспорт контактов

-

- Скачать все контакты в выбранном формате: CSV, - JSON или vCard (.vcf) - — совместимо с Nextcloud и другими адресными книгами. -

-
-
- - -
- -
-

- Нет контактов для экспорта. -

-
- {{ exportResult.error }} - - Экспортировано контактов: {{ exportResult.count }} - ({{ exportResult.formatLabel }}). - -
- -
-

Бэкап локальной базы

-

- Экспортирует/импортирует локальные данные. Пароль для шифрования необязателен. -

-
- - -
-
- - - + +
+ +

Экспорт контактов

+ +

+ В локальной базе нет контактов. +

+

+ Контактов в локальной базе: {{ localContactCount }}. +

+ +
+ +

Полный бэкап

+

+ Экспорт или восстановление всей локальной базы (контакты, связи, карты). Пароль необязателен. +

+
+ + +
+
+ + + +
+
+ + +
+
+

Удалённая база

+ Сервер +
+

+ Django API. Нужен вход в аккаунт. + Сейчас активен серверный режим. +

+ +

Проверка сервера…

+

+ Сервер доступен · контактов: {{ serverProbe.contactCount }} · связей: {{ serverProbe.relationCount }} +

+

{{ serverProbe.message }}

+

+ Войдите в аккаунт для работы с серверной базой. +

+ + + +
+ +

Экспорт контактов

+ +

+ На сервере нет контактов. +

+ +
@@ -139,22 +135,40 @@ END:VCARD