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:
Till-JS 2025-12-03 15:55:04 +01:00
parent 79b629b820
commit 180eced0d0
34 changed files with 4182 additions and 5 deletions

View file

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

View file

@ -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",

View file

@ -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 {}

View file

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

View file

@ -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';

View 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;
}

View 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);
}
}

View 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 {}

View 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}`);
}
}
}

View file

@ -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;
}

View file

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

View 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;
}

View 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);
}
}

View 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 {}

View 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,
};
}
}

View 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;
}

View 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);
}
}

View 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 {}

View 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';
}
}

View 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');
}
}

View 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;
}
}

View file

@ -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;
}
}

View 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 {};

View 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);
}

View 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 }),
});
},
};

View 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);
},
};

View 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)} />

View file

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

View file

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

View file

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

View file

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

View file

@ -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"
}
}

View file

@ -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"
}
}

View 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>