Page MenuHomePhorge

D5235.1774820355.diff
No OneTemporary

Authored By
Unknown
Size
20 KB
Referenced Files
None
Subscribers
None

D5235.1774820355.diff

diff --git a/src/app/Console/Commands/Contact/ImportCommand.php b/src/app/Console/Commands/Contact/ImportCommand.php
new file mode 100644
--- /dev/null
+++ b/src/app/Console/Commands/Contact/ImportCommand.php
@@ -0,0 +1,216 @@
+<?php
+
+namespace App\Console\Commands\Contact;
+
+use App\Contact;
+use App\Console\Command;
+
+class ImportCommand extends Command
+{
+ /**
+ * The name and signature of the console command.
+ *
+ * @var string
+ */
+ protected $signature = 'contact:import {user} {file}';
+
+ /**
+ * The console command description.
+ *
+ * @var string
+ */
+ protected $description = 'Import user contacts (global addressbook) from a file.';
+
+ /**
+ * Execute the console command.
+ *
+ * @return mixed
+ */
+ public function handle()
+ {
+ parent::handle();
+
+ $user = $this->getUser($this->argument('user'));
+
+ if (!$user) {
+ $this->error("User not found.");
+ return 1;
+ }
+
+ // TODO: We should not allow an import for non-owner users
+ // or find the account owner and use it instead
+
+ $file = $this->argument('file');
+
+ if (!file_exists($file)) {
+ $this->error("File '{$file}' does not exist.");
+ return 1;
+ }
+
+ $header = file_get_contents($file, false, null, 0, 1024);
+
+ if ($header === false) {
+ $this->error("Failed to read file '{$file}'.");
+ return 1;
+ }
+
+ if (!strlen($header)) {
+ $this->error("File '{$file}' is empty.");
+ return 1;
+ }
+
+ if (!($type = $this->getFileType($header))) {
+ $this->error("Unsupported file type.");
+ return 1;
+ }
+
+ $bar = $this->createProgressBar(1, "Reading file");
+
+ $contacts = $this->getContactsFromFile($file, $type);
+
+ $bar->advance();
+ $bar->finish();
+
+ if (empty($contacts)) {
+ $this->error("No contacts found in the file.");
+ return 1;
+ }
+
+ $bar = $this->createProgressBar(count($contacts), "Importing contacts");
+
+ // TODO: An option to remove all existing contacts
+ // TODO: Will this use too much memory for a huge addressbook?
+ $existing = $user->contacts()->get()->keyBy('email')->all();
+
+ foreach ($contacts as $contact) {
+ $exists = $existing[$contact->email] ?? null;
+
+ if (!$exists) {
+ $contact->user_id = $user->id;
+ $contact->save();
+ $existing[$contact->email] = $contact;
+ } else {
+ $exists->name = $contact->name;
+ $exists->save();
+ }
+
+ $bar->advance();
+ }
+
+ $bar->finish();
+
+ $this->info("DONE");
+ }
+
+ /**
+ * Parse the input file
+ *
+ * @param string $file File location
+ * @param string $type File type (format)
+ *
+ * @return array<Contact>
+ */
+ protected function getContactsFromFile(string $file, string $type): array
+ {
+ switch ($type) {
+ case 'csv':
+ return $this->parseCsvFile($file);
+ }
+
+ return [];
+ }
+
+ /**
+ * Recognize file type by parsing a chunk of the content from the file start
+ *
+ * @param string $header File content
+ */
+ protected function getFileType(string $header): ?string
+ {
+ [$line, ] = preg_split('/\r?\n/', $header);
+
+ // TODO: vCard, LDIF
+
+ // Is this CSV format?
+ $arr = str_getcsv($line, ",", "\"", "\\");
+ if (count($arr) > 1) {
+ // We expect first line to contain data in supported order or field names
+ if (
+ ($arr[0] == 'Email' && $arr[1] == 'Name')
+ || in_array('Email', $arr)
+ || strpos($arr[0], '@') !== false
+ ) {
+ return 'csv';
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Validate contact data
+ */
+ protected function isValid(Contact $contact): bool
+ {
+ // Validate email address
+ // Validate (truncate) display name
+
+ return true;
+ }
+
+ /**
+ * Parse a CSV file
+ *
+ * @param string $file File location
+ *
+ * @return array<Contact>
+ */
+ protected function parseCsvFile(string $file): array
+ {
+ $handle = fopen($file, 'r');
+ if ($handle === false) {
+ return [];
+ }
+
+ $fields = null;
+ $contacts = [];
+ $all_props = ['email', 'name'];
+
+ while (($data = fgetcsv($handle, 4096, ",", "\"", "\\")) !== false) {
+ if ($fields === null) {
+ $filtered = array_filter(
+ array_map('strtolower', $data),
+ fn ($h) => in_array($h, $all_props)
+ );
+
+ if (count($filtered) == 1 || count($filtered) == 2) {
+ foreach ($all_props as $prop) {
+ if (($idx = array_search($prop, $filtered)) !== false) {
+ $fields[$prop] = $idx;
+ }
+ }
+
+ continue;
+ } else {
+ $fields = ['email' => 0, 'name' => 1];
+ }
+ }
+
+ $contact = new Contact();
+
+ foreach ($all_props as $prop) {
+ if (isset($fields[$prop]) && isset($data[$fields[$prop]])) {
+ $contact->{$prop} = $data[$fields[$prop]];
+ }
+ }
+
+ if ($this->isValid($contact)) {
+ $contacts[] = $contact;
+ }
+ }
+
+ fclose($handle);
+
+ return $contacts;
+ }
+}
diff --git a/src/app/Console/Commands/Scalpel/Contact/CreateCommand.php b/src/app/Console/Commands/Scalpel/Contact/CreateCommand.php
new file mode 100644
--- /dev/null
+++ b/src/app/Console/Commands/Scalpel/Contact/CreateCommand.php
@@ -0,0 +1,15 @@
+<?php
+
+namespace App\Console\Commands\Scalpel\Contact;
+
+use App\Console\ObjectCreateCommand;
+
+class CreateCommand extends ObjectCreateCommand
+{
+ protected $hidden = true;
+
+ protected $commandPrefix = 'scalpel';
+ protected $objectClass = \App\Contact::class;
+ protected $objectName = 'contact';
+ protected $objectTitle = null;
+}
diff --git a/src/app/Console/Commands/Scalpel/Contact/DeleteCommand.php b/src/app/Console/Commands/Scalpel/Contact/DeleteCommand.php
new file mode 100644
--- /dev/null
+++ b/src/app/Console/Commands/Scalpel/Contact/DeleteCommand.php
@@ -0,0 +1,16 @@
+<?php
+
+namespace App\Console\Commands\Scalpel\Contact;
+
+use App\Console\ObjectDeleteCommand;
+
+class DeleteCommand extends ObjectDeleteCommand
+{
+ protected $dangerous = true;
+ protected $hidden = true;
+
+ protected $commandPrefix = 'scalpel';
+ protected $objectClass = \App\Contact::class;
+ protected $objectName = 'contact';
+ protected $objectTitle = null;
+}
diff --git a/src/app/Console/Commands/Scalpel/Contact/ReadCommand.php b/src/app/Console/Commands/Scalpel/Contact/ReadCommand.php
new file mode 100644
--- /dev/null
+++ b/src/app/Console/Commands/Scalpel/Contact/ReadCommand.php
@@ -0,0 +1,15 @@
+<?php
+
+namespace App\Console\Commands\Scalpel\Contact;
+
+use App\Console\ObjectReadCommand;
+
+class ReadCommand extends ObjectReadCommand
+{
+ protected $hidden = true;
+
+ protected $commandPrefix = 'scalpel';
+ protected $objectClass = \App\Contact::class;
+ protected $objectName = 'contact';
+ protected $objectTitle = null;
+}
diff --git a/src/app/Console/Commands/Scalpel/Contact/UpdateCommand.php b/src/app/Console/Commands/Scalpel/Contact/UpdateCommand.php
new file mode 100644
--- /dev/null
+++ b/src/app/Console/Commands/Scalpel/Contact/UpdateCommand.php
@@ -0,0 +1,15 @@
+<?php
+
+namespace App\Console\Commands\Scalpel\Contact;
+
+use App\Console\ObjectUpdateCommand;
+
+class UpdateCommand extends ObjectUpdateCommand
+{
+ protected $hidden = true;
+
+ protected $commandPrefix = 'scalpel';
+ protected $objectClass = \App\Contact::class;
+ protected $objectName = 'contact';
+ protected $objectTitle = null;
+}
diff --git a/src/app/Console/Commands/User/ContactsCommand.php b/src/app/Console/Commands/User/ContactsCommand.php
new file mode 100644
--- /dev/null
+++ b/src/app/Console/Commands/User/ContactsCommand.php
@@ -0,0 +1,13 @@
+<?php
+
+namespace App\Console\Commands\User;
+
+use App\Console\ObjectRelationListCommand;
+
+class ContactsCommand extends ObjectRelationListCommand
+{
+ protected $objectClass = \App\User::class;
+ protected $objectName = 'user';
+ protected $objectTitle = 'email';
+ protected $objectRelation = 'contacts';
+}
diff --git a/src/app/Contact.php b/src/app/Contact.php
new file mode 100644
--- /dev/null
+++ b/src/app/Contact.php
@@ -0,0 +1,41 @@
+<?php
+
+namespace App;
+
+use Illuminate\Database\Eloquent\Model;
+
+/**
+ * The eloquent definition of a Contact (in the global addressbook).
+ *
+ * @property int $id The contact identifier
+ * @property string $email The contact email address
+ * @property string $name The contact (display) name
+ * @property int $user_id The contact owner
+ */
+class Contact extends Model
+{
+ /** @var array<string, string> The attributes that should be cast */
+ protected $casts = [
+ 'created_at' => 'datetime:Y-m-d H:i:s',
+ ];
+
+ /** @var array<int, string> The attributes that are mass assignable */
+ protected $fillable = [
+ 'email',
+ 'name',
+ 'user_id',
+ ];
+
+ /** @var bool Indicates if the model should be timestamped. */
+ public $timestamps = false;
+
+ /**
+ * Ensure the email is appropriately cased.
+ *
+ * @param string $email Email address
+ */
+ public function setEmailAttribute(string $email): void
+ {
+ $this->attributes['email'] = strtolower($email);
+ }
+}
diff --git a/src/app/Http/Controllers/API/V4/SearchController.php b/src/app/Http/Controllers/API/V4/SearchController.php
--- a/src/app/Http/Controllers/API/V4/SearchController.php
+++ b/src/app/Http/Controllers/API/V4/SearchController.php
@@ -16,6 +16,56 @@
class SearchController extends Controller
{
+ /**
+ * Search request for user's contacts
+ *
+ * @param \Illuminate\Http\Request $request The API request.
+ *
+ * @return \Illuminate\Http\JsonResponse The response
+ */
+ public function searchContacts(Request $request)
+ {
+ $user = $this->guard()->user();
+ $search = trim(request()->input('search'));
+ $limit = intval(request()->input('limit'));
+
+ if ($limit <= 0) {
+ $limit = 15;
+ } elseif ($limit > 100) {
+ $limit = 100;
+ }
+
+ $owner = $user->walletOwner();
+
+ if (!$owner) {
+ return $this->errorResponse(500);
+ }
+
+ // Prepare the query
+ $query = $owner->contacts();
+
+ if (strlen($search)) {
+ $query->Where(function ($query) use ($search) {
+ $query->whereLike('name', "%{$search}%")
+ ->orWhereLike('email', "%{$search}%");
+ });
+ }
+
+ // Execute the query
+ $result = $query->orderBy('email')->limit($limit)->get()
+ ->map(function ($contact) {
+ return [
+ 'email' => $contact->email,
+ 'name' => $contact->name,
+ ];
+ });
+
+ return response()->json([
+ 'list' => $result,
+ 'count' => count($result),
+ ]);
+ }
+
/**
* Search request for user's email addresses
*
diff --git a/src/app/User.php b/src/app/User.php
--- a/src/app/User.php
+++ b/src/app/User.php
@@ -274,6 +274,16 @@
return $this->canDelete($object);
}
+ /**
+ * Contacts (global addressbook) for this user.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\HasMany
+ */
+ public function contacts()
+ {
+ return $this->hasMany(Contact::class);
+ }
+
/**
* Degrade the user
*
diff --git a/src/database/migrations/2025_05_02_100000_create_contacts_table.php b/src/database/migrations/2025_05_02_100000_create_contacts_table.php
new file mode 100644
--- /dev/null
+++ b/src/database/migrations/2025_05_02_100000_create_contacts_table.php
@@ -0,0 +1,38 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+ /**
+ * Run the migrations.
+ */
+ public function up(): void
+ {
+ Schema::create(
+ 'contacts',
+ function (Blueprint $table) {
+ $table->bigIncrements('id');
+ $table->bigInteger('user_id');
+ $table->string('name');
+ $table->string('email');
+ $table->timestamp('created_at')->useCurrent();
+
+ $table->unique(['user_id', 'email']);
+
+ $table->foreign('user_id')->references('id')->on('users')
+ ->onDelete('cascade')->onUpdate('cascade');
+ }
+ );
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('contacts');
+ }
+};
diff --git a/src/routes/api.php b/src/routes/api.php
--- a/src/routes/api.php
+++ b/src/routes/api.php
@@ -180,6 +180,7 @@
Route::get('payments/status', [API\V4\PaymentsController::class, 'paymentStatus']);
Route::get('search/self', [API\V4\SearchController::class, 'searchSelf']);
+ Route::get('search/contacts', [API\V4\SearchController::class, 'searchContacts']);
if (\config('app.with_user_search')) {
Route::get('search/user', [API\V4\SearchController::class, 'searchUser']);
}
diff --git a/src/tests/Feature/Console/Contact/ImportTest.php b/src/tests/Feature/Console/Contact/ImportTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/Console/Contact/ImportTest.php
@@ -0,0 +1,86 @@
+<?php
+
+namespace Tests\Feature\Console\Contact;
+
+use App\Contact;
+use Tests\TestCase;
+
+class ImportTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ Contact::truncate();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ Contact::truncate();
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test input checking
+ */
+ public function testHandle(): void
+ {
+ $user = $this->getTestUser('ned@kolab.org');
+ $path = self::BASE_DIR . '/data';
+
+ // Non-existing user
+ $code = \Artisan::call("contact:import unknown@unknown.org {$path}/contacts.csv");
+ $output = trim(\Artisan::output());
+ $this->assertSame(1, $code);
+ $this->assertSame("User not found.", $output);
+
+ // Non-existing file
+ $code = \Artisan::call("contact:import {$user->email} {$path}/non-existing.csv");
+ $output = trim(\Artisan::output());
+ $this->assertSame(1, $code);
+ $this->assertSame("File '{$path}/non-existing.csv' does not exist.", $output);
+
+ // Empty file
+ $code = \Artisan::call("contact:import {$user->email} {$path}/empty.csv");
+ $output = trim(\Artisan::output());
+ $this->assertSame(1, $code);
+ $this->assertSame("File '{$path}/empty.csv' is empty.", $output);
+
+ // Unsupported file type
+ $code = \Artisan::call("contact:import {$user->email} {$path}/takeout.zip");
+ $output = trim(\Artisan::output());
+ $this->assertSame(1, $code);
+ $this->assertSame("Unsupported file type.", $output);
+ }
+
+ /**
+ * Test importing from a CSV file
+ */
+ public function testHandleCsv(): void
+ {
+ $user = $this->getTestUser('ned@kolab.org');
+ $path = self::BASE_DIR . '/data';
+
+ // Test a proper csv file
+ $code = \Artisan::call("contact:import {$user->email} {$path}/contacts.csv");
+ $output = trim(\Artisan::output());
+ $this->assertSame(0, $code);
+ $this->assertStringContainsString("DONE", $output);
+
+ $contacts = $user->contacts()->orderBy('email')->get();
+ $this->assertCount(2, $contacts);
+ $this->assertSame('contact1@test.com', $contacts[0]->email);
+ $this->assertSame('Contact1', $contacts[0]->name);
+ $this->assertSame('contact2@test.com', $contacts[1]->email);
+ $this->assertSame('Contact2', $contacts[1]->name);
+
+ // TODO: Test only-emails case (data/email.csv)
+ }
+}
diff --git a/src/tests/Feature/Controller/SearchTest.php b/src/tests/Feature/Controller/SearchTest.php
--- a/src/tests/Feature/Controller/SearchTest.php
+++ b/src/tests/Feature/Controller/SearchTest.php
@@ -2,6 +2,7 @@
namespace Tests\Feature\Controller;
+use App\Contact;
use Tests\TestCase;
class SearchTest extends TestCase
@@ -14,6 +15,7 @@
parent::setUp();
$this->deleteTestUser('jane@kolabnow.com');
+ Contact::truncate();
}
/**
@@ -22,10 +24,78 @@
public function tearDown(): void
{
$this->deleteTestUser('jane@kolabnow.com');
+ Contact::truncate();
parent::tearDown();
}
+ /**
+ * Test searching contacts
+ */
+ public function testSearchContacts(): void
+ {
+ // Unauth access not allowed
+ $response = $this->get("api/v4/search/contacts");
+ $response->assertStatus(401);
+
+ $john = $this->getTestUser('john@kolab.org');
+ $jack = $this->getTestUser('jack@kolab.org');
+
+ // Empty list
+ $response = $this->actingAs($john)->get("api/v4/search/contacts");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(0, $json['count']);
+ $this->assertSame([], $json['list']);
+
+ // Create two contacts
+ $john->contacts()->create(['email' => 'test1@domain.com', 'name' => 'Name1']);
+ $john->contacts()->create(['email' => 'test2@domain.com', 'name' => 'Name2']);
+
+ $response = $this->actingAs($john)->get("api/v4/search/contacts?search=test");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(2, $json['count']);
+ $this->assertCount(2, $json['list']);
+ $this->assertSame(['email' => 'test1@domain.com', 'name' => 'Name1'], $json['list'][0]);
+ $this->assertSame(['email' => 'test2@domain.com', 'name' => 'Name2'], $json['list'][1]);
+
+ // Search by part of email address
+ $response = $this->actingAs($john)->get("api/v4/search/contacts?search=ST1");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(1, $json['count']);
+ $this->assertCount(1, $json['list']);
+ $this->assertSame(['email' => 'test1@domain.com', 'name' => 'Name1'], $json['list'][0]);
+
+ // Search by part of name
+ $response = $this->actingAs($john)->get("api/v4/search/contacts?search=ME2");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(1, $json['count']);
+ $this->assertCount(1, $json['list']);
+ $this->assertSame(['email' => 'test2@domain.com', 'name' => 'Name2'], $json['list'][0]);
+
+ // Accessing by non-controller user
+ $response = $this->actingAs($jack)->get("api/v4/search/contacts");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(2, $json['count']);
+ $this->assertCount(2, $json['list']);
+ $this->assertSame(['email' => 'test1@domain.com', 'name' => 'Name1'], $json['list'][0]);
+ $this->assertSame(['email' => 'test2@domain.com', 'name' => 'Name2'], $json['list'][1]);
+ }
+
/**
* Test searching
*/
@@ -86,6 +156,7 @@
*/
public function testSearchUser(): void
{
+ // FIXME: It looks it's not working when you have APP_WITH_USER_SEARCH=true in .env file
\putenv('APP_WITH_USER_SEARCH=false'); // can't be done using \config()
$this->refreshApplication(); // reload routes
diff --git a/src/tests/data/contacts.csv b/src/tests/data/contacts.csv
new file mode 100644
--- /dev/null
+++ b/src/tests/data/contacts.csv
@@ -0,0 +1,3 @@
+"Email","Name","Organization"
+"contact1@test.com","Contact1","Org1"
+"contact2@test.com","Contact2","Org2"

File Metadata

Mime Type
text/plain
Expires
Sun, Mar 29, 9:39 PM (5 d, 22 h ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18776652
Default Alt Text
D5235.1774820355.diff (20 KB)

Event Timeline