mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:41:09 +02:00
✨ feat(contacts): add import/export with Google Contacts integration
- Add vCard/CSV file import with duplicate detection and merge options - Add Google Contacts OAuth2 integration for importing from Google - Add vCard/CSV export with format selection and filtering options - Add connected_accounts table for OAuth token storage - Add FileUploader, ImportPreview, GoogleImport, ExportModal components - Add i18n translations for import/export (DE/EN)
This commit is contained in:
parent
79b629b820
commit
180eced0d0
34 changed files with 4182 additions and 5 deletions
|
|
@ -97,8 +97,17 @@ pnpm build # Build for production
|
|||
| `/api/v1/notes/:id` | DELETE | Delete note |
|
||||
| `/api/v1/contacts/:id/activities` | GET | Get contact activities |
|
||||
| `/api/v1/contacts/:id/activities` | POST | Log activity |
|
||||
| `/api/v1/contacts/import` | POST | Import contacts (vCard/CSV)|
|
||||
| `/api/v1/contacts/export` | GET | Export contacts |
|
||||
| `/api/v1/import/preview` | POST | Preview file import (vCard/CSV) |
|
||||
| `/api/v1/import/execute` | POST | Execute contact import |
|
||||
| `/api/v1/import/template/csv` | GET | Download CSV template |
|
||||
| `/api/v1/google/auth-url` | GET | Get Google OAuth URL |
|
||||
| `/api/v1/google/callback` | POST | Exchange OAuth code |
|
||||
| `/api/v1/google/status` | GET | Get Google connection status |
|
||||
| `/api/v1/google/disconnect` | DELETE | Disconnect Google account |
|
||||
| `/api/v1/google/contacts` | GET | Fetch Google contacts |
|
||||
| `/api/v1/google/import` | POST | Import from Google |
|
||||
| `/api/v1/export` | GET | Quick export all contacts |
|
||||
| `/api/v1/export` | POST | Export with options |
|
||||
| `/api/v1/organizations/:orgId/contacts` | GET | Get organization contacts |
|
||||
| `/api/v1/teams/:teamId/contacts` | GET | Get team contacts |
|
||||
| `/api/v1/contacts/:id/share` | POST | Share contact |
|
||||
|
|
@ -165,6 +174,20 @@ pnpm build # Build for production
|
|||
- `metadata` (JSONB)
|
||||
- `created_at` (TIMESTAMP)
|
||||
|
||||
**connected_accounts** - OAuth provider connections (Google, etc.)
|
||||
|
||||
- `id` (UUID) - Primary key
|
||||
- `user_id` (VARCHAR) - User reference
|
||||
- `provider` (VARCHAR) - Provider name (e.g., 'google')
|
||||
- `provider_account_id` (VARCHAR) - Provider's user ID
|
||||
- `provider_email` (VARCHAR) - Provider account email
|
||||
- `access_token` (TEXT) - OAuth access token (encrypted)
|
||||
- `refresh_token` (TEXT) - OAuth refresh token (encrypted)
|
||||
- `token_expires_at` (TIMESTAMP) - Token expiration time
|
||||
- `scope` (TEXT) - Granted OAuth scopes
|
||||
- `provider_data` (JSONB) - Additional provider-specific data
|
||||
- `created_at`, `updated_at` (TIMESTAMP)
|
||||
|
||||
### Environment Variables
|
||||
|
||||
#### Backend (.env)
|
||||
|
|
@ -180,6 +203,12 @@ S3_REGION=us-east-1
|
|||
S3_ACCESS_KEY=minioadmin
|
||||
S3_SECRET_KEY=minioadmin
|
||||
S3_BUCKET=contacts-photos
|
||||
|
||||
# Google OAuth (for contacts import)
|
||||
# Get credentials from https://console.cloud.google.com/apis/credentials
|
||||
GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com
|
||||
GOOGLE_CLIENT_SECRET=your-client-secret
|
||||
GOOGLE_REDIRECT_URI=http://localhost:5184/import?tab=google
|
||||
```
|
||||
|
||||
#### Mobile (.env)
|
||||
|
|
|
|||
|
|
@ -26,17 +26,21 @@
|
|||
"@nestjs/platform-express": "^10.4.15",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"csv-parse": "^6.1.0",
|
||||
"dotenv": "^16.4.7",
|
||||
"drizzle-kit": "^0.30.2",
|
||||
"drizzle-orm": "^0.38.3",
|
||||
"googleapis": "^144.0.0",
|
||||
"postgres": "^3.4.5",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
"rxjs": "^7.8.1",
|
||||
"vcard4": "^4.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.4.9",
|
||||
"@nestjs/schematics": "^10.2.3",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/multer": "^1.4.11",
|
||||
"@types/node": "^22.10.2",
|
||||
"@typescript-eslint/eslint-plugin": "^8.18.1",
|
||||
"@typescript-eslint/parser": "^8.18.1",
|
||||
|
|
|
|||
|
|
@ -7,6 +7,9 @@ import { TagModule } from './tag/tag.module';
|
|||
import { NoteModule } from './note/note.module';
|
||||
import { ActivityModule } from './activity/activity.module';
|
||||
import { HealthModule } from './health/health.module';
|
||||
import { ImportModule } from './import/import.module';
|
||||
import { ExportModule } from './export/export.module';
|
||||
import { GoogleModule } from './google/google.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
|
|
@ -21,6 +24,9 @@ import { HealthModule } from './health/health.module';
|
|||
NoteModule,
|
||||
ActivityModule,
|
||||
HealthModule,
|
||||
ImportModule,
|
||||
ExportModule,
|
||||
GoogleModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
import { pgTable, uuid, timestamp, varchar, text, jsonb } from 'drizzle-orm/pg-core';
|
||||
|
||||
export interface GoogleContactsProviderData {
|
||||
syncToken?: string;
|
||||
lastSyncedAt?: string;
|
||||
importedResourceNames?: string[];
|
||||
totalContacts?: number;
|
||||
}
|
||||
|
||||
export type ProviderData = GoogleContactsProviderData;
|
||||
|
||||
export const connectedAccounts = pgTable('connected_accounts', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: varchar('user_id', { length: 255 }).notNull(),
|
||||
|
||||
// Provider identification
|
||||
provider: varchar('provider', { length: 50 }).notNull(), // 'google'
|
||||
providerAccountId: varchar('provider_account_id', { length: 255 }),
|
||||
providerEmail: varchar('provider_email', { length: 255 }),
|
||||
|
||||
// OAuth tokens
|
||||
accessToken: text('access_token').notNull(),
|
||||
refreshToken: text('refresh_token'),
|
||||
tokenExpiresAt: timestamp('token_expires_at', { withTimezone: true }),
|
||||
scope: text('scope'),
|
||||
|
||||
// Provider-specific metadata
|
||||
providerData: jsonb('provider_data').$type<ProviderData>(),
|
||||
|
||||
// Timestamps
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export type ConnectedAccount = typeof connectedAccounts.$inferSelect;
|
||||
export type NewConnectedAccount = typeof connectedAccounts.$inferInsert;
|
||||
|
|
@ -3,3 +3,4 @@ export * from './groups.schema';
|
|||
export * from './tags.schema';
|
||||
export * from './notes.schema';
|
||||
export * from './activities.schema';
|
||||
export * from './connected-accounts.schema';
|
||||
|
|
|
|||
27
apps/contacts/apps/backend/src/export/dto/export.dto.ts
Normal file
27
apps/contacts/apps/backend/src/export/dto/export.dto.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { IsEnum, IsOptional, IsArray, IsUUID } from 'class-validator';
|
||||
|
||||
export type ExportFormat = 'vcard' | 'csv';
|
||||
|
||||
export class ExportRequestDto {
|
||||
@IsEnum(['vcard', 'csv'])
|
||||
format: ExportFormat;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsUUID('4', { each: true })
|
||||
contactIds?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
groupId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
tagId?: string;
|
||||
|
||||
@IsOptional()
|
||||
includeFavorites?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
includeArchived?: boolean;
|
||||
}
|
||||
53
apps/contacts/apps/backend/src/export/export.controller.ts
Normal file
53
apps/contacts/apps/backend/src/export/export.controller.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { Controller, Get, Post, Body, Query, Res, UseGuards } from '@nestjs/common';
|
||||
import { Response } from 'express';
|
||||
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||
import { ExportService } from './export.service';
|
||||
import { ExportRequestDto, ExportFormat } from './dto/export.dto';
|
||||
|
||||
@Controller('api/v1/export')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ExportController {
|
||||
constructor(private readonly exportService: ExportService) {}
|
||||
|
||||
/**
|
||||
* Export contacts via POST with options in body
|
||||
*/
|
||||
@Post()
|
||||
async exportContacts(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Body() exportRequest: ExportRequestDto,
|
||||
@Res() res: Response
|
||||
) {
|
||||
const result = await this.exportService.exportContacts(user.userId, exportRequest);
|
||||
|
||||
res.setHeader('Content-Type', result.mimeType);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${result.filename}"`);
|
||||
res.setHeader('X-Contact-Count', result.contactCount.toString());
|
||||
res.send(result.data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick export all contacts via GET
|
||||
*/
|
||||
@Get()
|
||||
async quickExport(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Res() res: Response,
|
||||
@Query('format') format: ExportFormat = 'vcard',
|
||||
@Query('favorites') favorites?: string,
|
||||
@Query('archived') archived?: string
|
||||
) {
|
||||
const exportRequest: ExportRequestDto = {
|
||||
format,
|
||||
includeFavorites: favorites === 'true' ? true : favorites === 'false' ? false : undefined,
|
||||
includeArchived: archived === 'true',
|
||||
};
|
||||
|
||||
const result = await this.exportService.exportContacts(user.userId, exportRequest);
|
||||
|
||||
res.setHeader('Content-Type', result.mimeType);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${result.filename}"`);
|
||||
res.setHeader('X-Contact-Count', result.contactCount.toString());
|
||||
res.send(result.data);
|
||||
}
|
||||
}
|
||||
10
apps/contacts/apps/backend/src/export/export.module.ts
Normal file
10
apps/contacts/apps/backend/src/export/export.module.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ExportController } from './export.controller';
|
||||
import { ExportService } from './export.service';
|
||||
|
||||
@Module({
|
||||
controllers: [ExportController],
|
||||
providers: [ExportService],
|
||||
exports: [ExportService],
|
||||
})
|
||||
export class ExportModule {}
|
||||
140
apps/contacts/apps/backend/src/export/export.service.ts
Normal file
140
apps/contacts/apps/backend/src/export/export.service.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import { eq, and, inArray } from 'drizzle-orm';
|
||||
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||
import { type Database } from '../db/connection';
|
||||
import { contacts, type Contact } from '../db/schema';
|
||||
import { contactToGroups, contactToTags } from '../db/schema';
|
||||
import { ExportRequestDto, ExportFormat } from './dto/export.dto';
|
||||
import { generateVCardFile } from './generators/vcard.generator';
|
||||
import { generateCsvFile } from './generators/csv.generator';
|
||||
|
||||
export interface ExportResult {
|
||||
data: string;
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
contactCount: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ExportService {
|
||||
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
|
||||
|
||||
/**
|
||||
* Export contacts based on the request options
|
||||
*/
|
||||
async exportContacts(userId: string, options: ExportRequestDto): Promise<ExportResult> {
|
||||
// Get contacts based on filters
|
||||
const contactList = await this.getContactsForExport(userId, options);
|
||||
|
||||
// Generate export data
|
||||
const { data, mimeType, extension } = this.generateExportData(contactList, options.format);
|
||||
|
||||
// Generate filename
|
||||
const timestamp = new Date().toISOString().slice(0, 10);
|
||||
const filename = `contacts-${timestamp}.${extension}`;
|
||||
|
||||
return {
|
||||
data,
|
||||
filename,
|
||||
mimeType,
|
||||
contactCount: contactList.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get contacts based on export options
|
||||
*/
|
||||
private async getContactsForExport(
|
||||
userId: string,
|
||||
options: ExportRequestDto
|
||||
): Promise<Contact[]> {
|
||||
const { contactIds, groupId, tagId, includeFavorites, includeArchived = false } = options;
|
||||
|
||||
// If specific contact IDs are provided, fetch those
|
||||
if (contactIds && contactIds.length > 0) {
|
||||
return this.db
|
||||
.select()
|
||||
.from(contacts)
|
||||
.where(and(eq(contacts.userId, userId), inArray(contacts.id, contactIds)));
|
||||
}
|
||||
|
||||
// If a group is specified, get contacts in that group
|
||||
if (groupId) {
|
||||
const groupContacts = await this.db
|
||||
.select({ contactId: contactToGroups.contactId })
|
||||
.from(contactToGroups)
|
||||
.where(eq(contactToGroups.groupId, groupId));
|
||||
|
||||
const contactIdsInGroup = groupContacts.map((gc) => gc.contactId);
|
||||
|
||||
if (contactIdsInGroup.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.db
|
||||
.select()
|
||||
.from(contacts)
|
||||
.where(and(eq(contacts.userId, userId), inArray(contacts.id, contactIdsInGroup)));
|
||||
}
|
||||
|
||||
// If a tag is specified, get contacts with that tag
|
||||
if (tagId) {
|
||||
const taggedContacts = await this.db
|
||||
.select({ contactId: contactToTags.contactId })
|
||||
.from(contactToTags)
|
||||
.where(eq(contactToTags.tagId, tagId));
|
||||
|
||||
const contactIdsWithTag = taggedContacts.map((tc) => tc.contactId);
|
||||
|
||||
if (contactIdsWithTag.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.db
|
||||
.select()
|
||||
.from(contacts)
|
||||
.where(and(eq(contacts.userId, userId), inArray(contacts.id, contactIdsWithTag)));
|
||||
}
|
||||
|
||||
// Default: get all contacts with optional filters
|
||||
let conditions = [eq(contacts.userId, userId)];
|
||||
|
||||
if (!includeArchived) {
|
||||
conditions.push(eq(contacts.isArchived, false));
|
||||
}
|
||||
|
||||
if (includeFavorites !== undefined) {
|
||||
conditions.push(eq(contacts.isFavorite, includeFavorites));
|
||||
}
|
||||
|
||||
return this.db
|
||||
.select()
|
||||
.from(contacts)
|
||||
.where(and(...conditions));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate export data in the specified format
|
||||
*/
|
||||
private generateExportData(
|
||||
contactList: Contact[],
|
||||
format: ExportFormat
|
||||
): { data: string; mimeType: string; extension: string } {
|
||||
switch (format) {
|
||||
case 'vcard':
|
||||
return {
|
||||
data: generateVCardFile(contactList),
|
||||
mimeType: 'text/vcard',
|
||||
extension: 'vcf',
|
||||
};
|
||||
case 'csv':
|
||||
return {
|
||||
data: generateCsvFile(contactList),
|
||||
mimeType: 'text/csv',
|
||||
extension: 'csv',
|
||||
};
|
||||
default:
|
||||
throw new Error(`Unsupported export format: ${format}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
import { Contact } from '../../db/schema/contacts.schema';
|
||||
|
||||
/**
|
||||
* CSV column configuration
|
||||
*/
|
||||
const CSV_COLUMNS = [
|
||||
{ key: 'firstName', header: 'First Name' },
|
||||
{ key: 'lastName', header: 'Last Name' },
|
||||
{ key: 'displayName', header: 'Display Name' },
|
||||
{ key: 'nickname', header: 'Nickname' },
|
||||
{ key: 'email', header: 'Email' },
|
||||
{ key: 'phone', header: 'Phone' },
|
||||
{ key: 'mobile', header: 'Mobile' },
|
||||
{ key: 'company', header: 'Company' },
|
||||
{ key: 'jobTitle', header: 'Job Title' },
|
||||
{ key: 'department', header: 'Department' },
|
||||
{ key: 'street', header: 'Street' },
|
||||
{ key: 'city', header: 'City' },
|
||||
{ key: 'postalCode', header: 'Postal Code' },
|
||||
{ key: 'country', header: 'Country' },
|
||||
{ key: 'website', header: 'Website' },
|
||||
{ key: 'birthday', header: 'Birthday' },
|
||||
{ key: 'notes', header: 'Notes' },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Generate CSV file from contacts
|
||||
*/
|
||||
export function generateCsvFile(contacts: Contact[]): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
// Header row
|
||||
const headers = CSV_COLUMNS.map((col) => col.header);
|
||||
lines.push(headers.join(','));
|
||||
|
||||
// Data rows
|
||||
for (const contact of contacts) {
|
||||
const row = CSV_COLUMNS.map((col) => {
|
||||
const value = contact[col.key as keyof Contact];
|
||||
return escapeCsvValue(formatValue(value));
|
||||
});
|
||||
lines.push(row.join(','));
|
||||
}
|
||||
|
||||
return lines.join('\r\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a value for CSV output
|
||||
*/
|
||||
function formatValue(value: unknown): string {
|
||||
if (value === null || value === undefined) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (value instanceof Date) {
|
||||
return value.toISOString().slice(0, 10); // YYYY-MM-DD
|
||||
}
|
||||
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? 'true' : 'false';
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
return String(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape a value for CSV (RFC 4180)
|
||||
*/
|
||||
function escapeCsvValue(value: string): string {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// If the value contains comma, quote, or newline, wrap in quotes and escape quotes
|
||||
if (value.includes(',') || value.includes('"') || value.includes('\n') || value.includes('\r')) {
|
||||
return `"${value.replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
import { Contact } from '../../db/schema/contacts.schema';
|
||||
|
||||
/**
|
||||
* Generates vCard 3.0 format
|
||||
* Reference: https://www.rfc-editor.org/rfc/rfc2426
|
||||
*/
|
||||
export function generateVCard(contact: Contact): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push('BEGIN:VCARD');
|
||||
lines.push('VERSION:3.0');
|
||||
|
||||
// Name
|
||||
const lastName = contact.lastName || '';
|
||||
const firstName = contact.firstName || '';
|
||||
lines.push(`N:${escapeVCardValue(lastName)};${escapeVCardValue(firstName)};;;`);
|
||||
|
||||
// Full name
|
||||
const fn =
|
||||
contact.displayName || [firstName, lastName].filter(Boolean).join(' ') || 'Unnamed Contact';
|
||||
lines.push(`FN:${escapeVCardValue(fn)}`);
|
||||
|
||||
// Nickname
|
||||
if (contact.nickname) {
|
||||
lines.push(`NICKNAME:${escapeVCardValue(contact.nickname)}`);
|
||||
}
|
||||
|
||||
// Organization
|
||||
if (contact.company || contact.department) {
|
||||
const org = [contact.company, contact.department].filter(Boolean).join(';');
|
||||
lines.push(`ORG:${escapeVCardValue(org)}`);
|
||||
}
|
||||
|
||||
// Job Title
|
||||
if (contact.jobTitle) {
|
||||
lines.push(`TITLE:${escapeVCardValue(contact.jobTitle)}`);
|
||||
}
|
||||
|
||||
// Email
|
||||
if (contact.email) {
|
||||
lines.push(`EMAIL;TYPE=INTERNET:${escapeVCardValue(contact.email)}`);
|
||||
}
|
||||
|
||||
// Phone
|
||||
if (contact.phone) {
|
||||
lines.push(`TEL;TYPE=WORK:${escapeVCardValue(contact.phone)}`);
|
||||
}
|
||||
|
||||
// Mobile
|
||||
if (contact.mobile) {
|
||||
lines.push(`TEL;TYPE=CELL:${escapeVCardValue(contact.mobile)}`);
|
||||
}
|
||||
|
||||
// Address
|
||||
if (contact.street || contact.city || contact.postalCode || contact.country) {
|
||||
const adr = [
|
||||
'', // PO Box
|
||||
'', // Extended address
|
||||
contact.street || '',
|
||||
contact.city || '',
|
||||
'', // Region
|
||||
contact.postalCode || '',
|
||||
contact.country || '',
|
||||
]
|
||||
.map(escapeVCardValue)
|
||||
.join(';');
|
||||
lines.push(`ADR;TYPE=HOME:${adr}`);
|
||||
}
|
||||
|
||||
// Website
|
||||
if (contact.website) {
|
||||
lines.push(`URL:${escapeVCardValue(contact.website)}`);
|
||||
}
|
||||
|
||||
// Birthday
|
||||
if (contact.birthday) {
|
||||
// Format: YYYYMMDD - birthday is stored as string (date type in DB)
|
||||
const bday = String(contact.birthday).replace(/-/g, '');
|
||||
lines.push(`BDAY:${bday}`);
|
||||
}
|
||||
|
||||
// Notes
|
||||
if (contact.notes) {
|
||||
lines.push(`NOTE:${escapeVCardValue(contact.notes)}`);
|
||||
}
|
||||
|
||||
// Photo URL
|
||||
if (contact.photoUrl) {
|
||||
lines.push(`PHOTO;VALUE=URI:${escapeVCardValue(contact.photoUrl)}`);
|
||||
}
|
||||
|
||||
// UID
|
||||
lines.push(`UID:${contact.id}`);
|
||||
|
||||
// Revision timestamp
|
||||
const rev = contact.updatedAt?.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z';
|
||||
lines.push(`REV:${rev}`);
|
||||
|
||||
lines.push('END:VCARD');
|
||||
|
||||
return lines.join('\r\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate multiple vCards as a single file
|
||||
*/
|
||||
export function generateVCardFile(contacts: Contact[]): string {
|
||||
return contacts.map((contact) => generateVCard(contact)).join('\r\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape special characters for vCard values
|
||||
*/
|
||||
function escapeVCardValue(value: string): string {
|
||||
if (!value) return '';
|
||||
|
||||
return value
|
||||
.replace(/\\/g, '\\\\') // Escape backslashes first
|
||||
.replace(/;/g, '\\;') // Escape semicolons
|
||||
.replace(/,/g, '\\,') // Escape commas
|
||||
.replace(/\n/g, '\\n'); // Escape newlines
|
||||
}
|
||||
95
apps/contacts/apps/backend/src/google/dto/google.dto.ts
Normal file
95
apps/contacts/apps/backend/src/google/dto/google.dto.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import { IsString, IsOptional, IsArray, IsBoolean } from 'class-validator';
|
||||
|
||||
export class GoogleCallbackDto {
|
||||
@IsString()
|
||||
code: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
state?: string;
|
||||
}
|
||||
|
||||
export class GoogleImportDto {
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
resourceNames?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
all?: boolean;
|
||||
}
|
||||
|
||||
export interface GoogleContact {
|
||||
resourceName: string;
|
||||
etag?: string;
|
||||
names?: Array<{
|
||||
displayName?: string;
|
||||
familyName?: string;
|
||||
givenName?: string;
|
||||
middleName?: string;
|
||||
}>;
|
||||
emailAddresses?: Array<{
|
||||
value?: string;
|
||||
type?: string;
|
||||
}>;
|
||||
phoneNumbers?: Array<{
|
||||
value?: string;
|
||||
type?: string;
|
||||
}>;
|
||||
addresses?: Array<{
|
||||
streetAddress?: string;
|
||||
city?: string;
|
||||
postalCode?: string;
|
||||
country?: string;
|
||||
type?: string;
|
||||
}>;
|
||||
organizations?: Array<{
|
||||
name?: string;
|
||||
title?: string;
|
||||
department?: string;
|
||||
}>;
|
||||
urls?: Array<{
|
||||
value?: string;
|
||||
type?: string;
|
||||
}>;
|
||||
birthdays?: Array<{
|
||||
date?: {
|
||||
year?: number;
|
||||
month?: number;
|
||||
day?: number;
|
||||
};
|
||||
}>;
|
||||
biographies?: Array<{
|
||||
value?: string;
|
||||
}>;
|
||||
photos?: Array<{
|
||||
url?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface GoogleAuthUrlResponse {
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface GoogleContactsResponse {
|
||||
contacts: GoogleContact[];
|
||||
nextPageToken?: string;
|
||||
totalPeople?: number;
|
||||
}
|
||||
|
||||
export interface GoogleImportResult {
|
||||
imported: number;
|
||||
skipped: number;
|
||||
errors: Array<{
|
||||
resourceName: string;
|
||||
error: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ConnectedAccountResponse {
|
||||
id: string;
|
||||
provider: string;
|
||||
providerEmail: string | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
74
apps/contacts/apps/backend/src/google/google.controller.ts
Normal file
74
apps/contacts/apps/backend/src/google/google.controller.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { Controller, Get, Post, Delete, Body, Query, UseGuards } from '@nestjs/common';
|
||||
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||
import { GoogleService } from './google.service';
|
||||
import { GoogleCallbackDto, GoogleImportDto } from './dto/google.dto';
|
||||
|
||||
@Controller('google')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class GoogleController {
|
||||
constructor(private readonly googleService: GoogleService) {}
|
||||
|
||||
/**
|
||||
* Get OAuth2 authorization URL
|
||||
*/
|
||||
@Get('auth-url')
|
||||
getAuthUrl(@Query('state') state?: string) {
|
||||
const url = this.googleService.getAuthUrl(state);
|
||||
return { url };
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle OAuth2 callback
|
||||
*/
|
||||
@Post('callback')
|
||||
async handleCallback(@CurrentUser() user: CurrentUserData, @Body() dto: GoogleCallbackDto) {
|
||||
const account = await this.googleService.handleCallback(user.userId, dto.code);
|
||||
return { success: true, account };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get connected account status
|
||||
*/
|
||||
@Get('status')
|
||||
async getStatus(@CurrentUser() user: CurrentUserData) {
|
||||
const account = await this.googleService.getConnectedAccount(user.userId);
|
||||
return {
|
||||
connected: !!account,
|
||||
account: account
|
||||
? {
|
||||
id: account.id,
|
||||
providerEmail: account.providerEmail,
|
||||
createdAt: account.createdAt,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect Google account
|
||||
*/
|
||||
@Delete('disconnect')
|
||||
async disconnect(@CurrentUser() user: CurrentUserData) {
|
||||
await this.googleService.disconnect(user.userId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch contacts from Google
|
||||
*/
|
||||
@Get('contacts')
|
||||
async fetchContacts(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Query('pageToken') pageToken?: string
|
||||
) {
|
||||
return this.googleService.fetchContacts(user.userId, pageToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* Import selected Google contacts
|
||||
*/
|
||||
@Post('import')
|
||||
async importContacts(@CurrentUser() user: CurrentUserData, @Body() dto: GoogleImportDto) {
|
||||
return this.googleService.importContacts(user.userId, dto.resourceNames, dto.all);
|
||||
}
|
||||
}
|
||||
10
apps/contacts/apps/backend/src/google/google.module.ts
Normal file
10
apps/contacts/apps/backend/src/google/google.module.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { GoogleController } from './google.controller';
|
||||
import { GoogleService } from './google.service';
|
||||
|
||||
@Module({
|
||||
controllers: [GoogleController],
|
||||
providers: [GoogleService],
|
||||
exports: [GoogleService],
|
||||
})
|
||||
export class GoogleModule {}
|
||||
389
apps/contacts/apps/backend/src/google/google.service.ts
Normal file
389
apps/contacts/apps/backend/src/google/google.service.ts
Normal file
|
|
@ -0,0 +1,389 @@
|
|||
import { Injectable, Inject, UnauthorizedException, BadRequestException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { google, people_v1, Auth } from 'googleapis';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||
import { type Database } from '../db/connection';
|
||||
import {
|
||||
connectedAccounts,
|
||||
type ConnectedAccount,
|
||||
type GoogleContactsProviderData,
|
||||
} from '../db/schema';
|
||||
import { contacts, type NewContact } from '../db/schema';
|
||||
import type {
|
||||
GoogleContact,
|
||||
GoogleContactsResponse,
|
||||
GoogleImportResult,
|
||||
ConnectedAccountResponse,
|
||||
} from './dto/google.dto';
|
||||
|
||||
const GOOGLE_SCOPES = [
|
||||
'https://www.googleapis.com/auth/contacts.readonly',
|
||||
'https://www.googleapis.com/auth/userinfo.email',
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class GoogleService {
|
||||
private oauth2Client: Auth.OAuth2Client;
|
||||
|
||||
constructor(
|
||||
@Inject(DATABASE_CONNECTION) private db: Database,
|
||||
private configService: ConfigService
|
||||
) {
|
||||
this.oauth2Client = new google.auth.OAuth2(
|
||||
this.configService.get('GOOGLE_CLIENT_ID'),
|
||||
this.configService.get('GOOGLE_CLIENT_SECRET'),
|
||||
this.configService.get('GOOGLE_REDIRECT_URI')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate OAuth2 authorization URL
|
||||
*/
|
||||
getAuthUrl(state?: string): string {
|
||||
return this.oauth2Client.generateAuthUrl({
|
||||
access_type: 'offline',
|
||||
scope: GOOGLE_SCOPES,
|
||||
prompt: 'consent',
|
||||
state,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange authorization code for tokens and store them
|
||||
*/
|
||||
async handleCallback(userId: string, code: string): Promise<ConnectedAccountResponse> {
|
||||
const { tokens } = await this.oauth2Client.getToken(code);
|
||||
|
||||
if (!tokens.access_token) {
|
||||
throw new BadRequestException('Failed to get access token from Google');
|
||||
}
|
||||
|
||||
// Get user info
|
||||
this.oauth2Client.setCredentials(tokens);
|
||||
const oauth2 = google.oauth2({ version: 'v2', auth: this.oauth2Client });
|
||||
const { data: userInfo } = await oauth2.userinfo.get();
|
||||
|
||||
// Check if already connected
|
||||
const existing = await this.getConnectedAccount(userId);
|
||||
|
||||
if (existing) {
|
||||
// Update existing connection
|
||||
const [updated] = await this.db
|
||||
.update(connectedAccounts)
|
||||
.set({
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token || existing.refreshToken,
|
||||
tokenExpiresAt: tokens.expiry_date ? new Date(tokens.expiry_date) : null,
|
||||
scope: tokens.scope || null,
|
||||
providerEmail: userInfo.email || null,
|
||||
providerAccountId: userInfo.id || null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(connectedAccounts.id, existing.id))
|
||||
.returning();
|
||||
|
||||
return this.toResponse(updated);
|
||||
}
|
||||
|
||||
// Create new connection
|
||||
const [account] = await this.db
|
||||
.insert(connectedAccounts)
|
||||
.values({
|
||||
userId,
|
||||
provider: 'google',
|
||||
providerAccountId: userInfo.id || null,
|
||||
providerEmail: userInfo.email || null,
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token || null,
|
||||
tokenExpiresAt: tokens.expiry_date ? new Date(tokens.expiry_date) : null,
|
||||
scope: tokens.scope || null,
|
||||
providerData: {},
|
||||
})
|
||||
.returning();
|
||||
|
||||
return this.toResponse(account);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get connected Google account for user
|
||||
*/
|
||||
async getConnectedAccount(userId: string): Promise<ConnectedAccount | null> {
|
||||
const [account] = await this.db
|
||||
.select()
|
||||
.from(connectedAccounts)
|
||||
.where(and(eq(connectedAccounts.userId, userId), eq(connectedAccounts.provider, 'google')));
|
||||
|
||||
return account || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect Google account
|
||||
*/
|
||||
async disconnect(userId: string): Promise<void> {
|
||||
const account = await this.getConnectedAccount(userId);
|
||||
|
||||
if (account) {
|
||||
// Revoke token
|
||||
try {
|
||||
await this.oauth2Client.revokeToken(account.accessToken);
|
||||
} catch {
|
||||
// Ignore revoke errors
|
||||
}
|
||||
|
||||
// Delete from database
|
||||
await this.db
|
||||
.delete(connectedAccounts)
|
||||
.where(and(eq(connectedAccounts.userId, userId), eq(connectedAccounts.provider, 'google')));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch contacts from Google
|
||||
*/
|
||||
async fetchContacts(userId: string, pageToken?: string): Promise<GoogleContactsResponse> {
|
||||
const account = await this.getConnectedAccount(userId);
|
||||
if (!account) {
|
||||
throw new UnauthorizedException('Google account not connected');
|
||||
}
|
||||
|
||||
// Ensure token is valid
|
||||
await this.ensureValidToken(account);
|
||||
|
||||
this.oauth2Client.setCredentials({
|
||||
access_token: account.accessToken,
|
||||
refresh_token: account.refreshToken,
|
||||
});
|
||||
|
||||
const peopleService = google.people({ version: 'v1', auth: this.oauth2Client });
|
||||
|
||||
const response = await peopleService.people.connections.list({
|
||||
resourceName: 'people/me',
|
||||
pageSize: 100,
|
||||
pageToken,
|
||||
personFields:
|
||||
'names,emailAddresses,phoneNumbers,addresses,organizations,urls,birthdays,biographies,photos',
|
||||
});
|
||||
|
||||
const googleContacts: GoogleContact[] = (response.data.connections || []).map((person) => ({
|
||||
resourceName: person.resourceName || '',
|
||||
etag: person.etag || undefined,
|
||||
names: person.names?.map((n) => ({
|
||||
displayName: n.displayName || undefined,
|
||||
familyName: n.familyName || undefined,
|
||||
givenName: n.givenName || undefined,
|
||||
middleName: n.middleName || undefined,
|
||||
})),
|
||||
emailAddresses: person.emailAddresses?.map((e) => ({
|
||||
value: e.value || undefined,
|
||||
type: e.type || undefined,
|
||||
})),
|
||||
phoneNumbers: person.phoneNumbers?.map((p) => ({
|
||||
value: p.value || undefined,
|
||||
type: p.type || undefined,
|
||||
})),
|
||||
addresses: person.addresses?.map((a) => ({
|
||||
streetAddress: a.streetAddress || undefined,
|
||||
city: a.city || undefined,
|
||||
postalCode: a.postalCode || undefined,
|
||||
country: a.country || undefined,
|
||||
type: a.type || undefined,
|
||||
})),
|
||||
organizations: person.organizations?.map((o) => ({
|
||||
name: o.name || undefined,
|
||||
title: o.title || undefined,
|
||||
department: o.department || undefined,
|
||||
})),
|
||||
urls: person.urls?.map((u) => ({
|
||||
value: u.value || undefined,
|
||||
type: u.type || undefined,
|
||||
})),
|
||||
birthdays: person.birthdays?.map((b) => ({
|
||||
date: b.date
|
||||
? {
|
||||
year: b.date.year || undefined,
|
||||
month: b.date.month || undefined,
|
||||
day: b.date.day || undefined,
|
||||
}
|
||||
: undefined,
|
||||
})),
|
||||
biographies: person.biographies?.map((bio) => ({
|
||||
value: bio.value || undefined,
|
||||
})),
|
||||
photos: person.photos?.map((p) => ({
|
||||
url: p.url || undefined,
|
||||
})),
|
||||
}));
|
||||
|
||||
return {
|
||||
contacts: googleContacts,
|
||||
nextPageToken: response.data.nextPageToken || undefined,
|
||||
totalPeople: response.data.totalPeople || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Import selected Google contacts
|
||||
*/
|
||||
async importContacts(
|
||||
userId: string,
|
||||
resourceNames?: string[],
|
||||
importAll = false
|
||||
): Promise<GoogleImportResult> {
|
||||
const result: GoogleImportResult = {
|
||||
imported: 0,
|
||||
skipped: 0,
|
||||
errors: [],
|
||||
};
|
||||
|
||||
// Fetch all contacts if importAll
|
||||
let contactsToImport: GoogleContact[] = [];
|
||||
|
||||
if (importAll) {
|
||||
let pageToken: string | undefined;
|
||||
do {
|
||||
const response = await this.fetchContacts(userId, pageToken);
|
||||
contactsToImport.push(...response.contacts);
|
||||
pageToken = response.nextPageToken;
|
||||
} while (pageToken);
|
||||
} else if (resourceNames && resourceNames.length > 0) {
|
||||
const response = await this.fetchContacts(userId);
|
||||
contactsToImport = response.contacts.filter((c) => resourceNames.includes(c.resourceName));
|
||||
}
|
||||
|
||||
// Import each contact
|
||||
for (const googleContact of contactsToImport) {
|
||||
try {
|
||||
const contactData = this.mapGoogleContactToContact(googleContact, userId);
|
||||
|
||||
// Check for duplicates by email
|
||||
if (contactData.email) {
|
||||
const [existing] = await this.db
|
||||
.select()
|
||||
.from(contacts)
|
||||
.where(and(eq(contacts.userId, userId), eq(contacts.email, contactData.email)));
|
||||
|
||||
if (existing) {
|
||||
result.skipped++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
await this.db.insert(contacts).values(contactData);
|
||||
result.imported++;
|
||||
} catch (error) {
|
||||
result.errors.push({
|
||||
resourceName: googleContact.resourceName,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Update provider data with imported resource names
|
||||
const account = await this.getConnectedAccount(userId);
|
||||
if (account) {
|
||||
const providerData = (account.providerData as GoogleContactsProviderData) || {};
|
||||
const importedNames = contactsToImport.map((c) => c.resourceName);
|
||||
|
||||
await this.db
|
||||
.update(connectedAccounts)
|
||||
.set({
|
||||
providerData: {
|
||||
...providerData,
|
||||
lastSyncedAt: new Date().toISOString(),
|
||||
importedResourceNames: [
|
||||
...(providerData.importedResourceNames || []),
|
||||
...importedNames,
|
||||
],
|
||||
},
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(connectedAccounts.id, account.id));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure OAuth token is valid, refresh if needed
|
||||
*/
|
||||
private async ensureValidToken(account: ConnectedAccount): Promise<void> {
|
||||
if (account.tokenExpiresAt && new Date() >= account.tokenExpiresAt) {
|
||||
if (!account.refreshToken) {
|
||||
throw new UnauthorizedException('Token expired and no refresh token available');
|
||||
}
|
||||
|
||||
this.oauth2Client.setCredentials({
|
||||
refresh_token: account.refreshToken,
|
||||
});
|
||||
|
||||
const { credentials } = await this.oauth2Client.refreshAccessToken();
|
||||
|
||||
await this.db
|
||||
.update(connectedAccounts)
|
||||
.set({
|
||||
accessToken: credentials.access_token!,
|
||||
tokenExpiresAt: credentials.expiry_date ? new Date(credentials.expiry_date) : null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(connectedAccounts.id, account.id));
|
||||
|
||||
account.accessToken = credentials.access_token!;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Google contact to our contact schema
|
||||
*/
|
||||
private mapGoogleContactToContact(googleContact: GoogleContact, userId: string): NewContact {
|
||||
const name = googleContact.names?.[0];
|
||||
const email = googleContact.emailAddresses?.[0];
|
||||
const phone = googleContact.phoneNumbers?.find((p) => p.type !== 'mobile');
|
||||
const mobile = googleContact.phoneNumbers?.find((p) => p.type === 'mobile');
|
||||
const address = googleContact.addresses?.[0];
|
||||
const org = googleContact.organizations?.[0];
|
||||
const website = googleContact.urls?.[0];
|
||||
const birthday = googleContact.birthdays?.[0];
|
||||
const bio = googleContact.biographies?.[0];
|
||||
const photo = googleContact.photos?.[0];
|
||||
|
||||
let birthdayStr: string | undefined;
|
||||
if (birthday?.date?.year && birthday?.date?.month && birthday?.date?.day) {
|
||||
birthdayStr = `${birthday.date.year}-${String(birthday.date.month).padStart(2, '0')}-${String(birthday.date.day).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
return {
|
||||
userId,
|
||||
createdBy: userId,
|
||||
firstName: name?.givenName || null,
|
||||
lastName: name?.familyName || null,
|
||||
displayName: name?.displayName || null,
|
||||
email: email?.value || null,
|
||||
phone: phone?.value || null,
|
||||
mobile: mobile?.value || googleContact.phoneNumbers?.[0]?.value || null,
|
||||
street: address?.streetAddress || null,
|
||||
city: address?.city || null,
|
||||
postalCode: address?.postalCode || null,
|
||||
country: address?.country || null,
|
||||
company: org?.name || null,
|
||||
jobTitle: org?.title || null,
|
||||
department: org?.department || null,
|
||||
website: website?.value || null,
|
||||
birthday: birthdayStr || null,
|
||||
notes: bio?.value || null,
|
||||
photoUrl: photo?.url || null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to response DTO
|
||||
*/
|
||||
private toResponse(account: ConnectedAccount): ConnectedAccountResponse {
|
||||
return {
|
||||
id: account.id,
|
||||
provider: account.provider,
|
||||
providerEmail: account.providerEmail,
|
||||
createdAt: account.createdAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
122
apps/contacts/apps/backend/src/import/dto/import.dto.ts
Normal file
122
apps/contacts/apps/backend/src/import/dto/import.dto.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import { IsEnum, IsOptional, IsArray, ValidateNested, IsString, IsEmail } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class ParsedContactDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
firstName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
lastName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
displayName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
nickname?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
email?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
phone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
mobile?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
street?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
city?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
postalCode?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
country?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
company?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
jobTitle?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
department?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
website?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
birthday?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
photoUrl?: string;
|
||||
}
|
||||
|
||||
export type DuplicateAction = 'skip' | 'merge' | 'create';
|
||||
|
||||
export class DuplicateInfo {
|
||||
importIndex: number;
|
||||
existingContactId: string;
|
||||
existingContactName: string;
|
||||
matchField: 'email' | 'phone';
|
||||
matchValue: string;
|
||||
}
|
||||
|
||||
export class ImportPreviewResponseDto {
|
||||
contacts: ParsedContactDto[];
|
||||
duplicates: DuplicateInfo[];
|
||||
totalParsed: number;
|
||||
validCount: number;
|
||||
invalidCount: number;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export class ExecuteImportDto {
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => ParsedContactDto)
|
||||
contacts: ParsedContactDto[];
|
||||
|
||||
@IsEnum(['skip', 'merge', 'create'])
|
||||
duplicateAction: DuplicateAction;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
skipIndices?: number[];
|
||||
}
|
||||
|
||||
export class ImportResultDto {
|
||||
imported: number;
|
||||
skipped: number;
|
||||
merged: number;
|
||||
errors: ImportErrorDto[];
|
||||
}
|
||||
|
||||
export class ImportErrorDto {
|
||||
index: number;
|
||||
contactName: string;
|
||||
error: string;
|
||||
}
|
||||
80
apps/contacts/apps/backend/src/import/import.controller.ts
Normal file
80
apps/contacts/apps/backend/src/import/import.controller.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Get,
|
||||
Body,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
UploadedFile,
|
||||
BadRequestException,
|
||||
Res,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { Response } from 'express';
|
||||
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||
import { ImportService } from './import.service';
|
||||
import { ExecuteImportDto } from './dto/import.dto';
|
||||
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
|
||||
const ALLOWED_EXTENSIONS = ['.vcf', '.vcard', '.csv'];
|
||||
|
||||
@Controller('import')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ImportController {
|
||||
constructor(private readonly importService: ImportService) {}
|
||||
|
||||
/**
|
||||
* Preview import from uploaded file
|
||||
*/
|
||||
@Post('preview')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
limits: { fileSize: MAX_FILE_SIZE },
|
||||
fileFilter: (req, file, callback) => {
|
||||
const ext = '.' + file.originalname.split('.').pop()?.toLowerCase();
|
||||
if (!ALLOWED_EXTENSIONS.includes(ext)) {
|
||||
callback(
|
||||
new BadRequestException(`Invalid file type. Allowed: ${ALLOWED_EXTENSIONS.join(', ')}`),
|
||||
false
|
||||
);
|
||||
return;
|
||||
}
|
||||
callback(null, true);
|
||||
},
|
||||
})
|
||||
)
|
||||
async previewImport(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@UploadedFile() file: Express.Multer.File
|
||||
) {
|
||||
if (!file) {
|
||||
throw new BadRequestException('No file uploaded');
|
||||
}
|
||||
|
||||
return this.importService.preview(user.userId, file);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the import with selected options
|
||||
*/
|
||||
@Post('execute')
|
||||
async executeImport(@CurrentUser() user: CurrentUserData, @Body() dto: ExecuteImportDto) {
|
||||
return this.importService.execute(user.userId, dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download CSV template
|
||||
*/
|
||||
@Get('template/csv')
|
||||
async getCsvTemplate(@Res() res: Response) {
|
||||
const template = this.importService.getCsvTemplate();
|
||||
|
||||
res.set({
|
||||
'Content-Type': 'text/csv; charset=utf-8',
|
||||
'Content-Disposition': 'attachment; filename="contacts-template.csv"',
|
||||
});
|
||||
|
||||
res.send(template);
|
||||
}
|
||||
}
|
||||
10
apps/contacts/apps/backend/src/import/import.module.ts
Normal file
10
apps/contacts/apps/backend/src/import/import.module.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ImportController } from './import.controller';
|
||||
import { ImportService } from './import.service';
|
||||
|
||||
@Module({
|
||||
controllers: [ImportController],
|
||||
providers: [ImportService],
|
||||
exports: [ImportService],
|
||||
})
|
||||
export class ImportModule {}
|
||||
227
apps/contacts/apps/backend/src/import/import.service.ts
Normal file
227
apps/contacts/apps/backend/src/import/import.service.ts
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
import { Injectable, Inject, BadRequestException } from '@nestjs/common';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||
import { type Database } from '../db/connection';
|
||||
import { contacts, type Contact, type NewContact } from '../db/schema';
|
||||
import { VCardParser } from './parsers/vcard.parser';
|
||||
import { CsvParser, CsvFieldMapping } from './parsers/csv.parser';
|
||||
import { DuplicateDetector } from './utils/duplicate-detector';
|
||||
import {
|
||||
ParsedContactDto,
|
||||
ImportPreviewResponseDto,
|
||||
ExecuteImportDto,
|
||||
ImportResultDto,
|
||||
ImportErrorDto,
|
||||
DuplicateInfo,
|
||||
} from './dto/import.dto';
|
||||
|
||||
@Injectable()
|
||||
export class ImportService {
|
||||
private vcardParser = new VCardParser();
|
||||
private csvParser = new CsvParser();
|
||||
private duplicateDetector = new DuplicateDetector();
|
||||
|
||||
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
|
||||
|
||||
/**
|
||||
* Preview import from uploaded file
|
||||
*/
|
||||
async preview(
|
||||
userId: string,
|
||||
file: Express.Multer.File
|
||||
): Promise<ImportPreviewResponseDto & { fieldMapping?: CsvFieldMapping[] }> {
|
||||
const content = file.buffer.toString('utf-8');
|
||||
const extension = this.getFileExtension(file.originalname);
|
||||
|
||||
let parsedContacts: ParsedContactDto[] = [];
|
||||
let parseErrors: string[] = [];
|
||||
let fieldMapping: CsvFieldMapping[] | undefined;
|
||||
|
||||
// Parse based on file type
|
||||
if (extension === 'vcf' || extension === 'vcard') {
|
||||
const result = this.vcardParser.parse(content);
|
||||
parsedContacts = result.contacts;
|
||||
parseErrors = result.errors;
|
||||
} else if (extension === 'csv') {
|
||||
const result = this.csvParser.parse(content);
|
||||
parsedContacts = result.contacts;
|
||||
parseErrors = result.errors;
|
||||
fieldMapping = result.fieldMapping;
|
||||
} else {
|
||||
throw new BadRequestException(
|
||||
`Unsupported file type: .${extension}. Use .vcf or .csv files.`
|
||||
);
|
||||
}
|
||||
|
||||
// Validate contacts
|
||||
const { valid, invalid } = this.validateContacts(parsedContacts);
|
||||
|
||||
// Fetch existing contacts for duplicate detection
|
||||
const existingContacts = await this.db
|
||||
.select()
|
||||
.from(contacts)
|
||||
.where(eq(contacts.userId, userId));
|
||||
|
||||
// Detect duplicates
|
||||
const duplicates = this.duplicateDetector.detectDuplicates(valid, existingContacts);
|
||||
|
||||
return {
|
||||
contacts: valid,
|
||||
duplicates,
|
||||
totalParsed: parsedContacts.length,
|
||||
validCount: valid.length,
|
||||
invalidCount: invalid.length,
|
||||
errors: parseErrors,
|
||||
fieldMapping,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the import with the selected options
|
||||
*/
|
||||
async execute(userId: string, dto: ExecuteImportDto): Promise<ImportResultDto> {
|
||||
const result: ImportResultDto = {
|
||||
imported: 0,
|
||||
skipped: 0,
|
||||
merged: 0,
|
||||
errors: [],
|
||||
};
|
||||
|
||||
// Build skip set for fast lookup
|
||||
const skipSet = new Set(dto.skipIndices || []);
|
||||
|
||||
// Fetch existing contacts for merge operations
|
||||
let existingContactsMap = new Map<string, Contact>();
|
||||
if (dto.duplicateAction === 'merge') {
|
||||
const existingContacts = await this.db
|
||||
.select()
|
||||
.from(contacts)
|
||||
.where(eq(contacts.userId, userId));
|
||||
|
||||
const duplicates = this.duplicateDetector.detectDuplicates(dto.contacts, existingContacts);
|
||||
|
||||
for (const dup of duplicates) {
|
||||
const existing = existingContacts.find((c) => c.id === dup.existingContactId);
|
||||
if (existing) {
|
||||
existingContactsMap.set(dup.importIndex.toString(), existing);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process each contact
|
||||
for (let i = 0; i < dto.contacts.length; i++) {
|
||||
if (skipSet.has(i)) {
|
||||
result.skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const contact = dto.contacts[i];
|
||||
|
||||
try {
|
||||
// Check if this is a duplicate that needs handling
|
||||
const existingContact = existingContactsMap.get(i.toString());
|
||||
|
||||
if (existingContact && dto.duplicateAction === 'merge') {
|
||||
// Merge: Update existing contact with new data
|
||||
const updates = this.duplicateDetector.mergeContacts(existingContact, contact);
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await this.db
|
||||
.update(contacts)
|
||||
.set({ ...updates, updatedAt: new Date() })
|
||||
.where(eq(contacts.id, existingContact.id));
|
||||
}
|
||||
|
||||
result.merged++;
|
||||
} else if (existingContact && dto.duplicateAction === 'skip') {
|
||||
// Skip: Don't import
|
||||
result.skipped++;
|
||||
} else {
|
||||
// Create new contact
|
||||
const newContact: NewContact = {
|
||||
userId,
|
||||
createdBy: userId,
|
||||
firstName: contact.firstName,
|
||||
lastName: contact.lastName,
|
||||
displayName: contact.displayName,
|
||||
nickname: contact.nickname,
|
||||
email: contact.email,
|
||||
phone: contact.phone,
|
||||
mobile: contact.mobile,
|
||||
street: contact.street,
|
||||
city: contact.city,
|
||||
postalCode: contact.postalCode,
|
||||
country: contact.country,
|
||||
company: contact.company,
|
||||
jobTitle: contact.jobTitle,
|
||||
department: contact.department,
|
||||
website: contact.website,
|
||||
birthday: contact.birthday,
|
||||
notes: contact.notes,
|
||||
photoUrl: contact.photoUrl,
|
||||
};
|
||||
|
||||
await this.db.insert(contacts).values(newContact);
|
||||
result.imported++;
|
||||
}
|
||||
} catch (error) {
|
||||
result.errors.push({
|
||||
index: i,
|
||||
contactName: this.getContactName(contact),
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate CSV template
|
||||
*/
|
||||
getCsvTemplate(): string {
|
||||
return CsvParser.generateTemplate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file extension from filename
|
||||
*/
|
||||
private getFileExtension(filename: string): string {
|
||||
const parts = filename.toLowerCase().split('.');
|
||||
return parts[parts.length - 1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate contacts - separate valid from invalid
|
||||
*/
|
||||
private validateContacts(parsedContacts: ParsedContactDto[]): {
|
||||
valid: ParsedContactDto[];
|
||||
invalid: ParsedContactDto[];
|
||||
} {
|
||||
const valid: ParsedContactDto[] = [];
|
||||
const invalid: ParsedContactDto[] = [];
|
||||
|
||||
for (const contact of parsedContacts) {
|
||||
// A contact is valid if it has at least a name or email
|
||||
if (contact.firstName || contact.lastName || contact.email || contact.displayName) {
|
||||
valid.push(contact);
|
||||
} else {
|
||||
invalid.push(contact);
|
||||
}
|
||||
}
|
||||
|
||||
return { valid, invalid };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display name for a contact
|
||||
*/
|
||||
private getContactName(contact: ParsedContactDto): string {
|
||||
if (contact.displayName) return contact.displayName;
|
||||
if (contact.firstName || contact.lastName) {
|
||||
return [contact.firstName, contact.lastName].filter(Boolean).join(' ');
|
||||
}
|
||||
if (contact.email) return contact.email;
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
318
apps/contacts/apps/backend/src/import/parsers/csv.parser.ts
Normal file
318
apps/contacts/apps/backend/src/import/parsers/csv.parser.ts
Normal file
|
|
@ -0,0 +1,318 @@
|
|||
import { parse } from 'csv-parse/sync';
|
||||
import { ParsedContactDto } from '../dto/import.dto';
|
||||
|
||||
// Common header variations mapped to our fields
|
||||
const HEADER_MAPPINGS: Record<string, keyof ParsedContactDto> = {
|
||||
// First Name
|
||||
'first name': 'firstName',
|
||||
first_name: 'firstName',
|
||||
firstname: 'firstName',
|
||||
'given name': 'firstName',
|
||||
given_name: 'firstName',
|
||||
vorname: 'firstName',
|
||||
|
||||
// Last Name
|
||||
'last name': 'lastName',
|
||||
last_name: 'lastName',
|
||||
lastname: 'lastName',
|
||||
surname: 'lastName',
|
||||
'family name': 'lastName',
|
||||
family_name: 'lastName',
|
||||
nachname: 'lastName',
|
||||
|
||||
// Display Name
|
||||
'display name': 'displayName',
|
||||
display_name: 'displayName',
|
||||
displayname: 'displayName',
|
||||
'full name': 'displayName',
|
||||
name: 'displayName',
|
||||
anzeigename: 'displayName',
|
||||
|
||||
// Nickname
|
||||
nickname: 'nickname',
|
||||
nick: 'nickname',
|
||||
spitzname: 'nickname',
|
||||
|
||||
// Email
|
||||
email: 'email',
|
||||
'e-mail': 'email',
|
||||
'email address': 'email',
|
||||
mail: 'email',
|
||||
|
||||
// Phone
|
||||
phone: 'phone',
|
||||
telephone: 'phone',
|
||||
tel: 'phone',
|
||||
'phone number': 'phone',
|
||||
telefon: 'phone',
|
||||
|
||||
// Mobile
|
||||
mobile: 'mobile',
|
||||
'mobile phone': 'mobile',
|
||||
cell: 'mobile',
|
||||
'cell phone': 'mobile',
|
||||
cellphone: 'mobile',
|
||||
handy: 'mobile',
|
||||
mobil: 'mobile',
|
||||
|
||||
// Street
|
||||
street: 'street',
|
||||
'street address': 'street',
|
||||
address: 'street',
|
||||
strasse: 'street',
|
||||
straße: 'street',
|
||||
|
||||
// City
|
||||
city: 'city',
|
||||
town: 'city',
|
||||
stadt: 'city',
|
||||
ort: 'city',
|
||||
|
||||
// Postal Code
|
||||
'postal code': 'postalCode',
|
||||
postal_code: 'postalCode',
|
||||
postalcode: 'postalCode',
|
||||
zip: 'postalCode',
|
||||
'zip code': 'postalCode',
|
||||
zipcode: 'postalCode',
|
||||
plz: 'postalCode',
|
||||
postleitzahl: 'postalCode',
|
||||
|
||||
// Country
|
||||
country: 'country',
|
||||
land: 'country',
|
||||
|
||||
// Company
|
||||
company: 'company',
|
||||
organization: 'company',
|
||||
organisation: 'company',
|
||||
org: 'company',
|
||||
firma: 'company',
|
||||
unternehmen: 'company',
|
||||
|
||||
// Job Title
|
||||
'job title': 'jobTitle',
|
||||
job_title: 'jobTitle',
|
||||
jobtitle: 'jobTitle',
|
||||
title: 'jobTitle',
|
||||
position: 'jobTitle',
|
||||
rolle: 'jobTitle',
|
||||
|
||||
// Department
|
||||
department: 'department',
|
||||
dept: 'department',
|
||||
abteilung: 'department',
|
||||
|
||||
// Website
|
||||
website: 'website',
|
||||
url: 'website',
|
||||
'web site': 'website',
|
||||
homepage: 'website',
|
||||
webseite: 'website',
|
||||
|
||||
// Birthday
|
||||
birthday: 'birthday',
|
||||
'birth date': 'birthday',
|
||||
birthdate: 'birthday',
|
||||
dob: 'birthday',
|
||||
geburtstag: 'birthday',
|
||||
geburtsdatum: 'birthday',
|
||||
|
||||
// Notes
|
||||
notes: 'notes',
|
||||
note: 'notes',
|
||||
comments: 'notes',
|
||||
comment: 'notes',
|
||||
notizen: 'notes',
|
||||
bemerkungen: 'notes',
|
||||
};
|
||||
|
||||
export interface CsvFieldMapping {
|
||||
csvHeader: string;
|
||||
contactField: keyof ParsedContactDto | null;
|
||||
sampleValue: string;
|
||||
}
|
||||
|
||||
export class CsvParser {
|
||||
/**
|
||||
* Parse CSV content into contacts
|
||||
*/
|
||||
parse(content: string): {
|
||||
contacts: ParsedContactDto[];
|
||||
errors: string[];
|
||||
fieldMapping: CsvFieldMapping[];
|
||||
} {
|
||||
const errors: string[] = [];
|
||||
let records: Record<string, string>[];
|
||||
|
||||
try {
|
||||
records = parse(content, {
|
||||
columns: true,
|
||||
skip_empty_lines: true,
|
||||
trim: true,
|
||||
bom: true,
|
||||
relaxColumnCount: true,
|
||||
relaxQuotes: true,
|
||||
});
|
||||
} catch (error) {
|
||||
errors.push(`CSV parse error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
return { contacts: [], errors, fieldMapping: [] };
|
||||
}
|
||||
|
||||
if (records.length === 0) {
|
||||
return { contacts: [], errors: ['CSV file is empty'], fieldMapping: [] };
|
||||
}
|
||||
|
||||
// Detect field mapping from headers
|
||||
const headers = Object.keys(records[0]);
|
||||
const fieldMapping = this.detectFieldMapping(headers, records[0]);
|
||||
|
||||
// Parse contacts
|
||||
const contacts: ParsedContactDto[] = [];
|
||||
|
||||
for (let i = 0; i < records.length; i++) {
|
||||
try {
|
||||
const contact = this.mapRecordToContact(records[i], fieldMapping);
|
||||
if (
|
||||
contact &&
|
||||
(contact.firstName || contact.lastName || contact.email || contact.displayName)
|
||||
) {
|
||||
contacts.push(contact);
|
||||
}
|
||||
} catch (error) {
|
||||
errors.push(`Row ${i + 2}: ${error instanceof Error ? error.message : 'Parse error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { contacts, errors, fieldMapping };
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect field mapping based on header names
|
||||
*/
|
||||
private detectFieldMapping(
|
||||
headers: string[],
|
||||
sampleRecord: Record<string, string>
|
||||
): CsvFieldMapping[] {
|
||||
return headers.map((header) => {
|
||||
const normalizedHeader = header.toLowerCase().trim();
|
||||
const contactField = HEADER_MAPPINGS[normalizedHeader] || null;
|
||||
|
||||
return {
|
||||
csvHeader: header,
|
||||
contactField,
|
||||
sampleValue: sampleRecord[header] || '',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a CSV record to a ParsedContactDto
|
||||
*/
|
||||
private mapRecordToContact(
|
||||
record: Record<string, string>,
|
||||
fieldMapping: CsvFieldMapping[]
|
||||
): ParsedContactDto | null {
|
||||
const contact: ParsedContactDto = {};
|
||||
|
||||
for (const mapping of fieldMapping) {
|
||||
if (!mapping.contactField) continue;
|
||||
|
||||
const value = record[mapping.csvHeader]?.trim();
|
||||
if (!value) continue;
|
||||
|
||||
// Special handling for birthday
|
||||
if (mapping.contactField === 'birthday') {
|
||||
contact.birthday = this.parseBirthday(value);
|
||||
} else {
|
||||
contact[mapping.contactField] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate displayName if not set
|
||||
if (!contact.displayName && (contact.firstName || contact.lastName)) {
|
||||
contact.displayName = [contact.firstName, contact.lastName].filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
return contact;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse various birthday formats to ISO format
|
||||
*/
|
||||
private parseBirthday(value: string): string | undefined {
|
||||
// Try common formats
|
||||
const formats = [
|
||||
/^(\d{4})-(\d{2})-(\d{2})$/, // ISO: 2000-01-15
|
||||
/^(\d{2})\/(\d{2})\/(\d{4})$/, // US: 01/15/2000
|
||||
/^(\d{2})\.(\d{2})\.(\d{4})$/, // EU: 15.01.2000
|
||||
/^(\d{2})-(\d{2})-(\d{4})$/, // Alt: 15-01-2000
|
||||
];
|
||||
|
||||
// ISO format
|
||||
if (formats[0].test(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
// US format MM/DD/YYYY
|
||||
const usMatch = value.match(formats[1]);
|
||||
if (usMatch) {
|
||||
return `${usMatch[3]}-${usMatch[1]}-${usMatch[2]}`;
|
||||
}
|
||||
|
||||
// EU format DD.MM.YYYY or DD-MM-YYYY
|
||||
const euMatch = value.match(formats[2]) || value.match(formats[3]);
|
||||
if (euMatch) {
|
||||
return `${euMatch[3]}-${euMatch[2]}-${euMatch[1]}`;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a CSV template with all supported fields
|
||||
*/
|
||||
static generateTemplate(): string {
|
||||
const headers = [
|
||||
'First Name',
|
||||
'Last Name',
|
||||
'Display Name',
|
||||
'Nickname',
|
||||
'Email',
|
||||
'Phone',
|
||||
'Mobile',
|
||||
'Street',
|
||||
'City',
|
||||
'Postal Code',
|
||||
'Country',
|
||||
'Company',
|
||||
'Job Title',
|
||||
'Department',
|
||||
'Website',
|
||||
'Birthday',
|
||||
'Notes',
|
||||
];
|
||||
|
||||
const sampleRow = [
|
||||
'Max',
|
||||
'Mustermann',
|
||||
'Max Mustermann',
|
||||
'Maxi',
|
||||
'max@example.com',
|
||||
'+49 123 456789',
|
||||
'+49 170 1234567',
|
||||
'Musterstraße 1',
|
||||
'Berlin',
|
||||
'10115',
|
||||
'Germany',
|
||||
'Musterfirma GmbH',
|
||||
'Software Engineer',
|
||||
'Engineering',
|
||||
'https://example.com',
|
||||
'1990-01-15',
|
||||
'Example contact',
|
||||
];
|
||||
|
||||
return [headers.join(','), sampleRow.join(',')].join('\n');
|
||||
}
|
||||
}
|
||||
247
apps/contacts/apps/backend/src/import/parsers/vcard.parser.ts
Normal file
247
apps/contacts/apps/backend/src/import/parsers/vcard.parser.ts
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
import { ParsedContactDto } from '../dto/import.dto';
|
||||
|
||||
interface VCardProperty {
|
||||
name: string;
|
||||
params: Record<string, string>;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export class VCardParser {
|
||||
/**
|
||||
* Parse vCard content (supports v2.1, v3.0, v4.0)
|
||||
*/
|
||||
parse(content: string): { contacts: ParsedContactDto[]; errors: string[] } {
|
||||
const contacts: ParsedContactDto[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
// Normalize line endings and unfold long lines
|
||||
const normalizedContent = this.unfoldLines(content.replace(/\r\n/g, '\n').replace(/\r/g, '\n'));
|
||||
|
||||
// Split into individual vCards
|
||||
const vcardBlocks = this.splitVCards(normalizedContent);
|
||||
|
||||
for (let i = 0; i < vcardBlocks.length; i++) {
|
||||
try {
|
||||
const contact = this.parseVCard(vcardBlocks[i]);
|
||||
if (
|
||||
contact &&
|
||||
(contact.firstName || contact.lastName || contact.email || contact.displayName)
|
||||
) {
|
||||
contacts.push(contact);
|
||||
}
|
||||
} catch (error) {
|
||||
errors.push(`vCard ${i + 1}: ${error instanceof Error ? error.message : 'Parse error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { contacts, errors };
|
||||
}
|
||||
|
||||
private unfoldLines(content: string): string {
|
||||
// RFC 2425: Long lines are folded by inserting CRLF + whitespace
|
||||
return content.replace(/\n[ \t]/g, '');
|
||||
}
|
||||
|
||||
private splitVCards(content: string): string[] {
|
||||
const vcards: string[] = [];
|
||||
const regex = /BEGIN:VCARD[\s\S]*?END:VCARD/gi;
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(content)) !== null) {
|
||||
vcards.push(match[0]);
|
||||
}
|
||||
|
||||
return vcards;
|
||||
}
|
||||
|
||||
private parseVCard(vcardContent: string): ParsedContactDto | null {
|
||||
const lines = vcardContent.split('\n').filter((line) => line.trim() !== '');
|
||||
const properties: VCardProperty[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('BEGIN:') || line.startsWith('END:') || line.startsWith('VERSION:')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const property = this.parseLine(line);
|
||||
if (property) {
|
||||
properties.push(property);
|
||||
}
|
||||
}
|
||||
|
||||
return this.mapToContact(properties);
|
||||
}
|
||||
|
||||
private parseLine(line: string): VCardProperty | null {
|
||||
// Format: NAME;PARAM1=VALUE1;PARAM2=VALUE2:value
|
||||
const colonIndex = line.indexOf(':');
|
||||
if (colonIndex === -1) return null;
|
||||
|
||||
const nameAndParams = line.substring(0, colonIndex);
|
||||
const value = line.substring(colonIndex + 1);
|
||||
|
||||
const parts = nameAndParams.split(';');
|
||||
const name = parts[0].toUpperCase();
|
||||
const params: Record<string, string> = {};
|
||||
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
const paramPart = parts[i];
|
||||
const equalIndex = paramPart.indexOf('=');
|
||||
if (equalIndex !== -1) {
|
||||
const paramName = paramPart.substring(0, equalIndex).toUpperCase();
|
||||
const paramValue = paramPart.substring(equalIndex + 1);
|
||||
params[paramName] = paramValue;
|
||||
} else {
|
||||
// Handle vCard 2.1 style params without =
|
||||
params[paramPart.toUpperCase()] = 'true';
|
||||
}
|
||||
}
|
||||
|
||||
return { name, params, value: this.decodeValue(value, params) };
|
||||
}
|
||||
|
||||
private decodeValue(value: string, params: Record<string, string>): string {
|
||||
// Handle quoted-printable encoding (common in vCard 2.1)
|
||||
if (params['ENCODING'] === 'QUOTED-PRINTABLE') {
|
||||
return this.decodeQuotedPrintable(value);
|
||||
}
|
||||
|
||||
// Handle escaped characters
|
||||
return value
|
||||
.replace(/\\n/gi, '\n')
|
||||
.replace(/\\,/g, ',')
|
||||
.replace(/\\;/g, ';')
|
||||
.replace(/\\\\/g, '\\');
|
||||
}
|
||||
|
||||
private decodeQuotedPrintable(str: string): string {
|
||||
return str.replace(/=([0-9A-F]{2})/gi, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
|
||||
}
|
||||
|
||||
private mapToContact(properties: VCardProperty[]): ParsedContactDto {
|
||||
const contact: ParsedContactDto = {};
|
||||
|
||||
for (const prop of properties) {
|
||||
switch (prop.name) {
|
||||
case 'N':
|
||||
this.parseName(prop.value, contact);
|
||||
break;
|
||||
|
||||
case 'FN':
|
||||
contact.displayName = prop.value;
|
||||
break;
|
||||
|
||||
case 'NICKNAME':
|
||||
contact.nickname = prop.value;
|
||||
break;
|
||||
|
||||
case 'EMAIL':
|
||||
if (!contact.email) {
|
||||
contact.email = prop.value;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'TEL':
|
||||
this.parsePhone(prop, contact);
|
||||
break;
|
||||
|
||||
case 'ADR':
|
||||
this.parseAddress(prop.value, contact);
|
||||
break;
|
||||
|
||||
case 'ORG':
|
||||
this.parseOrganization(prop.value, contact);
|
||||
break;
|
||||
|
||||
case 'TITLE':
|
||||
contact.jobTitle = prop.value;
|
||||
break;
|
||||
|
||||
case 'URL':
|
||||
if (!contact.website) {
|
||||
contact.website = prop.value;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'BDAY':
|
||||
contact.birthday = this.parseBirthday(prop.value);
|
||||
break;
|
||||
|
||||
case 'NOTE':
|
||||
contact.notes = prop.value;
|
||||
break;
|
||||
|
||||
case 'PHOTO':
|
||||
// Only store URL references, not base64 data
|
||||
if (prop.params['VALUE'] === 'URI' || prop.value.startsWith('http')) {
|
||||
contact.photoUrl = prop.value;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate displayName if not set
|
||||
if (!contact.displayName && (contact.firstName || contact.lastName)) {
|
||||
contact.displayName = [contact.firstName, contact.lastName].filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
return contact;
|
||||
}
|
||||
|
||||
private parseName(value: string, contact: ParsedContactDto): void {
|
||||
// N:LastName;FirstName;MiddleName;Prefix;Suffix
|
||||
const parts = value.split(';');
|
||||
if (parts[0]) contact.lastName = parts[0];
|
||||
if (parts[1]) contact.firstName = parts[1];
|
||||
}
|
||||
|
||||
private parsePhone(prop: VCardProperty, contact: ParsedContactDto): void {
|
||||
const typeStr = (prop.params['TYPE'] || '').toUpperCase();
|
||||
|
||||
if (typeStr.includes('CELL') || typeStr.includes('MOBILE')) {
|
||||
if (!contact.mobile) {
|
||||
contact.mobile = prop.value;
|
||||
}
|
||||
} else {
|
||||
if (!contact.phone) {
|
||||
contact.phone = prop.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private parseAddress(value: string, contact: ParsedContactDto): void {
|
||||
// ADR:POBox;Extended;Street;City;State;PostalCode;Country
|
||||
const parts = value.split(';');
|
||||
if (parts[2]) contact.street = parts[2];
|
||||
if (parts[3]) contact.city = parts[3];
|
||||
// parts[4] is state/region - we could append to city
|
||||
if (parts[5]) contact.postalCode = parts[5];
|
||||
if (parts[6]) contact.country = parts[6];
|
||||
}
|
||||
|
||||
private parseOrganization(value: string, contact: ParsedContactDto): void {
|
||||
// ORG:Company;Department
|
||||
const parts = value.split(';');
|
||||
if (parts[0]) contact.company = parts[0];
|
||||
if (parts[1]) contact.department = parts[1];
|
||||
}
|
||||
|
||||
private parseBirthday(value: string): string | undefined {
|
||||
// Handle various formats: YYYY-MM-DD, YYYYMMDD, --MMDD
|
||||
const cleaned = value.replace(/-/g, '');
|
||||
|
||||
if (cleaned.length === 8) {
|
||||
const year = cleaned.substring(0, 4);
|
||||
const month = cleaned.substring(4, 6);
|
||||
const day = cleaned.substring(6, 8);
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
// Already in ISO format
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
import { Contact } from '../../db/schema';
|
||||
import { DuplicateInfo, ParsedContactDto } from '../dto/import.dto';
|
||||
|
||||
export class DuplicateDetector {
|
||||
/**
|
||||
* Detect duplicates between imported contacts and existing contacts
|
||||
*/
|
||||
detectDuplicates(
|
||||
importedContacts: ParsedContactDto[],
|
||||
existingContacts: Contact[]
|
||||
): DuplicateInfo[] {
|
||||
const duplicates: DuplicateInfo[] = [];
|
||||
|
||||
// Build lookup maps for faster matching
|
||||
const emailMap = new Map<string, Contact>();
|
||||
const phoneMap = new Map<string, Contact>();
|
||||
|
||||
for (const contact of existingContacts) {
|
||||
if (contact.email) {
|
||||
emailMap.set(this.normalizeEmail(contact.email), contact);
|
||||
}
|
||||
if (contact.phone) {
|
||||
phoneMap.set(this.normalizePhone(contact.phone), contact);
|
||||
}
|
||||
if (contact.mobile) {
|
||||
phoneMap.set(this.normalizePhone(contact.mobile), contact);
|
||||
}
|
||||
}
|
||||
|
||||
// Check each imported contact for duplicates
|
||||
for (let i = 0; i < importedContacts.length; i++) {
|
||||
const imported = importedContacts[i];
|
||||
|
||||
// Check email first (primary match)
|
||||
if (imported.email) {
|
||||
const normalizedEmail = this.normalizeEmail(imported.email);
|
||||
const existingByEmail = emailMap.get(normalizedEmail);
|
||||
|
||||
if (existingByEmail) {
|
||||
duplicates.push({
|
||||
importIndex: i,
|
||||
existingContactId: existingByEmail.id,
|
||||
existingContactName: this.getContactName(existingByEmail),
|
||||
matchField: 'email',
|
||||
matchValue: imported.email,
|
||||
});
|
||||
continue; // Skip phone check if email matches
|
||||
}
|
||||
}
|
||||
|
||||
// Check phone (secondary match)
|
||||
const phonesToCheck = [imported.phone, imported.mobile].filter(Boolean) as string[];
|
||||
|
||||
for (const phone of phonesToCheck) {
|
||||
const normalizedPhone = this.normalizePhone(phone);
|
||||
const existingByPhone = phoneMap.get(normalizedPhone);
|
||||
|
||||
if (existingByPhone) {
|
||||
duplicates.push({
|
||||
importIndex: i,
|
||||
existingContactId: existingByPhone.id,
|
||||
existingContactName: this.getContactName(existingByPhone),
|
||||
matchField: 'phone',
|
||||
matchValue: phone,
|
||||
});
|
||||
break; // Only report first phone match
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return duplicates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize email for comparison
|
||||
*/
|
||||
private normalizeEmail(email: string): string {
|
||||
return email.toLowerCase().trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize phone number for comparison
|
||||
* Removes all non-digit characters except leading +
|
||||
*/
|
||||
private normalizePhone(phone: string): string {
|
||||
const hasPlus = phone.startsWith('+');
|
||||
const digits = phone.replace(/\D/g, '');
|
||||
return hasPlus ? '+' + digits : digits;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display name for a contact
|
||||
*/
|
||||
private getContactName(contact: Contact): string {
|
||||
if (contact.displayName) return contact.displayName;
|
||||
if (contact.firstName || contact.lastName) {
|
||||
return [contact.firstName, contact.lastName].filter(Boolean).join(' ');
|
||||
}
|
||||
if (contact.email) return contact.email;
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge imported data with existing contact
|
||||
* Only fills in missing fields from the imported data
|
||||
*/
|
||||
mergeContacts(existing: Contact, imported: ParsedContactDto): Partial<Contact> {
|
||||
const updates: Partial<Contact> = {};
|
||||
|
||||
// Only update fields that are empty in existing contact
|
||||
if (!existing.firstName && imported.firstName) updates.firstName = imported.firstName;
|
||||
if (!existing.lastName && imported.lastName) updates.lastName = imported.lastName;
|
||||
if (!existing.displayName && imported.displayName) updates.displayName = imported.displayName;
|
||||
if (!existing.nickname && imported.nickname) updates.nickname = imported.nickname;
|
||||
if (!existing.email && imported.email) updates.email = imported.email;
|
||||
if (!existing.phone && imported.phone) updates.phone = imported.phone;
|
||||
if (!existing.mobile && imported.mobile) updates.mobile = imported.mobile;
|
||||
if (!existing.street && imported.street) updates.street = imported.street;
|
||||
if (!existing.city && imported.city) updates.city = imported.city;
|
||||
if (!existing.postalCode && imported.postalCode) updates.postalCode = imported.postalCode;
|
||||
if (!existing.country && imported.country) updates.country = imported.country;
|
||||
if (!existing.company && imported.company) updates.company = imported.company;
|
||||
if (!existing.jobTitle && imported.jobTitle) updates.jobTitle = imported.jobTitle;
|
||||
if (!existing.department && imported.department) updates.department = imported.department;
|
||||
if (!existing.website && imported.website) updates.website = imported.website;
|
||||
if (!existing.birthday && imported.birthday) updates.birthday = imported.birthday;
|
||||
if (!existing.notes && imported.notes) updates.notes = imported.notes;
|
||||
if (!existing.photoUrl && imported.photoUrl) updates.photoUrl = imported.photoUrl;
|
||||
|
||||
return updates;
|
||||
}
|
||||
}
|
||||
21
apps/contacts/apps/backend/src/types/multer.d.ts
vendored
Normal file
21
apps/contacts/apps/backend/src/types/multer.d.ts
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
/// <reference types="node" />
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
namespace Multer {
|
||||
interface File {
|
||||
fieldname: string;
|
||||
originalname: string;
|
||||
encoding: string;
|
||||
mimetype: string;
|
||||
size: number;
|
||||
destination: string;
|
||||
filename: string;
|
||||
path: string;
|
||||
buffer: Buffer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
101
apps/contacts/apps/web/src/lib/api/export.ts
Normal file
101
apps/contacts/apps/web/src/lib/api/export.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
const API_BASE = 'http://localhost:3015/api/v1';
|
||||
|
||||
export type ExportFormat = 'vcard' | 'csv';
|
||||
|
||||
export interface ExportOptions {
|
||||
format: ExportFormat;
|
||||
contactIds?: string[];
|
||||
groupId?: string;
|
||||
tagId?: string;
|
||||
includeFavorites?: boolean;
|
||||
includeArchived?: boolean;
|
||||
}
|
||||
|
||||
async function fetchWithAuth(url: string, options: RequestInit = {}) {
|
||||
const token = await authStore.getAccessToken();
|
||||
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers || {}),
|
||||
};
|
||||
|
||||
if (token) {
|
||||
(headers as Record<string, string>)['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
return fetch(`${API_BASE}${url}`, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
export const exportApi = {
|
||||
/**
|
||||
* Export contacts with options
|
||||
*/
|
||||
async exportContacts(options: ExportOptions): Promise<void> {
|
||||
const response = await fetchWithAuth('/export', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(options),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ message: 'Export failed' }));
|
||||
throw new Error(error.message || 'Export failed');
|
||||
}
|
||||
|
||||
// Get filename from Content-Disposition header
|
||||
const contentDisposition = response.headers.get('Content-Disposition');
|
||||
const filenameMatch = contentDisposition?.match(/filename="(.+)"/);
|
||||
const filename = filenameMatch
|
||||
? filenameMatch[1]
|
||||
: `contacts.${options.format === 'vcard' ? 'vcf' : 'csv'}`;
|
||||
|
||||
// Get the blob and trigger download
|
||||
const blob = await response.blob();
|
||||
downloadBlob(blob, filename);
|
||||
|
||||
// Return contact count from header
|
||||
const contactCount = response.headers.get('X-Contact-Count');
|
||||
return;
|
||||
},
|
||||
|
||||
/**
|
||||
* Quick export all contacts
|
||||
*/
|
||||
async quickExport(format: ExportFormat = 'vcard'): Promise<void> {
|
||||
const response = await fetchWithAuth(`/export?format=${format}`);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ message: 'Export failed' }));
|
||||
throw new Error(error.message || 'Export failed');
|
||||
}
|
||||
|
||||
// Get filename from Content-Disposition header
|
||||
const contentDisposition = response.headers.get('Content-Disposition');
|
||||
const filenameMatch = contentDisposition?.match(/filename="(.+)"/);
|
||||
const filename = filenameMatch
|
||||
? filenameMatch[1]
|
||||
: `contacts.${format === 'vcard' ? 'vcf' : 'csv'}`;
|
||||
|
||||
// Get the blob and trigger download
|
||||
const blob = await response.blob();
|
||||
downloadBlob(blob, filename);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper to trigger file download
|
||||
*/
|
||||
function downloadBlob(blob: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
132
apps/contacts/apps/web/src/lib/api/google.ts
Normal file
132
apps/contacts/apps/web/src/lib/api/google.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
const API_BASE = 'http://localhost:3015/api/v1';
|
||||
|
||||
export interface GoogleContact {
|
||||
resourceName: string;
|
||||
names?: Array<{
|
||||
displayName?: string;
|
||||
familyName?: string;
|
||||
givenName?: string;
|
||||
}>;
|
||||
emailAddresses?: Array<{
|
||||
value?: string;
|
||||
type?: string;
|
||||
}>;
|
||||
phoneNumbers?: Array<{
|
||||
value?: string;
|
||||
type?: string;
|
||||
}>;
|
||||
organizations?: Array<{
|
||||
name?: string;
|
||||
title?: string;
|
||||
}>;
|
||||
photos?: Array<{
|
||||
url?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface GoogleContactsResponse {
|
||||
contacts: GoogleContact[];
|
||||
nextPageToken?: string;
|
||||
totalPeople?: number;
|
||||
}
|
||||
|
||||
export interface ConnectedAccount {
|
||||
id: string;
|
||||
providerEmail: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface GoogleStatus {
|
||||
connected: boolean;
|
||||
account: ConnectedAccount | null;
|
||||
}
|
||||
|
||||
export interface GoogleImportResult {
|
||||
imported: number;
|
||||
skipped: number;
|
||||
errors: Array<{
|
||||
resourceName: string;
|
||||
error: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
async function fetchWithAuth(url: string, options: RequestInit = {}) {
|
||||
const token = await authStore.getAccessToken();
|
||||
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers || {}),
|
||||
};
|
||||
|
||||
if (token) {
|
||||
(headers as Record<string, string>)['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}${url}`, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ message: 'Request failed' }));
|
||||
throw new Error(error.message || 'Request failed');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export const googleApi = {
|
||||
/**
|
||||
* Get OAuth authorization URL
|
||||
*/
|
||||
async getAuthUrl(): Promise<string> {
|
||||
const response = await fetchWithAuth('/google/auth-url');
|
||||
return response.url;
|
||||
},
|
||||
|
||||
/**
|
||||
* Exchange authorization code for tokens
|
||||
*/
|
||||
async handleCallback(code: string): Promise<{ success: boolean; account: ConnectedAccount }> {
|
||||
return fetchWithAuth('/google/callback', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ code }),
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Get connection status
|
||||
*/
|
||||
async getStatus(): Promise<GoogleStatus> {
|
||||
return fetchWithAuth('/google/status');
|
||||
},
|
||||
|
||||
/**
|
||||
* Disconnect Google account
|
||||
*/
|
||||
async disconnect(): Promise<void> {
|
||||
await fetchWithAuth('/google/disconnect', {
|
||||
method: 'DELETE',
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch contacts from Google
|
||||
*/
|
||||
async fetchContacts(pageToken?: string): Promise<GoogleContactsResponse> {
|
||||
const params = pageToken ? `?pageToken=${encodeURIComponent(pageToken)}` : '';
|
||||
return fetchWithAuth(`/google/contacts${params}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Import selected contacts
|
||||
*/
|
||||
async importContacts(resourceNames?: string[], all = false): Promise<GoogleImportResult> {
|
||||
return fetchWithAuth('/google/import', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ resourceNames, all }),
|
||||
});
|
||||
},
|
||||
};
|
||||
155
apps/contacts/apps/web/src/lib/api/import.ts
Normal file
155
apps/contacts/apps/web/src/lib/api/import.ts
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
const API_BASE = 'http://localhost:3015/api/v1';
|
||||
|
||||
export interface ParsedContact {
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
displayName?: string;
|
||||
nickname?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
mobile?: string;
|
||||
street?: string;
|
||||
city?: string;
|
||||
postalCode?: string;
|
||||
country?: string;
|
||||
company?: string;
|
||||
jobTitle?: string;
|
||||
department?: string;
|
||||
website?: string;
|
||||
birthday?: string;
|
||||
notes?: string;
|
||||
photoUrl?: string;
|
||||
}
|
||||
|
||||
export interface DuplicateInfo {
|
||||
importIndex: number;
|
||||
existingContactId: string;
|
||||
existingContactName: string;
|
||||
matchField: 'email' | 'phone';
|
||||
matchValue: string;
|
||||
}
|
||||
|
||||
export interface CsvFieldMapping {
|
||||
csvHeader: string;
|
||||
contactField: keyof ParsedContact | null;
|
||||
sampleValue: string;
|
||||
}
|
||||
|
||||
export interface ImportPreviewResponse {
|
||||
contacts: ParsedContact[];
|
||||
duplicates: DuplicateInfo[];
|
||||
totalParsed: number;
|
||||
validCount: number;
|
||||
invalidCount: number;
|
||||
errors: string[];
|
||||
fieldMapping?: CsvFieldMapping[];
|
||||
}
|
||||
|
||||
export interface ImportError {
|
||||
index: number;
|
||||
contactName: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export interface ImportResult {
|
||||
imported: number;
|
||||
skipped: number;
|
||||
merged: number;
|
||||
errors: ImportError[];
|
||||
}
|
||||
|
||||
export type DuplicateAction = 'skip' | 'merge' | 'create';
|
||||
|
||||
export const importApi = {
|
||||
/**
|
||||
* Preview import from uploaded file
|
||||
*/
|
||||
async preview(file: File): Promise<ImportPreviewResponse> {
|
||||
const token = await authStore.getAccessToken();
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await fetch(`${API_BASE}/import/preview`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ message: 'Upload failed' }));
|
||||
throw new Error(error.message || 'Upload failed');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
/**
|
||||
* Execute the import
|
||||
*/
|
||||
async execute(
|
||||
contacts: ParsedContact[],
|
||||
duplicateAction: DuplicateAction,
|
||||
skipIndices?: number[]
|
||||
): Promise<ImportResult> {
|
||||
const token = await authStore.getAccessToken();
|
||||
|
||||
const response = await fetch(`${API_BASE}/import/execute`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
contacts,
|
||||
duplicateAction,
|
||||
skipIndices,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ message: 'Import failed' }));
|
||||
throw new Error(error.message || 'Import failed');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
/**
|
||||
* Get CSV template download URL
|
||||
*/
|
||||
getTemplateUrl(): string {
|
||||
return `${API_BASE}/import/template/csv`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Download CSV template
|
||||
*/
|
||||
async downloadTemplate(): Promise<void> {
|
||||
const token = await authStore.getAccessToken();
|
||||
|
||||
const response = await fetch(`${API_BASE}/import/template/csv`, {
|
||||
headers: {
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to download template');
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'contacts-template.csv';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
},
|
||||
};
|
||||
200
apps/contacts/apps/web/src/lib/components/ContactList.svelte
Normal file
200
apps/contacts/apps/web/src/lib/components/ContactList.svelte
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { contactsStore } from '$lib/stores/contacts.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import ExportModal from '$lib/components/export/ExportModal.svelte';
|
||||
|
||||
let searchQuery = $state('');
|
||||
let searchTimeout: ReturnType<typeof setTimeout>;
|
||||
let showExportModal = $state(false);
|
||||
|
||||
function handleSearch() {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
contactsStore.setSearch(searchQuery);
|
||||
contactsStore.loadContacts();
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function getInitials(contact: (typeof contactsStore.contacts)[0]) {
|
||||
const first = contact.firstName?.[0] || '';
|
||||
const last = contact.lastName?.[0] || '';
|
||||
return (first + last).toUpperCase() || contact.email?.[0]?.toUpperCase() || '?';
|
||||
}
|
||||
|
||||
function getDisplayName(contact: (typeof contactsStore.contacts)[0]) {
|
||||
if (contact.displayName) return contact.displayName;
|
||||
if (contact.firstName || contact.lastName) {
|
||||
return [contact.firstName, contact.lastName].filter(Boolean).join(' ');
|
||||
}
|
||||
return contact.email || 'Unbekannt';
|
||||
}
|
||||
|
||||
async function handleToggleFavorite(e: MouseEvent, id: string) {
|
||||
e.stopPropagation();
|
||||
await contactsStore.toggleFavorite(id);
|
||||
}
|
||||
|
||||
function handleContactClick(id: string) {
|
||||
goto(`/contacts/${id}`);
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
// Only load if not already loaded
|
||||
if (contactsStore.contacts.length === 0) {
|
||||
await contactsStore.loadContacts();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold text-foreground">{$_('contacts.title')}</h1>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showExportModal = true)}
|
||||
class="btn btn-secondary flex items-center gap-2"
|
||||
title={$_('export.title')}
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"
|
||||
/>
|
||||
</svg>
|
||||
<span class="hidden sm:inline">{$_('export.button')}</span>
|
||||
</button>
|
||||
<a href="/contacts/new" class="btn btn-primary flex items-center gap-2">
|
||||
<span>+</span>
|
||||
<span>{$_('contacts.new')}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={$_('contacts.search')}
|
||||
bind:value={searchQuery}
|
||||
oninput={handleSearch}
|
||||
class="input w-full pl-10"
|
||||
/>
|
||||
<svg
|
||||
class="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-muted-foreground"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
{#if contactsStore.loading}
|
||||
<div class="flex justify-center py-12">
|
||||
<div
|
||||
class="h-8 w-8 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent"
|
||||
></div>
|
||||
</div>
|
||||
{:else if contactsStore.contacts.length === 0}
|
||||
<!-- Empty state -->
|
||||
<div class="text-center py-12">
|
||||
<div class="text-6xl mb-4">👤</div>
|
||||
<h2 class="text-xl font-semibold text-foreground mb-2">{$_('contacts.noContacts')}</h2>
|
||||
<p class="text-muted-foreground mb-4">{$_('contacts.addFirst')}</p>
|
||||
<a href="/contacts/new" class="btn btn-primary">
|
||||
{$_('contacts.new')}
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Contacts List -->
|
||||
<div class="space-y-2">
|
||||
{#each contactsStore.contacts as contact (contact.id)}
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => handleContactClick(contact.id)}
|
||||
onkeydown={(e) => e.key === 'Enter' && handleContactClick(contact.id)}
|
||||
class="contact-card w-full text-left cursor-pointer"
|
||||
>
|
||||
<!-- Avatar -->
|
||||
<div class="avatar">
|
||||
{#if contact.photoUrl}
|
||||
<img
|
||||
src={contact.photoUrl}
|
||||
alt={getDisplayName(contact)}
|
||||
class="h-full w-full rounded-full object-cover"
|
||||
/>
|
||||
{:else}
|
||||
{getInitials(contact)}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Contact Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-medium text-foreground truncate">
|
||||
{getDisplayName(contact)}
|
||||
</div>
|
||||
{#if contact.company || contact.jobTitle}
|
||||
<div class="text-sm text-muted-foreground truncate">
|
||||
{[contact.jobTitle, contact.company].filter(Boolean).join(' @ ')}
|
||||
</div>
|
||||
{/if}
|
||||
{#if contact.email}
|
||||
<div class="text-sm text-muted-foreground truncate">
|
||||
{contact.email}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Favorite button -->
|
||||
<button
|
||||
onclick={(e) => handleToggleFavorite(e, contact.id)}
|
||||
class="p-2 rounded-full hover:bg-accent transition-colors"
|
||||
>
|
||||
{#if contact.isFavorite}
|
||||
<svg class="h-5 w-5 text-red-500 fill-current" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg
|
||||
class="h-5 w-5 text-muted-foreground"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Total count -->
|
||||
<p class="text-sm text-muted-foreground text-center">
|
||||
{contactsStore.total} Kontakte
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Export Modal -->
|
||||
<ExportModal isOpen={showExportModal} onClose={() => (showExportModal = false)} />
|
||||
|
|
@ -0,0 +1,196 @@
|
|||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { exportApi, type ExportFormat } from '$lib/api/export';
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
selectedContactIds?: string[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { isOpen, selectedContactIds = [], onClose }: Props = $props();
|
||||
|
||||
let format = $state<ExportFormat>('vcard');
|
||||
let includeArchived = $state(false);
|
||||
let isExporting = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
async function handleExport() {
|
||||
isExporting = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
await exportApi.exportContacts({
|
||||
format,
|
||||
contactIds: selectedContactIds.length > 0 ? selectedContactIds : undefined,
|
||||
includeArchived,
|
||||
});
|
||||
onClose();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Export fehlgeschlagen';
|
||||
} finally {
|
||||
isExporting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleBackdropClick(e: MouseEvent) {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
{#if isOpen}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
|
||||
onclick={handleBackdropClick}
|
||||
>
|
||||
<div class="bg-card rounded-xl shadow-xl w-full max-w-md p-6 space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-xl font-bold text-foreground">{$_('export.title')}</h2>
|
||||
<button
|
||||
type="button"
|
||||
onclick={onClose}
|
||||
class="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Selection Info -->
|
||||
{#if selectedContactIds.length > 0}
|
||||
<div class="bg-primary/10 text-primary rounded-lg p-3 text-sm">
|
||||
{$_('export.selectedCount', { values: { count: selectedContactIds.length } })}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-muted-foreground text-sm">{$_('export.allContacts')}</p>
|
||||
{/if}
|
||||
|
||||
<!-- Error -->
|
||||
{#if error}
|
||||
<div class="bg-red-500/10 border border-red-500/20 rounded-lg p-3 text-red-500 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Format Selection -->
|
||||
<div class="space-y-3">
|
||||
<label class="block text-sm font-medium text-foreground">{$_('export.format')}</label>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (format = 'vcard')}
|
||||
class="p-4 rounded-lg border-2 transition-colors text-left
|
||||
{format === 'vcard'
|
||||
? 'border-primary bg-primary/10'
|
||||
: 'border-border hover:border-muted-foreground'}"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<svg
|
||||
class="w-8 h-8 text-primary"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 6H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V8a2 2 0 00-2-2h-5m-4 0V5a2 2 0 114 0v1m-4 0a2 2 0 104 0m-5 8a2 2 0 100-4 2 2 0 000 4zm0 0c1.306 0 2.417.835 2.83 2M9 14a3.001 3.001 0 00-2.83 2M15 11h3m-3 4h2"
|
||||
/>
|
||||
</svg>
|
||||
<div>
|
||||
<div class="font-medium text-foreground">vCard</div>
|
||||
<div class="text-xs text-muted-foreground">.vcf</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (format = 'csv')}
|
||||
class="p-4 rounded-lg border-2 transition-colors text-left
|
||||
{format === 'csv' ? 'border-primary bg-primary/10' : 'border-border hover:border-muted-foreground'}"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<svg
|
||||
class="w-8 h-8 text-green-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
<div>
|
||||
<div class="font-medium text-foreground">CSV</div>
|
||||
<div class="text-xs text-muted-foreground">.csv</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Options -->
|
||||
<div class="space-y-3">
|
||||
<label class="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={includeArchived}
|
||||
class="w-5 h-5 rounded border-border text-primary focus:ring-primary"
|
||||
/>
|
||||
<span class="text-sm text-foreground">{$_('export.includeArchived')}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-end gap-3 pt-2">
|
||||
<button type="button" onclick={onClose} class="btn btn-secondary">
|
||||
{$_('common.cancel')}
|
||||
</button>
|
||||
<button type="button" onclick={handleExport} disabled={isExporting} class="btn btn-primary">
|
||||
{#if isExporting}
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<span
|
||||
class="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin"
|
||||
></span>
|
||||
{$_('export.exporting')}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"
|
||||
/>
|
||||
</svg>
|
||||
{$_('export.button')}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
onFileSelect: (file: File) => void;
|
||||
accept?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
let { onFileSelect, accept = '.vcf,.vcard,.csv', disabled = false }: Props = $props();
|
||||
|
||||
let isDragging = $state(false);
|
||||
let fileInput: HTMLInputElement;
|
||||
|
||||
function handleDragOver(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
if (!disabled) {
|
||||
isDragging = true;
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragLeave(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
isDragging = false;
|
||||
}
|
||||
|
||||
function handleDrop(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
isDragging = false;
|
||||
|
||||
if (disabled) return;
|
||||
|
||||
const files = e.dataTransfer?.files;
|
||||
if (files && files.length > 0) {
|
||||
onFileSelect(files[0]);
|
||||
}
|
||||
}
|
||||
|
||||
function handleFileSelect(e: Event) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
if (target.files && target.files.length > 0) {
|
||||
onFileSelect(target.files[0]);
|
||||
target.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function handleClick() {
|
||||
if (!disabled) {
|
||||
fileInput.click();
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleClick();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
class="border-2 border-dashed rounded-xl p-8 text-center transition-colors cursor-pointer
|
||||
{isDragging ? 'border-primary bg-primary/5' : 'border-border hover:border-primary/50'}
|
||||
{disabled ? 'opacity-50 cursor-not-allowed' : ''}"
|
||||
ondragover={handleDragOver}
|
||||
ondragleave={handleDragLeave}
|
||||
ondrop={handleDrop}
|
||||
onclick={handleClick}
|
||||
onkeydown={handleKeydown}
|
||||
>
|
||||
<input
|
||||
bind:this={fileInput}
|
||||
type="file"
|
||||
{accept}
|
||||
class="hidden"
|
||||
onchange={handleFileSelect}
|
||||
{disabled}
|
||||
/>
|
||||
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<div class="w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center text-primary">
|
||||
<svg
|
||||
class="w-8 h-8"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-lg font-medium text-foreground">{$_('import.dropzone.title')}</p>
|
||||
<p class="text-sm text-muted-foreground mt-1">{$_('import.dropzone.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<span class="flex items-center gap-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
vCard (.vcf)
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 10h18M3 14h18m-9-4v8m-7 0h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
CSV (.csv)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,398 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import {
|
||||
googleApi,
|
||||
type GoogleContact,
|
||||
type GoogleStatus,
|
||||
type GoogleImportResult,
|
||||
} from '$lib/api/google';
|
||||
import { contactsStore } from '$lib/stores/contacts.svelte';
|
||||
|
||||
type Step = 'connect' | 'select' | 'result';
|
||||
|
||||
let step = $state<Step>('connect');
|
||||
let isLoading = $state(true);
|
||||
let isImporting = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let status = $state<GoogleStatus | null>(null);
|
||||
let contacts = $state<GoogleContact[]>([]);
|
||||
let selectedContacts = $state<Set<string>>(new Set());
|
||||
let nextPageToken = $state<string | undefined>(undefined);
|
||||
let result = $state<GoogleImportResult | null>(null);
|
||||
|
||||
// Handle OAuth callback
|
||||
onMount(async () => {
|
||||
const code = $page.url.searchParams.get('code');
|
||||
|
||||
if (code) {
|
||||
try {
|
||||
await googleApi.handleCallback(code);
|
||||
// Remove code from URL
|
||||
goto('/import?tab=google', { replaceState: true });
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to connect';
|
||||
}
|
||||
}
|
||||
|
||||
await loadStatus();
|
||||
});
|
||||
|
||||
async function loadStatus() {
|
||||
isLoading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
status = await googleApi.getStatus();
|
||||
if (status.connected) {
|
||||
step = 'select';
|
||||
await loadContacts();
|
||||
} else {
|
||||
step = 'connect';
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load status';
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadContacts(pageToken?: string) {
|
||||
try {
|
||||
const response = await googleApi.fetchContacts(pageToken);
|
||||
if (pageToken) {
|
||||
contacts = [...contacts, ...response.contacts];
|
||||
} else {
|
||||
contacts = response.contacts;
|
||||
// Select all by default
|
||||
selectedContacts = new Set(response.contacts.map((c) => c.resourceName));
|
||||
}
|
||||
nextPageToken = response.nextPageToken;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load contacts';
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConnect() {
|
||||
try {
|
||||
const url = await googleApi.getAuthUrl();
|
||||
window.location.href = url;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to get auth URL';
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDisconnect() {
|
||||
try {
|
||||
await googleApi.disconnect();
|
||||
status = null;
|
||||
contacts = [];
|
||||
selectedContacts = new Set();
|
||||
step = 'connect';
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to disconnect';
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLoadMore() {
|
||||
if (nextPageToken) {
|
||||
await loadContacts(nextPageToken);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleContact(resourceName: string) {
|
||||
const newSet = new Set(selectedContacts);
|
||||
if (newSet.has(resourceName)) {
|
||||
newSet.delete(resourceName);
|
||||
} else {
|
||||
newSet.add(resourceName);
|
||||
}
|
||||
selectedContacts = newSet;
|
||||
}
|
||||
|
||||
function selectAll() {
|
||||
selectedContacts = new Set(contacts.map((c) => c.resourceName));
|
||||
}
|
||||
|
||||
function deselectAll() {
|
||||
selectedContacts = new Set();
|
||||
}
|
||||
|
||||
async function handleImport() {
|
||||
isImporting = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
result = await googleApi.importContacts(Array.from(selectedContacts));
|
||||
step = 'result';
|
||||
// Refresh contacts list
|
||||
await contactsStore.loadContacts();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to import';
|
||||
} finally {
|
||||
isImporting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleDone() {
|
||||
goto('/');
|
||||
}
|
||||
|
||||
function handleImportMore() {
|
||||
step = 'select';
|
||||
result = null;
|
||||
selectedContacts = new Set(contacts.map((c) => c.resourceName));
|
||||
}
|
||||
|
||||
function getContactName(contact: GoogleContact): string {
|
||||
return contact.names?.[0]?.displayName || contact.emailAddresses?.[0]?.value || 'Unknown';
|
||||
}
|
||||
|
||||
function getContactSubtitle(contact: GoogleContact): string {
|
||||
const parts: string[] = [];
|
||||
if (contact.emailAddresses?.[0]?.value) {
|
||||
parts.push(contact.emailAddresses[0].value);
|
||||
}
|
||||
if (contact.organizations?.[0]?.name) {
|
||||
parts.push(contact.organizations[0].name);
|
||||
}
|
||||
return parts.join(' • ');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
{#if error}
|
||||
<div class="bg-red-500/10 border border-red-500/20 rounded-lg p-4 text-red-500">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isLoading}
|
||||
<div class="flex flex-col items-center justify-center py-12">
|
||||
<div
|
||||
class="h-12 w-12 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent"
|
||||
></div>
|
||||
<p class="mt-4 text-muted-foreground">{$_('google.loading')}</p>
|
||||
</div>
|
||||
{:else if step === 'connect'}
|
||||
<!-- Connect Step -->
|
||||
<div class="bg-card rounded-xl p-8 text-center space-y-6">
|
||||
<div class="w-20 h-20 mx-auto rounded-full bg-[#4285f4]/10 flex items-center justify-center">
|
||||
<svg class="w-10 h-10" viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
fill="#4285F4"
|
||||
/>
|
||||
<path
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
fill="#34A853"
|
||||
/>
|
||||
<path
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
fill="#FBBC05"
|
||||
/>
|
||||
<path
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
fill="#EA4335"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-foreground">{$_('google.connect.title')}</h2>
|
||||
<p class="text-muted-foreground mt-2">{$_('google.connect.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
<button type="button" onclick={handleConnect} class="btn btn-primary">
|
||||
{$_('google.connect.button')}
|
||||
</button>
|
||||
</div>
|
||||
{:else if step === 'select'}
|
||||
<!-- Select Step -->
|
||||
<div class="space-y-4">
|
||||
<!-- Connected Account Info -->
|
||||
<div class="bg-card rounded-lg p-4 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-full bg-[#4285f4]/10 flex items-center justify-center">
|
||||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
fill="#4285F4"
|
||||
/>
|
||||
<path
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
fill="#34A853"
|
||||
/>
|
||||
<path
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
fill="#FBBC05"
|
||||
/>
|
||||
<path
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
fill="#EA4335"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-medium text-foreground">{$_('google.connected')}</div>
|
||||
<div class="text-sm text-muted-foreground">{status?.account?.providerEmail || ''}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleDisconnect}
|
||||
class="text-sm text-red-500 hover:underline"
|
||||
>
|
||||
{$_('google.disconnect')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Contact List -->
|
||||
<div class="bg-card rounded-lg overflow-hidden">
|
||||
<div class="flex items-center justify-between p-4 border-b border-border">
|
||||
<h3 class="font-medium text-foreground">
|
||||
{$_('google.contacts')} ({contacts.length})
|
||||
</h3>
|
||||
<div class="flex gap-2">
|
||||
<button type="button" onclick={selectAll} class="text-sm text-primary hover:underline">
|
||||
{$_('import.preview.selectAll')}
|
||||
</button>
|
||||
<span class="text-muted-foreground">|</span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={deselectAll}
|
||||
class="text-sm text-primary hover:underline"
|
||||
>
|
||||
{$_('import.preview.deselectAll')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="max-h-[400px] overflow-y-auto divide-y divide-border">
|
||||
{#each contacts as contact}
|
||||
{@const isSelected = selectedContacts.has(contact.resourceName)}
|
||||
|
||||
<label
|
||||
class="flex items-center gap-4 p-4 hover:bg-muted/50 cursor-pointer transition-colors {!isSelected
|
||||
? 'opacity-50'
|
||||
: ''}"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onchange={() => toggleContact(contact.resourceName)}
|
||||
class="w-5 h-5 rounded border-border text-primary focus:ring-primary"
|
||||
/>
|
||||
|
||||
{#if contact.photos?.[0]?.url}
|
||||
<img
|
||||
src={contact.photos[0].url}
|
||||
alt=""
|
||||
class="w-10 h-10 rounded-full object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<div
|
||||
class="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary font-medium"
|
||||
>
|
||||
{getContactName(contact).charAt(0).toUpperCase()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-medium text-foreground truncate">
|
||||
{getContactName(contact)}
|
||||
</div>
|
||||
<div class="text-sm text-muted-foreground truncate">
|
||||
{getContactSubtitle(contact)}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if nextPageToken}
|
||||
<div class="p-4 border-t border-border text-center">
|
||||
<button type="button" onclick={handleLoadMore} class="text-primary hover:underline">
|
||||
{$_('google.loadMore')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleImport}
|
||||
class="btn btn-primary"
|
||||
disabled={isImporting || selectedContacts.size === 0}
|
||||
>
|
||||
{#if isImporting}
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<span
|
||||
class="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin"
|
||||
></span>
|
||||
{$_('import.importing')}
|
||||
</span>
|
||||
{:else}
|
||||
{$_('import.preview.importButton', { values: { count: selectedContacts.size } })}
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if step === 'result' && result}
|
||||
<!-- Result Step -->
|
||||
<div class="bg-card rounded-xl p-8 text-center space-y-6">
|
||||
<div
|
||||
class="w-20 h-20 mx-auto rounded-full bg-green-500/10 flex items-center justify-center text-green-500"
|
||||
>
|
||||
<svg class="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-foreground">{$_('import.result.title')}</h2>
|
||||
<p class="text-muted-foreground mt-2">{$_('import.result.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 max-w-xs mx-auto">
|
||||
<div class="bg-green-500/10 rounded-lg p-4">
|
||||
<div class="text-3xl font-bold text-green-500">{result.imported}</div>
|
||||
<div class="text-sm text-muted-foreground">{$_('import.result.imported')}</div>
|
||||
</div>
|
||||
<div class="bg-gray-500/10 rounded-lg p-4">
|
||||
<div class="text-3xl font-bold text-gray-500">{result.skipped}</div>
|
||||
<div class="text-sm text-muted-foreground">{$_('import.result.skipped')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if result.errors.length > 0}
|
||||
<div class="bg-red-500/10 border border-red-500/20 rounded-lg p-4 text-left">
|
||||
<h3 class="font-medium text-red-500 mb-2">{$_('import.result.errors')}</h3>
|
||||
<ul class="text-sm text-red-400 space-y-1">
|
||||
{#each result.errors as err}
|
||||
<li>{err.error}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex justify-center gap-3">
|
||||
<button type="button" onclick={handleImportMore} class="btn btn-secondary">
|
||||
{$_('import.result.importMore')}
|
||||
</button>
|
||||
<button type="button" onclick={handleDone} class="btn btn-primary">
|
||||
{$_('import.result.done')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -0,0 +1,206 @@
|
|||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
import type {
|
||||
ParsedContact,
|
||||
DuplicateInfo,
|
||||
DuplicateAction,
|
||||
ImportPreviewResponse,
|
||||
} from '$lib/api/import';
|
||||
|
||||
interface Props {
|
||||
preview: ImportPreviewResponse;
|
||||
onImport: (duplicateAction: DuplicateAction, skipIndices: number[]) => void;
|
||||
onCancel: () => void;
|
||||
isImporting?: boolean;
|
||||
}
|
||||
|
||||
let { preview, onImport, onCancel, isImporting = false }: Props = $props();
|
||||
|
||||
let duplicateAction = $state<DuplicateAction>('skip');
|
||||
let selectedContacts = $state<Set<number>>(new Set(preview.contacts.map((_, i) => i)));
|
||||
|
||||
// Create a lookup for duplicates by index
|
||||
const duplicatesByIndex = $derived(new Map(preview.duplicates.map((d) => [d.importIndex, d])));
|
||||
|
||||
function toggleContact(index: number) {
|
||||
const newSet = new Set(selectedContacts);
|
||||
if (newSet.has(index)) {
|
||||
newSet.delete(index);
|
||||
} else {
|
||||
newSet.add(index);
|
||||
}
|
||||
selectedContacts = newSet;
|
||||
}
|
||||
|
||||
function selectAll() {
|
||||
selectedContacts = new Set(preview.contacts.map((_, i) => i));
|
||||
}
|
||||
|
||||
function deselectAll() {
|
||||
selectedContacts = new Set();
|
||||
}
|
||||
|
||||
function handleImport() {
|
||||
const skipIndices = preview.contacts.map((_, i) => i).filter((i) => !selectedContacts.has(i));
|
||||
onImport(duplicateAction, skipIndices);
|
||||
}
|
||||
|
||||
function getContactName(contact: ParsedContact): string {
|
||||
if (contact.displayName) return contact.displayName;
|
||||
if (contact.firstName || contact.lastName) {
|
||||
return [contact.firstName, contact.lastName].filter(Boolean).join(' ');
|
||||
}
|
||||
return contact.email || 'Unbekannt';
|
||||
}
|
||||
|
||||
function getContactSubtitle(contact: ParsedContact): string {
|
||||
const parts: string[] = [];
|
||||
if (contact.email) parts.push(contact.email);
|
||||
if (contact.company) parts.push(contact.company);
|
||||
return parts.join(' • ') || '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Summary -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div class="bg-card rounded-lg p-4 text-center">
|
||||
<div class="text-3xl font-bold text-foreground">{preview.totalParsed}</div>
|
||||
<div class="text-sm text-muted-foreground">{$_('import.preview.total')}</div>
|
||||
</div>
|
||||
<div class="bg-card rounded-lg p-4 text-center">
|
||||
<div class="text-3xl font-bold text-green-500">{preview.validCount}</div>
|
||||
<div class="text-sm text-muted-foreground">{$_('import.preview.valid')}</div>
|
||||
</div>
|
||||
<div class="bg-card rounded-lg p-4 text-center">
|
||||
<div class="text-3xl font-bold text-yellow-500">{preview.duplicates.length}</div>
|
||||
<div class="text-sm text-muted-foreground">{$_('import.preview.duplicates')}</div>
|
||||
</div>
|
||||
<div class="bg-card rounded-lg p-4 text-center">
|
||||
<div class="text-3xl font-bold text-primary">{selectedContacts.size}</div>
|
||||
<div class="text-sm text-muted-foreground">{$_('import.preview.selected')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Errors -->
|
||||
{#if preview.errors.length > 0}
|
||||
<div class="bg-red-500/10 border border-red-500/20 rounded-lg p-4">
|
||||
<h3 class="font-medium text-red-500 mb-2">{$_('import.preview.errors')}</h3>
|
||||
<ul class="text-sm text-red-400 space-y-1">
|
||||
{#each preview.errors as error}
|
||||
<li>{error}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Duplicate handling -->
|
||||
{#if preview.duplicates.length > 0}
|
||||
<div class="bg-card rounded-lg p-4">
|
||||
<h3 class="font-medium text-foreground mb-3">{$_('import.preview.duplicateHandling')}</h3>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<label
|
||||
class="flex items-center gap-2 px-4 py-2 rounded-lg border cursor-pointer transition-colors
|
||||
{duplicateAction === 'skip' ? 'border-primary bg-primary/10' : 'border-border'}"
|
||||
>
|
||||
<input type="radio" bind:group={duplicateAction} value="skip" class="sr-only" />
|
||||
<span class="text-foreground">{$_('import.preview.skip')}</span>
|
||||
</label>
|
||||
<label
|
||||
class="flex items-center gap-2 px-4 py-2 rounded-lg border cursor-pointer transition-colors
|
||||
{duplicateAction === 'merge' ? 'border-primary bg-primary/10' : 'border-border'}"
|
||||
>
|
||||
<input type="radio" bind:group={duplicateAction} value="merge" class="sr-only" />
|
||||
<span class="text-foreground">{$_('import.preview.merge')}</span>
|
||||
</label>
|
||||
<label
|
||||
class="flex items-center gap-2 px-4 py-2 rounded-lg border cursor-pointer transition-colors
|
||||
{duplicateAction === 'create' ? 'border-primary bg-primary/10' : 'border-border'}"
|
||||
>
|
||||
<input type="radio" bind:group={duplicateAction} value="create" class="sr-only" />
|
||||
<span class="text-foreground">{$_('import.preview.create')}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Contact list -->
|
||||
<div class="bg-card rounded-lg overflow-hidden">
|
||||
<div class="flex items-center justify-between p-4 border-b border-border">
|
||||
<h3 class="font-medium text-foreground">{$_('import.preview.contacts')}</h3>
|
||||
<div class="flex gap-2">
|
||||
<button type="button" onclick={selectAll} class="text-sm text-primary hover:underline">
|
||||
{$_('import.preview.selectAll')}
|
||||
</button>
|
||||
<span class="text-muted-foreground">|</span>
|
||||
<button type="button" onclick={deselectAll} class="text-sm text-primary hover:underline">
|
||||
{$_('import.preview.deselectAll')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="max-h-[400px] overflow-y-auto divide-y divide-border">
|
||||
{#each preview.contacts as contact, index}
|
||||
{@const duplicate = duplicatesByIndex.get(index)}
|
||||
{@const isSelected = selectedContacts.has(index)}
|
||||
|
||||
<label
|
||||
class="flex items-center gap-4 p-4 hover:bg-muted/50 cursor-pointer transition-colors
|
||||
{!isSelected ? 'opacity-50' : ''}"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onchange={() => toggleContact(index)}
|
||||
class="w-5 h-5 rounded border-border text-primary focus:ring-primary"
|
||||
/>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium text-foreground truncate">
|
||||
{getContactName(contact)}
|
||||
</span>
|
||||
{#if duplicate}
|
||||
<span class="text-xs px-2 py-0.5 rounded-full bg-yellow-500/10 text-yellow-500">
|
||||
{$_('import.preview.duplicateTag')}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="text-sm text-muted-foreground truncate">
|
||||
{getContactSubtitle(contact)}
|
||||
</div>
|
||||
{#if duplicate}
|
||||
<div class="text-xs text-yellow-500 mt-1">
|
||||
{$_('import.preview.matchesWith')} "{duplicate.existingContactName}" ({duplicate.matchField}:
|
||||
{duplicate.matchValue})
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-end gap-3">
|
||||
<button type="button" onclick={onCancel} class="btn btn-secondary" disabled={isImporting}>
|
||||
{$_('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleImport}
|
||||
class="btn btn-primary"
|
||||
disabled={isImporting || selectedContacts.size === 0}
|
||||
>
|
||||
{#if isImporting}
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<span class="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin"
|
||||
></span>
|
||||
{$_('import.importing')}
|
||||
</span>
|
||||
{:else}
|
||||
{$_('import.preview.importButton', { values: { count: selectedContacts.size } })}
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -2,6 +2,10 @@
|
|||
"app": {
|
||||
"name": "Contacts"
|
||||
},
|
||||
"common": {
|
||||
"back": "Zurück",
|
||||
"cancel": "Abbrechen"
|
||||
},
|
||||
"nav": {
|
||||
"contacts": "Kontakte",
|
||||
"groups": "Gruppen",
|
||||
|
|
@ -9,7 +13,50 @@
|
|||
"archive": "Archiv",
|
||||
"search": "Suche",
|
||||
"settings": "Einstellungen",
|
||||
"feedback": "Feedback"
|
||||
"feedback": "Feedback",
|
||||
"import": "Importieren"
|
||||
},
|
||||
"import": {
|
||||
"title": "Kontakte importieren",
|
||||
"subtitle": "Importiere Kontakte aus vCard- oder CSV-Dateien",
|
||||
"tabs": {
|
||||
"file": "Datei-Import",
|
||||
"google": "Google Kontakte"
|
||||
},
|
||||
"processing": "Datei wird verarbeitet...",
|
||||
"importing": "Importiere...",
|
||||
"downloadTemplate": "CSV-Vorlage herunterladen",
|
||||
"dropzone": {
|
||||
"title": "Datei hierher ziehen",
|
||||
"subtitle": "oder klicken zum Auswählen"
|
||||
},
|
||||
"preview": {
|
||||
"total": "Gesamt",
|
||||
"valid": "Gültig",
|
||||
"duplicates": "Duplikate",
|
||||
"selected": "Ausgewählt",
|
||||
"errors": "Fehler beim Parsen",
|
||||
"duplicateHandling": "Wie sollen Duplikate behandelt werden?",
|
||||
"skip": "Überspringen",
|
||||
"merge": "Zusammenführen",
|
||||
"create": "Trotzdem erstellen",
|
||||
"contacts": "Kontakte zum Importieren",
|
||||
"selectAll": "Alle auswählen",
|
||||
"deselectAll": "Alle abwählen",
|
||||
"duplicateTag": "Duplikat",
|
||||
"matchesWith": "Stimmt überein mit",
|
||||
"importButton": "{count} Kontakte importieren"
|
||||
},
|
||||
"result": {
|
||||
"title": "Import abgeschlossen",
|
||||
"subtitle": "Deine Kontakte wurden erfolgreich importiert",
|
||||
"imported": "Importiert",
|
||||
"merged": "Zusammengeführt",
|
||||
"skipped": "Übersprungen",
|
||||
"errors": "Fehler",
|
||||
"importMore": "Weitere importieren",
|
||||
"done": "Fertig"
|
||||
}
|
||||
},
|
||||
"contacts": {
|
||||
"title": "Kontakte",
|
||||
|
|
@ -60,5 +107,27 @@
|
|||
"saved": "Gespeichert",
|
||||
"deleted": "Gelöscht",
|
||||
"error": "Ein Fehler ist aufgetreten"
|
||||
},
|
||||
"google": {
|
||||
"loading": "Verbindung wird geprüft...",
|
||||
"connect": {
|
||||
"title": "Mit Google verbinden",
|
||||
"subtitle": "Importiere deine Kontakte direkt aus Google Kontakte",
|
||||
"button": "Mit Google verbinden"
|
||||
},
|
||||
"connected": "Verbunden mit Google",
|
||||
"disconnect": "Trennen",
|
||||
"contacts": "Kontakte",
|
||||
"loadMore": "Mehr laden"
|
||||
},
|
||||
"export": {
|
||||
"title": "Kontakte exportieren",
|
||||
"button": "Exportieren",
|
||||
"format": "Format auswählen",
|
||||
"selectedCount": "{count} Kontakte ausgewählt",
|
||||
"allContacts": "Alle Kontakte werden exportiert",
|
||||
"includeArchived": "Archivierte Kontakte einschließen",
|
||||
"exporting": "Exportiere...",
|
||||
"success": "Export erfolgreich"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@
|
|||
"app": {
|
||||
"name": "Contacts"
|
||||
},
|
||||
"common": {
|
||||
"back": "Back",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"nav": {
|
||||
"contacts": "Contacts",
|
||||
"groups": "Groups",
|
||||
|
|
@ -9,7 +13,50 @@
|
|||
"archive": "Archive",
|
||||
"search": "Search",
|
||||
"settings": "Settings",
|
||||
"feedback": "Feedback"
|
||||
"feedback": "Feedback",
|
||||
"import": "Import"
|
||||
},
|
||||
"import": {
|
||||
"title": "Import Contacts",
|
||||
"subtitle": "Import contacts from vCard or CSV files",
|
||||
"tabs": {
|
||||
"file": "File Import",
|
||||
"google": "Google Contacts"
|
||||
},
|
||||
"processing": "Processing file...",
|
||||
"importing": "Importing...",
|
||||
"downloadTemplate": "Download CSV template",
|
||||
"dropzone": {
|
||||
"title": "Drop file here",
|
||||
"subtitle": "or click to select"
|
||||
},
|
||||
"preview": {
|
||||
"total": "Total",
|
||||
"valid": "Valid",
|
||||
"duplicates": "Duplicates",
|
||||
"selected": "Selected",
|
||||
"errors": "Parse errors",
|
||||
"duplicateHandling": "How should duplicates be handled?",
|
||||
"skip": "Skip",
|
||||
"merge": "Merge",
|
||||
"create": "Create anyway",
|
||||
"contacts": "Contacts to import",
|
||||
"selectAll": "Select all",
|
||||
"deselectAll": "Deselect all",
|
||||
"duplicateTag": "Duplicate",
|
||||
"matchesWith": "Matches with",
|
||||
"importButton": "Import {count} contacts"
|
||||
},
|
||||
"result": {
|
||||
"title": "Import complete",
|
||||
"subtitle": "Your contacts have been successfully imported",
|
||||
"imported": "Imported",
|
||||
"merged": "Merged",
|
||||
"skipped": "Skipped",
|
||||
"errors": "Errors",
|
||||
"importMore": "Import more",
|
||||
"done": "Done"
|
||||
}
|
||||
},
|
||||
"contacts": {
|
||||
"title": "Contacts",
|
||||
|
|
@ -60,5 +107,27 @@
|
|||
"saved": "Saved",
|
||||
"deleted": "Deleted",
|
||||
"error": "An error occurred"
|
||||
},
|
||||
"google": {
|
||||
"loading": "Checking connection...",
|
||||
"connect": {
|
||||
"title": "Connect to Google",
|
||||
"subtitle": "Import your contacts directly from Google Contacts",
|
||||
"button": "Connect with Google"
|
||||
},
|
||||
"connected": "Connected to Google",
|
||||
"disconnect": "Disconnect",
|
||||
"contacts": "Contacts",
|
||||
"loadMore": "Load more"
|
||||
},
|
||||
"export": {
|
||||
"title": "Export Contacts",
|
||||
"button": "Export",
|
||||
"format": "Select format",
|
||||
"selectedCount": "{count} contacts selected",
|
||||
"allContacts": "All contacts will be exported",
|
||||
"includeArchived": "Include archived contacts",
|
||||
"exporting": "Exporting...",
|
||||
"success": "Export successful"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
283
apps/contacts/apps/web/src/routes/(app)/import/+page.svelte
Normal file
283
apps/contacts/apps/web/src/routes/(app)/import/+page.svelte
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import FileUploader from '$lib/components/import/FileUploader.svelte';
|
||||
import ImportPreview from '$lib/components/import/ImportPreview.svelte';
|
||||
import GoogleImport from '$lib/components/import/GoogleImport.svelte';
|
||||
import { importApi, type ImportPreviewResponse, type DuplicateAction } from '$lib/api/import';
|
||||
import { contactsStore } from '$lib/stores/contacts.svelte';
|
||||
import '$lib/i18n';
|
||||
|
||||
type Tab = 'file' | 'google';
|
||||
type Step = 'upload' | 'preview' | 'result';
|
||||
|
||||
// Get initial tab from URL
|
||||
let activeTab = $state<Tab>(($page.url.searchParams.get('tab') as Tab) || 'file');
|
||||
|
||||
let step = $state<Step>('upload');
|
||||
let isLoading = $state(false);
|
||||
let isImporting = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let selectedFile = $state<File | null>(null);
|
||||
let preview = $state<ImportPreviewResponse | null>(null);
|
||||
let result = $state<{
|
||||
imported: number;
|
||||
skipped: number;
|
||||
merged: number;
|
||||
errors: { index: number; contactName: string; error: string }[];
|
||||
} | null>(null);
|
||||
|
||||
function setActiveTab(tab: Tab) {
|
||||
activeTab = tab;
|
||||
// Reset file import state when switching tabs
|
||||
step = 'upload';
|
||||
preview = null;
|
||||
selectedFile = null;
|
||||
result = null;
|
||||
error = null;
|
||||
// Update URL without navigation
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('tab', tab);
|
||||
window.history.replaceState({}, '', url.toString());
|
||||
}
|
||||
|
||||
async function handleFileSelect(file: File) {
|
||||
selectedFile = file;
|
||||
error = null;
|
||||
isLoading = true;
|
||||
|
||||
try {
|
||||
preview = await importApi.preview(file);
|
||||
step = 'preview';
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Fehler beim Verarbeiten der Datei';
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImport(duplicateAction: DuplicateAction, skipIndices: number[]) {
|
||||
if (!preview) return;
|
||||
|
||||
isImporting = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
result = await importApi.execute(preview.contacts, duplicateAction, skipIndices);
|
||||
step = 'result';
|
||||
// Refresh contacts list
|
||||
await contactsStore.loadContacts();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Fehler beim Importieren';
|
||||
} finally {
|
||||
isImporting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
step = 'upload';
|
||||
preview = null;
|
||||
selectedFile = null;
|
||||
error = null;
|
||||
}
|
||||
|
||||
function handleDone() {
|
||||
goto('/');
|
||||
}
|
||||
|
||||
function handleImportMore() {
|
||||
step = 'upload';
|
||||
preview = null;
|
||||
selectedFile = null;
|
||||
result = null;
|
||||
error = null;
|
||||
}
|
||||
|
||||
async function handleDownloadTemplate() {
|
||||
try {
|
||||
await importApi.downloadTemplate();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Fehler beim Herunterladen';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$_('import.title')} - Contacts</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="max-w-4xl mx-auto space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-foreground">{$_('import.title')}</h1>
|
||||
<p class="text-muted-foreground mt-1">{$_('import.subtitle')}</p>
|
||||
</div>
|
||||
<a href="/" class="btn btn-secondary">
|
||||
{$_('common.back')}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="flex gap-2 border-b border-border">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => setActiveTab('file')}
|
||||
class="px-4 py-2 font-medium transition-colors border-b-2 -mb-px
|
||||
{activeTab === 'file'
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground'}"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
{$_('import.tabs.file')}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => setActiveTab('google')}
|
||||
class="px-4 py-2 font-medium transition-colors border-b-2 -mb-px
|
||||
{activeTab === 'google'
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground'}"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
/>
|
||||
<path
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
/>
|
||||
<path
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
/>
|
||||
<path
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
/>
|
||||
</svg>
|
||||
{$_('import.tabs.google')}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Error message (only for file import) -->
|
||||
{#if error && activeTab === 'file'}
|
||||
<div class="bg-red-500/10 border border-red-500/20 rounded-lg p-4 text-red-500">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- File Import Tab -->
|
||||
{#if activeTab === 'file'}
|
||||
<!-- Step: Upload -->
|
||||
{#if step === 'upload'}
|
||||
<div class="space-y-6">
|
||||
{#if isLoading}
|
||||
<div class="flex flex-col items-center justify-center py-12">
|
||||
<div
|
||||
class="h-12 w-12 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent"
|
||||
></div>
|
||||
<p class="mt-4 text-muted-foreground">{$_('import.processing')}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<FileUploader onFileSelect={handleFileSelect} />
|
||||
|
||||
<div class="text-center">
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleDownloadTemplate}
|
||||
class="text-primary hover:underline text-sm inline-flex items-center gap-2"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
{$_('import.downloadTemplate')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Step: Preview -->
|
||||
{#if step === 'preview' && preview}
|
||||
<ImportPreview {preview} onImport={handleImport} onCancel={handleCancel} {isImporting} />
|
||||
{/if}
|
||||
|
||||
<!-- Step: Result -->
|
||||
{#if step === 'result' && result}
|
||||
<div class="bg-card rounded-xl p-8 text-center space-y-6">
|
||||
<div
|
||||
class="w-20 h-20 mx-auto rounded-full bg-green-500/10 flex items-center justify-center text-green-500"
|
||||
>
|
||||
<svg class="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-foreground">{$_('import.result.title')}</h2>
|
||||
<p class="text-muted-foreground mt-2">{$_('import.result.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-4 max-w-md mx-auto">
|
||||
<div class="bg-green-500/10 rounded-lg p-4">
|
||||
<div class="text-3xl font-bold text-green-500">{result.imported}</div>
|
||||
<div class="text-sm text-muted-foreground">{$_('import.result.imported')}</div>
|
||||
</div>
|
||||
<div class="bg-blue-500/10 rounded-lg p-4">
|
||||
<div class="text-3xl font-bold text-blue-500">{result.merged}</div>
|
||||
<div class="text-sm text-muted-foreground">{$_('import.result.merged')}</div>
|
||||
</div>
|
||||
<div class="bg-gray-500/10 rounded-lg p-4">
|
||||
<div class="text-3xl font-bold text-gray-500">{result.skipped}</div>
|
||||
<div class="text-sm text-muted-foreground">{$_('import.result.skipped')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if result.errors.length > 0}
|
||||
<div class="bg-red-500/10 border border-red-500/20 rounded-lg p-4 text-left">
|
||||
<h3 class="font-medium text-red-500 mb-2">{$_('import.result.errors')}</h3>
|
||||
<ul class="text-sm text-red-400 space-y-1">
|
||||
{#each result.errors as err}
|
||||
<li>{err.contactName}: {err.error}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex justify-center gap-3">
|
||||
<button type="button" onclick={handleImportMore} class="btn btn-secondary">
|
||||
{$_('import.result.importMore')}
|
||||
</button>
|
||||
<button type="button" onclick={handleDone} class="btn btn-primary">
|
||||
{$_('import.result.done')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Google Import Tab -->
|
||||
{#if activeTab === 'google'}
|
||||
<GoogleImport />
|
||||
{/if}
|
||||
</div>
|
||||
Loading…
Add table
Add a link
Reference in a new issue