Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F117378159
D5235.1774820355.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
20 KB
Referenced Files
None
Subscribers
None
D5235.1774820355.diff
View Options
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
Details
Attached
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)
Attached To
Mode
D5235: Global Addressbook
Attached
Detach File
Event Timeline