This commit is contained in:
2023-10-02 23:10:49 +03:00
commit c6cb488379
255 changed files with 18731 additions and 0 deletions
+56
View File
@@ -0,0 +1,56 @@
<?php
namespace Lucent\AccessKey;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Validator;
use Lucent\LucentException;
use Lucent\Member\Role;
class AccessKey
{
public function __construct(
public readonly string $_id,
public readonly string $name,
public readonly Role $role,
public readonly string $token,
private readonly ?string $showOnceToken = null,
) {
$validator = Validator::make($this->toArray(), [
'name' => 'min:3,max:120',
]);
if ($validator->fails()) {
throw new LucentException($validator->errors()->first());
}
}
public static function fromArray(array $data): AccessKey
{
return new AccessKey(
_id: data_get($data, "_id"),
name: data_get($data, "name"),
role: Role::from(data_get($data, "role")),
token: data_get($data, "token"),
);
}
public function getShowOnceToken(): ?string
{
return $this->showOnceToken;
}
public function isValid(string $token): bool
{
return Hash::check($token, $this->token);
}
public function toArray(): array
{
return \json_decode(\json_encode($this), true);
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace Lucent\AccessKey;
use Carbon\Carbon;
use Lucent\Channel\ChannelContext;
use Lucent\DB\Monger;
class AccessKeyRepo
{
public static function findByToken(string $token): ?AccessKey
{
$channel = ChannelContext::get()->channel;
return $channel->accessKeys->firstWhere("token",$token);
}
public static function add(AccessKey $accessKey): void
{
$channel = ChannelContext::get()->channel;
Monger::central()->updateOne("channels", ["_id" => $channel->_id], [
'$push' => [
"accessKeys" => $accessKey->toArray()
],
'$set' => [
"updatedAt" => Carbon::now()->toJson(),
]
]);
}
public static function remove(string $id): void
{
$channel = ChannelContext::get()->channel;
Monger::central()->updateOne("channels", ["_id" => $channel->_id], [
'$pull' => [
"accessKeys" => ["_id" => $id]
],
'$set' => [
"updatedAt" => Carbon::now()->toJson(),
]
]);
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace Lucent\AccessKey;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Lucent\Id\Id;
use Lucent\LucentException;
use Lucent\Member\Role;
class AccessKeyService
{
public static function create(string $name, string $role): AccessKey
{
$showOnceToken = Str::random(48);
$accessKey = new AccessKey(
_id: Id::new(),
name: $name,
token: hash("sha256", $showOnceToken),
role: Role::from($role),
showOnceToken: $showOnceToken,
);
AccessKeyRepo::add($accessKey);
return $accessKey;
}
public static function findByToken(string $token): ?AccessKey
{
$hashedToken = hash("sha256", $token);
return AccessKeyRepo::findByToken($hashedToken);
}
public static function remove(string $id): void
{
AccessKeyRepo::remove($id);
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
namespace Lucent\AccessKey;
use Illuminate\Support\Collection;
/**
* @extends \Illuminate\Support\Collection<int|string, AccessKey>
*/
final class AccessKeysCollection extends Collection
{
public function __construct(
AccessKey ...$array
) {
parent::__construct($array);
}
/**
* @return AccessKey[]
**/
public function toArray(): array
{
return collect($this)->values()->toArray();
}
public function toDB(): array
{
return \json_decode(\json_encode($this), true);
}
public static function fromArray(array $data): AccessKeysCollection
{
$item = array_map([AccessKey::class, 'fromArray'], $data);
return new AccessKeysCollection(...$item);
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace Lucent\AccessKey;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Lucent\Account\Role;
class ApiMiddleware
{
public function handle(Request $request, Closure $next, string $accessLevel): Response
{
$bearerToken = $request->header('Authorization');
$token = str_replace('Bearer ', '', $bearerToken);
$role = match ($token) {
config("lucent.read_key") => Role::READER,
config("lucent.write_key") => Role::EDITOR,
config("lucent.developer_key") => Role::DEVELOPER,
default => ""
};
if (empty($role)) {
abort(401);
}
if (!$role->hasAccess($accessLevel)) {
abort(401);
}
$request->mergeIfMissing(['userId' => "system"]);
$request->mergeIfMissing(['userRole' => $role->value]);
return $next($request);
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace Lucent\Account;
use Lucent\Primitive\Collection;
readonly class AccountService
{
public function __construct(
private UserRepo $userRepo,
)
{
}
/**
* @return Collection<User>
*/
public function all(): Collection
{
return $this->userRepo->all();
}
/**
* @return Collection<UserProfile>
*/
public function allProfiles(): Collection
{
return $this->all()->map(fn($user) => $user->safe());
}
}
+158
View File
@@ -0,0 +1,158 @@
<?php
namespace Lucent\Account;
use Carbon\Carbon;
use Illuminate\Contracts\Session\Session;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
use Lucent\Channel\ChannelService;
use Lucent\LucentException;
use Lucent\Mail\LoginMail;
readonly class AuthService
{
public function __construct(
private ChannelService $channelService,
private UserRepo $userRepo,
public Session $session,
)
{
}
public function currentUserId(): ?string
{
return $this->session->get("user.id");
}
public function isLoggedIn(): bool
{
return !empty($this->currentUserId());
}
/**
* @throws LucentException
*/
public function login(string $email, string $token): void
{
$user = $this->userRepo->findByEmail(new Email($email));
if ($user->isEmpty()) {
throw new LucentException("You account was not found");
}
if ($user->get()->role === Role::REMOVED) {
throw new LucentException("Your account is not active");
}
if ($user->get()->mailToken !== $token) {
throw new LucentException("Token has expired or is invalid");
}
if (Carbon::parse($user->get()->loggedInAt)->lte(Carbon::now()->subHours(1))) {
throw new LucentException("Token has expired.");
}
$newUser = $user->get();
$newUser->updatedAt = Carbon::now()->toJson();
$newUser->mailToken = null;
$this->userRepo->update($newUser);
$this->session->put(["user" => $user->get()->safe()]);
}
public function create(string $name, string $email, string $role): User
{
$user = new User(
id: (string)Str::uuid(),
name: new Name($name),
email: new Email($email),
role: Role::from($role),
createdAt: Carbon::now()->toJson(),
updatedAt: Carbon::now()->toJson(),
loggedInAt: Carbon::now()->toJson(),
mailToken: Token::new(32),
);
$this->userRepo->insert($user);
return $user;
}
/**
* @throws LucentException
*/
public function sendLoginEmail(string $email): void
{
$emailAddress = (new Email($email));
$user = $this->userRepo->findByEmail($emailAddress);
if ($user->isEmpty()) {
throw new LucentException("User not found");
}
if ($user->get()->role === Role::REMOVED) {
throw new LucentException("Cannot reset email if the user is not active");
}
$newToken = $this->userRepo->updateLoginToken($user->get()->id);
Mail::to($email)->send(
new LoginMail(
$email,
$newToken,
$this->channelService->channel->lucentUrl
)
);
}
/**
* @throws LucentException
*/
public function changeRole(string $userId, string $newRole): void
{
$user = $this->userRepo->findById($userId);
if ($user->isEmpty()) {
throw new LucentException("User not found");
}
$newUser = $user->get();
$newUser->role = Role::from($newRole);
$newUser->updatedAt = Carbon::now()->toJson();
$this->userRepo->update($newUser);
}
/**
* @throws LucentException
*/
public function updateName(string $userId, string $name): void
{
$name = (new Name($name));
$this->userRepo->updateName($userId, $name);
}
/**
* @throws LucentException
*/
public function invite(
string $name,
string $email,
string $role
): User
{
$user = $this->create($name, $email, $role);
$this->sendLoginEmail($user->email);
return $user;
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace Lucent\Account;
use JsonSerializable;
use Lucent\Validator\Validator;
class Email implements JsonSerializable
{
private string $value;
function __construct(string $value)
{
Validator::single("Email", $value, "required|email|min:6|max:50");
$this->value = \strtolower($value);
}
function value(): string
{
return $this->value;
}
function __toString(): string{
return $this->value;
}
public function jsonSerialize(): string
{
return $this->value;
}
}
+37
View File
@@ -0,0 +1,37 @@
<?php
namespace Lucent\Account;
use JsonSerializable;
use Lucent\LucentException;
use Lucent\Validator\Validator;
class Name implements JsonSerializable
{
public string $value;
/**
* @throws LucentException
*/
function __construct(string $value)
{
Validator::single("Name", $value, "required|min:2|max:50");
$this->value = $value;
}
public function value(): string
{
return $this->value;
}
public function __toString(): string
{
return $this->value;
}
public function jsonSerialize(): string
{
return $this->value;
}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace Lucent\Account;
enum Role: string
{
case ADMIN = 'admin';
case EDITOR = 'editor';
case READER = 'reader';
case REMOVED = 'removed';
function hasAccess(string $roleName): bool
{
$trialRole = Role::from($roleName);
$access = match ($trialRole) {
Role::ADMIN => [Role::ADMIN, Role::DEVELOPER, Role::EDITOR, Role::READER],
Role::EDITOR => [Role::EDITOR, Role::READER],
Role::READER => [Role::READER],
Role::REMOVED => [],
};
return in_array($trialRole, $access);
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
namespace Lucent\Account;
class Token
{
public static function new(int $length = 64): string
{
return bin2hex(random_bytes($length));
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace Lucent\Account;
class User
{
function __construct(
public string $id,
public Name $name,
public Email $email,
public Role $role,
public string $createdAt,
public string $updatedAt,
public ?string $loggedInAt = null,
public ?string $mailToken = null,
)
{
}
public static function fromArray(array $data): User
{
return new User(
id: $data["id"],
name: new Name($data["name"] ?? ""),
email: new Email($data["email"]),
role: Role::tryFrom($data["role"]),
createdAt: $data["createdAt"],
updatedAt: $data["updatedAt"],
loggedInAt: $data["loggedInAt"] ?? null,
mailToken: $data["mailToken"] ?? null,
);
}
public function safe(): array
{
$userData = collect(toArray($this));
return $userData->only(["id", "name", "email", "role", "status"])->toArray();
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace Lucent\Account;
readonly class UserProfile
{
function __construct(
public string $id,
public string $name,
public string $email,
public Role $role,
)
{
}
public static function fromUser(User $user): UserProfile
{
return new UserProfile(
$user->id,
$user->name,
$user->email,
$user->role,
);
}
public static function fromArray(array $data): ?UserProfile
{
if (empty($data)) {
return null;
}
return new UserProfile(
id: data_get($data, "id"),
name: data_get($data, "name"),
email: data_get($data, "email"),
role: data_get($data, "role"),
);
}
}
+91
View File
@@ -0,0 +1,91 @@
<?php
namespace Lucent\Account;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use Lucent\Primitive\Collection;
use PhpOption\Option;
class UserRepo
{
public function count(): int
{
return DB::table("users")->count();
}
/**
* @return Collection<User>
*/
public function all(): Collection
{
$usersData = DB::table("users")->whereNot("status", "invite")->get();
$users = array_map(fn($userData) => User::fromArray((array)$userData), $usersData->toArray());
return new Collection($users);
}
public static function insert(User $user): void
{
$userData = toArray($user);
DB::table("users")->insert($userData);
}
public function update(User $user): void
{
$userData = toArray($user);
DB::table("users")->where("id", $user->id)->update($userData);
}
public function updateLoginToken(string $id): string
{
$newToken = Token::new(32);
DB::table("users")
->where("id", $id)
->update([
'loggedInAt' => Carbon::now()->toJson(),
'mailToken' => $newToken,
]);
return $newToken;
}
/**
* @return Option<User>
*/
public function findByEmail(Email $email): Option
{
$user = DB::table("users")->where("email", $email->value())->first();
if (empty($user)) {
return none();
}
return some(User::fromArray((array)$user));
}
/**
* @return Option<User>
*/
public function findById(string $id): Option
{
$user = DB::table("users")->where("id", $id)->first();
if (empty($user)) {
return none();
}
return some(User::fromArray((array)$user));
}
public function updateName(string $userId, Name $name): void
{
DB::table("users")->where("id", $userId)->update(["name" => $name->value]);
}
}
+55
View File
@@ -0,0 +1,55 @@
<?php
namespace Lucent;
use ArrayAccess;
use JsonSerializable;
/**
* @implements ArrayAccess<string,mixed>
*/
class ArrayContainer implements ArrayAccess, JsonSerializable
{
public function __construct(public array $data)
{
}
public function get(string $key, mixed $default = null): mixed
{
return $this->data[$key] ?? $default;
}
public function offsetSet($offset, $value): void
{
if (is_null($offset)) {
$this->data[] = $value;
} else {
$this->data[$offset] = $value;
}
}
public function offsetExists($offset): bool
{
return isset($this->data[$offset]);
}
public function offsetUnset($offset): void
{
unset($this->data[$offset]);
}
public function offsetGet($offset): mixed
{
return $this->data[$offset] ?? null;
}
public function jsonSerialize(): array
{
return $this->data;
}
public function toArray(): array
{
return $this->data;
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
namespace Lucent\Channel;
use Lucent\Primitive\Collection;
use Lucent\Schema\Schema;
final class Channel
{
public string $lucentUrl;
public string $filesUrl;
/**
* @param Collection<Schema> $schemas
*/
function __construct(
public string $name,
public string $url,
public Collection $schemas,
public array $imageFilters,
// public Collection $previewTargets,
)
{
$this->lucentUrl = $url . "/lucent";
$this->filesUrl = $url . "/storage";
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
namespace Lucent\Channel;
use Lucent\Primitive\Collection;
use Lucent\Schema\Schema;
use Lucent\Schema\SchemaService;
use PhpOption\Option;
final class ChannelService
{
public Channel $channel;
private function __construct(
public SchemaService $schemaService
)
{
}
public static function fromConfig(): ChannelService
{
$configJson = file_get_contents(storage_path("lucent.config.json"));
$configArray = json_decode($configJson, true);
$schemaService = new SchemaService();
$schemasCollection = (new Collection($configArray["schemas"]))->map([$schemaService, 'fromArray']);
$channel = new Channel(
name: $configArray["name"],
url: rtrim($configArray["url"], "/"),
schemas: $schemasCollection,
imageFilters: $configArray["imageFilters"] ?? [],
);
$channelService = new ChannelService($schemaService);
$channelService->channel = $channel;
return $channelService;
}
/**
* @param string $name
* @return Option<Schema>
*/
public function getSchema(string $name): Option
{
$schema = $this->channel->schemas->firstWhere("name", $name);
if (empty($schema)) {
return none();
}
return some($schema);
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace Lucent\Channel;
use Lucent\Validator\Validator;
class PreviewTarget
{
function __construct(
public readonly string $label,
public readonly string $url,
)
{
Validator::single("label", $label, "required|min:2|max:50");
Validator::single("url", $url, "required|url");
}
public static function fromArray(array $data): PreviewTarget
{
return new PreviewTarget(
label: $data["label"],
url: $data["url"],
);
}
}
+52
View File
@@ -0,0 +1,52 @@
<?php
namespace Lucent\Commands;
use DirectoryIterator;
use Illuminate\Console\Command;
class CompileConfig extends Command
{
protected $signature = 'lucent:compile:config {path}';
protected $description = 'Compiles Config';
public function __construct()
{
parent::__construct();
}
public function handle()
{
$configDir = base_path($this->argument('path'));
$configJson = file_get_contents($configDir . "lucent.json");
$config = json_decode($configJson, true);
$schemasDirIterator = new DirectoryIterator($configDir . "Schemas");
$schemas = [];
foreach ($schemasDirIterator as $file) {
if ($file->getExtension() !== "json") {
continue;
}
$schemaJson = file_get_contents($configDir . "Schemas/" . $file->getFilename());
$schema = json_decode($schemaJson, true);
if (empty($schema)) {
$this->error("Invalid JSON " . $file->getFilename());
return 0;
}
$schemas[] = $schema;
}
$config["schemas"] = collect($schemas)->sortBy("label")->values()->toArray();
$configOuputJson = json_encode($config);
file_put_contents(storage_path("lucent.config.json"), $configOuputJson);
$this->info("Lucent JSON update");
}
}
+72
View File
@@ -0,0 +1,72 @@
<?php
namespace Lucent\Commands;
use DirectoryIterator;
use Exception;
use Illuminate\Console\Command;
use Intervention\Image\ImageManager;
use Lucent\Channel\ChannelService;
use Lucent\Schema\Schema;
use Lucent\Schema\Type;
class RebuildThumbnails extends Command
{
protected $signature = 'lucent:rebuild:thumbnails';
protected $description = 'Rebuilds thumbnails for path';
public function __construct(public ImageManager $imageManager)
{
parent::__construct();
}
public function handle(ChannelService $channelService): int
{
$channelService->channel->schemas
->where("type", Type::FILES)->values()
->map([$this, 'rebuildThumbnails']);
return 0;
}
public function rebuildThumbnails(Schema $schema): void
{
$filesDir = storage_path("app/public/" . $schema->path . "/");
$thumbDir = storage_path("app/public/thumbs/" . $schema->path . "/");
if (!file_exists($thumbDir)) {
mkdir($thumbDir);
}
$filesDirIterator = new DirectoryIterator($filesDir);
foreach ($filesDirIterator as $file) {
if ($file->isDot()) {
continue;
}
try {
$image = $this->imageManager->make($filesDir . $file->getFilename());
} catch (Exception $e) {
$this->error($e->getMessage());
continue;
}
$image->fit(300, 300);
try {
$image->encode('webp', 75)->save($thumbDir . $file->getFilename());
} catch (Exception $e) {
$this->error($e->getMessage());
continue;
}
$this->info($file->getFilename());
}
}
}
+11
View File
@@ -0,0 +1,11 @@
<?php
return [
"view" => [
"image_server" => env("LUCENT_IMAGE_SERVER")
],
"read_key" => env("LUCENT_READ_KEY"),
"write_key" => env("LUCENT_WRITE_KEY"),
"developer_key" => env("LUCENT_DEVELOPER_KEY")
];
+61
View File
@@ -0,0 +1,61 @@
<?php
namespace Lucent\Edge;
use Lucent\LucentException;
use Lucent\Validator\Validator as LucentValidator;
final class Edge
{
/**
* @throws LucentException
*/
public function __construct(
public string $source,
public string $target,
public string $sourceSchema,
public string $targetSchema,
public string $field,
public string $rank = "a",
public int $depth = 0,
)
{
LucentValidator::single("source", $source, "required|uuid");
LucentValidator::single("target", $target, "required|uuid");
}
public function equal(Edge $edge): bool
{
return $this->targetSchema === $edge->targetSchema && $this->field === $edge->field && $this->target === $edge->target && $this->source === $edge->source;
}
public function toArray(): array
{
return json_decode(json_encode($this), true);
}
public function toDB(): array
{
$data = $this->toArray();
unset($data["depth"]);
return $data;
}
public static function fromArray(array $data): Edge
{
return new Edge(
source: data_get($data, 'source'),
target: data_get($data, 'target'),
sourceSchema: data_get($data, 'sourceSchema'),
targetSchema: data_get($data, 'targetSchema'),
field: data_get($data, 'field'),
rank: data_get($data, 'rank'),
depth: data_get($data, 'depth', 0),
);
}
}
+34
View File
@@ -0,0 +1,34 @@
<?php
namespace Lucent\Edge;
use Illuminate\Support\Collection;
/**
* @extends \Illuminate\Support\Collection<int|string, Edge>
*/
final class EdgeCollection extends Collection
{
public function __construct(
Edge ...$array
) {
parent::__construct($array);
}
/**
* @return Edge[]
**/
public function toArray(): array
{
return collect($this)->values()->toArray();
}
public static function fromArray(array $data): EdgeCollection
{
$edges = array_map([Edge::class, 'fromArray'], $data);
return new EdgeCollection(...$edges);
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php namespace Lucent\Edge;
use Illuminate\Support\Facades\DB;
use Lucent\LucentException;
use PDOException;
class EdgeRepo
{
public static function insert(Edge $edge): void
{
try {
DB::table("edges")->insert($edge->toDB());
} catch (PDOException $e) {
if ($e->getCode() == 23505) {
throw new LucentException("Edge already exists");
}
throw $e;
}
}
public static function update(string $from, EdgeCollection $edges): void
{
$edgesDB = collect($edges)->map(fn($e) => $e->toDB())->toArray();
DB::table("edges")->where("source", $from)->delete();
DB::table("edges")->insert($edgesDB);
}
}
+37
View File
@@ -0,0 +1,37 @@
<?php namespace Lucent\Edge;
use Lucent\LucentException;
class EdgeService
{
/**
* @throws LucentException
*/
public static function create(
string $source,
string $target,
string $sourceSchema,
string $targetSchema,
string $field,
string $rank,
): Edge
{
$edge = new Edge(
source: $source,
target: $target,
sourceSchema: $sourceSchema,
targetSchema: $targetSchema,
field: $field,
rank: $rank,
);
EdgeRepo::insert($edge);
return $edge;
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace Lucent\Edge;
final class QueryEdge
{
public function __construct(
public string $fromSchema,
public string $from,
public string $schema,
public string $field,
public string $to,
) {
}
}
+73
View File
@@ -0,0 +1,73 @@
<?php
namespace Lucent\Field;
use Lucent\LucentException;
class Field
{
/**
* @param string[] $collections
*/
function __construct(
public FieldName $name,
public string $label,
public string $ui,
public string $help = "",
public bool $trashed = false,
public bool $locked = false,
public string $regex = "",
public string $mime = "",
public bool $readonly = false,
public bool $nullable = false,
public bool $required = false,
public int $decimals = 0,
public array $collections = [],
public mixed $min = null,
public mixed $max = null,
public mixed $default = null,
public string $optionsFrom = "",
public string $optionsField = "",
public bool $optionsSuggest = true,
public bool $unique = false, // not used
public string $layout = "",
) {
}
public function toArray(): array
{
return \json_decode(\json_encode($this), true);
}
/**
* @throws LucentException
*/
public static function fromArray(array $data): Field
{
return new Field(
name: new FieldName($data["name"]),
label:$data["label"],
ui: $data["ui"],
help:$data["help"] ?? "",
trashed:$data["trashed"] ?? false,
locked:$data["locked"] ?? false,
regex:$data["regex"] ?? "" ,
mime:$data["mime"] ?? "" ,
readonly:$data["readonly"] ?? false,
nullable:$data["nullable"] ?? false,
required:$data["required"] ??false,
decimals:$data["decimals"] ?? 0,
collections:$data["collections"] ?? [],
min:$data["min"] ?? null,
max:$data["max"] ?? null,
default:$data["default"] ?? null,
optionsFrom:$data["optionsFrom"] ?? "" ,
optionsField:$data["optionsField"] ?? "" ,
optionsSuggest:$data["optionsSuggest"] ?? true,
unique:$data["unique"] ?? false,
layout:$data["layout"] ?? "" ,
);
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace Lucent\Field;
use Illuminate\Support\Collection;
/**
* @extends \Illuminate\Support\Collection<int|string, Field>
*/
final class FieldCollection extends Collection
{
public function __construct(
Field ...$array
) {
parent::__construct($array);
}
/**
* @return Schema[]
**/
public function toArray(): array
{
return collect($this)->values()->toArray();
}
public function findByName(string $name): ?Field
{
return $this->firstWhere("name", $name);
}
public static function fromArray(array $data): FieldCollection
{
$items = array_map([Field::class, 'fromArray'], $data);
return new FieldCollection(...$items);
}
public static function fromDB(string $data): FieldCollection
{
return FieldCollection::fromArray(\json_decode($data,true));
}
}
+167
View File
@@ -0,0 +1,167 @@
<?php
namespace Lucent\Field;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Lucent\Channel\ChannelRepo;
use Lucent\LucentException;
use Lucent\Schema\Type as SchemaType;
use function Lucent\Response\fail;
use function Lucent\Response\ok;
use function Lucent\Svelte\svelte;
class FieldController extends Controller
{
public function __construct(
private readonly FieldService $fieldService
)
{
}
public function new(Request $request)
{
$channel = ChannelRepo::current();
$schemaName = $request->route("schemaName");
$collections = collect($channel->schemas)->where("type", SchemaType::COLLECTION)->values()->toArray();
$filesSchemas = collect($channel->schemas)->where("type", SchemaType::FILES)->values()->toArray();
$newField = new Field(
name: new FieldName(""),
label: "",
ui: "text"
);
return svelte(
layout: "channel",
view: "fieldNew",
title: "New field",
data: [
"schemas" => $channel->schemas,
"schema" => $channel->schemas->where("name", $schemaName)->first(),
"collections" => $collections,
"filesSchemas" => $filesSchemas,
"field" => $newField->toArray(),
"uiList" => UI::values(),
]
);
}
public function create(Request $request)
{
try {
$this->fieldService->create(
schemaName: $request->route("schemaName"),
data: $request->input("data")
);
} catch (LucentException $th) {
return fail($th);
}
return ok();
}
public function edit(Request $request)
{
$channel = ChannelRepo::current();
$schemaName = $request->route("schemaName");
$schema = $channel->schemas->where("name.value", $schemaName)->first();
$collections = collect($channel->schemas)->where("type", SchemaType::COLLECTION)->values()->toArray();
$filesSchemas = collect($channel->schemas)->where("type", SchemaType::FILES)->values()->toArray();
$field = collect($schema->fields)->where("name.value", $request->route("fid"))->first();
return svelte(
layout: "channel",
view: "fieldEdit",
title: "Edit field",
data: [
"schemas" => $channel->schemas,
"schema" => $schema,
"collections" => $collections,
"filesSchemas" => $filesSchemas,
"field" => $field,
"uiList" => UI::values(),
]
);
}
public function replace(Request $request)
{
$schema = $this->fieldService->replace(
schemaName: $request->route("schemaName"),
// fields:$request->input("fields")
fields: json_decode($request->input("fields"), true)
);
return ok($schema->toArray());
}
public function update(Request $request)
{
try {
$this->fieldService->update(
schemaName: $request->route("schemaName"),
data: $request->input("data")
);
} catch (LucentException $th) {
return fail($th);
}
return ok();
}
public function move(Request $request)
{
$newSchema = $this->fieldService->move(
schemaName: $request->route("schemaName"),
source: $request->input("source"),
target: $request->input("target") ?? "",
);
return ok($newSchema->toArray());
}
public function trash(Request $request)
{
try {
$newSchema = $this->fieldService->trash(
schemaName: $request->route("schemaName"),
fieldName: $request->input("field"),
);
} catch (LucentException $th) {
return fail($th);
}
return ok($newSchema->toArray());
}
public function restore(Request $request)
{
try {
$newSchema = $this->fieldService->restore(
schemaName: $request->route("schemaName"),
fieldName: $request->input("field"),
);
} catch (LucentException $th) {
return fail($th);
}
return ok($newSchema->toArray());
}
public function delete(Request $request)
{
try {
$newSchema = $this->fieldService->delete(
schemaName: $request->route("schemaName"),
fieldName: $request->input("field"),
);
} catch (LucentException $th) {
return fail($th);
}
return ok($newSchema->toArray());
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace Lucent\Field;
use JsonSerializable;
use Lucent\LucentException;
use Lucent\Validator\Validator;
class FieldName implements JsonSerializable
{
public string $value;
/**
* @throws LucentException
*/
function __construct(string $value)
{
Validator::single("Name", $value, "min:2|max:50|alpha_dash");
$this->value = $value;
}
public function value(): string
{
return $this->value;
}
public function equals(FieldName $name): bool
{
return $this->value === $name->value;
}
public function __toString(): string
{
return $this->value;
}
public function jsonSerialize(): string
{
return $this->value;
}
}
+222
View File
@@ -0,0 +1,222 @@
<?php
namespace Lucent\Field;
use Lucent\Channel\ChannelRepo;
use Lucent\LucentException;
use Lucent\Primitive\Collection;
use Lucent\Schema\Schema;
use Lucent\Schema\SchemaRepo;
readonly class FieldService
{
public function __construct(
private SchemaRepo $schemaRepo
)
{
}
/**
* @throws LucentException
*/
public function create(
string $schemaName,
array $data
): Schema
{
if (empty($data["name"]) || empty($data["label"])) {
throw new LucentException("Name and Label are required");
}
$channel = ChannelRepo::current();
$schema = $channel->schemas->where("name.value", $schemaName)->first();
$field = Field::fromArray($data);
$this->validateNameUnique($schema, $field->name);
$schema->fields->push($field);
$this->schemaRepo->update($schema);
return $schema;
}
/**
* @throws LucentException
*/
public function update(
string $schemaName,
array $data
): Schema
{
if (empty($data["name"]) || empty($data["label"])) {
throw new LucentException("Name and Label are required");
}
$channel = ChannelRepo::current();
$schema = $channel->schemas->where("name", $schemaName)->first();
$field = Field::fromArray($data);
if ($field->locked) {
throw new LucentException("Locked fields can't get updated");
}
$schema->fields = $schema->fields->map(function (Field $aField) use ($field) {
if (!$aField->name->equals($field->name)) {
return $aField;
}
return $field;
});
$this->schemaRepo->update($schema);
return $schema;
}
public function move(
string $schemaName,
string $source,
string $target,
): Schema
{
$channel = ChannelRepo::current();
$schema = $channel->schemas->where("name", $schemaName)->first();
if ($source === $target) {
return $schema;
}
$sourceField = $schema->fields->where("name", $source)->first();
$fieldsWithoutSource = $schema->fields
->where("name", "!=", $source)
->values()
->toArray();
if (empty($target)) {
$schema->fields = new Collection([$sourceField, ...$fieldsWithoutSource]);
} else {
$newFields = collect($fieldsWithoutSource)->reduce(function ($carry, $afield) use ($sourceField, $target) {
$carry[] = $afield;
if ($afield->name->value === $target) {
$carry[] = $sourceField;
}
return $carry;
}, []);
$schema->fields = new Collection($newFields);
}
$this->schemaRepo->update($schema);
return $schema;
}
/**
* @throws LucentException
*/
public function trash(
string $schemaName,
string $fieldName,
): Schema
{
$channel = ChannelRepo::current();
$schema = $channel->schemas->where("name.value", $schemaName)->first();
$field = $schema->fields->where("name", $fieldName)->first();
if ($field->trashed) {
throw new LucentException("Field is already in trash");
}
if ($field->locked) {
throw new LucentException("Locked fields can't get trashed");
}
$schema->fields = $schema->fields->map(function (Field $aField) use ($fieldName) {
if ($aField->name->value !== $fieldName) {
return $aField;
}
$aField->trashed = true;
return $aField;
});
$this->schemaRepo->update($schema);
return $schema;
}
/**
* @throws LucentException
*/
public function restore(
string $schemaName,
string $fieldName,
): Schema
{
$channel = ChannelRepo::current();
$schema = $channel->schemas->where("name.value", $schemaName)->first();
$field = $schema->fields->where("name", $fieldName)->first();
if (!$field->trashed) {
throw new LucentException("You can only restore trashed fields");
}
$schema->fields = $schema->fields->map(function (Field $aField) use ($fieldName) {
if ($aField->name->value !== $fieldName) {
return $aField;
}
$aField->trashed = false;
return $aField;
});
$this->schemaRepo->update($schema);
return $schema;
}
/**
* @throws LucentException
*/
public function delete(
string $schemaName,
string $fieldName,
): Schema
{
$channel = ChannelRepo::current();
$schema = $channel->schemas->where("name.value", $schemaName)->first();
$field = $schema->fields->where("name", $fieldName)->first();
if (!$field->trashed) {
throw new LucentException("You can only delete trashed fields");
}
$schema->fields = $schema->fields
->filter(fn(Field $aField) => $aField->name->value !== $fieldName)
->values();
$this->schemaRepo->update($schema);
return $schema;
}
public function replace(
string $schemaName,
array $fields
): Schema
{
$channel = ChannelRepo::current();
$schema = $channel->schemas->where("name", $schemaName)->first();
$schema->fields = new Collection();
$newFields = collect($fields)->map(function ($fieldData) use ($schema) {
$field = Field::fromArray($fieldData);
$this->validateNameUnique($schema, $field->name);
return $field;
})->toArray();
$schema->fields = new Collection($newFields);
$this->schemaRepo->update($schema);
return $schema;
}
/**
* @throws LucentException
*/
private function validateNameUnique(Schema $schema, string $name): void
{
$fieldExists = collect($schema->fields)->where("name.value", $name)->first();
if (!empty($fieldExists)) {
throw new LucentException("Field with name $name exists in schema $schema->label");
}
}
}
+99
View File
@@ -0,0 +1,99 @@
<?php
namespace Lucent\Field;
readonly class System
{
function __construct(
public string $name,
public string $label,
public string $ui,
public bool $files,
)
{
}
public static function getById(string $id): System
{
return self::list()[$id];
}
/**
* @return array<string,System>
* */
public static function list(): array
{
return [
"_sys.status" => new System(
name: "_sys.status",
label: "Status",
ui: "status",
files: false,
),
"_sys.createdBy" => new System(
name: "_sys.createdBy",
label: "Created by",
ui: "user",
files: false,
),
"_sys.updatedBy" => new System(
name: "_sys.updatedBy",
label: "Updated by",
ui: "user",
files: false,
),
"_sys.createdAt" => new System(
name: "_sys.createdAt",
label: "Created at",
ui: "datetime",
files: false,
),
"_sys.updatedAt" => new System(
name: "_sys.updatedAt",
label: "Updated At",
ui: "datetime",
files: false,
),
"_file.path" => new System(
name: "_file.path",
label: "Filename",
ui: "text",
files: true,
),
"_file.mime" => new System(
name: "_file.mime",
label: "Mime Type",
ui: "text",
files: true,
),
"_file.width" => new System(
name: "_file.width",
label: "Dimensions",
ui: "text",
files: true,
),
"_file.size" => new System(
name: "_file.size",
label: "Size",
ui: "text",
files: true,
),
"_file.originalName" => new System(
name: "_file.originalName",
label: "Original Name",
ui: "text",
files: true,
),
"_file.checksum" => new System(
name: "_file.checksum",
label: "Checksum",
ui: "text",
files: true,
),
];
}
}
+298
View File
@@ -0,0 +1,298 @@
<?php
namespace Lucent\Field;
use Lucent\Schema\FieldType;
readonly class UI
{
function __construct(
public string $name,
public string $label,
public FieldType $type,
public bool $regex,
public bool $mime,
public bool $required,
public bool $nullable,
public bool $decimals,
public bool $collections,
public bool $min,
public bool $max,
public bool $default,
public bool $locked,
public bool $readonly,
public bool $options,
public bool $layout,
)
{
}
public static function getById(string $id): array
{
return self::buildUis()[$id];
}
public static function values(): array
{
return array_values(self::buildUis());
}
public static function buildUis(): array
{
return [
"uuid" => new UI(
name: "uuid",
label: "UUID",
type: FieldType::STRING,
regex: false,
mime: false,
required: true,
nullable: true,
decimals: false,
collections: false,
min: false,
max: false,
default: false,
locked: true,
readonly: true,
options: false,
layout: false,
),
"text" => new UI(
name: "text",
label: "Text",
type: FieldType::STRING,
regex: true,
mime: false,
required: true,
nullable: true,
decimals: false,
collections: false,
min: true,
max: true,
default: true,
locked: true,
readonly: true,
options: true,
layout: false,
),
"textarea" => new UI(
name: "textarea",
label: "Textarea",
type: FieldType::STRING,
regex: false,
mime: false,
required: true,
nullable: true,
decimals: false,
collections: false,
min: true,
max: true,
default: true,
locked: true,
readonly: true,
options: false,
layout: false,
),
"color" => new UI(
name: "color",
label: "Color",
type: FieldType::STRING,
regex: false,
mime: false,
required: true,
nullable: true,
decimals: false,
collections: false,
min: false,
max: false,
default: true,
locked: true,
readonly: true,
options: true,
layout: false,
),
"rich" => new UI(
name: "rich",
label: "Rich editor",
type: FieldType::STRING,
regex: false,
mime: false,
required: true,
nullable: true,
decimals: false,
collections: false,
min: true,
max: true,
default: true,
locked: true,
readonly: true,
options: false,
layout: false,
),
"block" => new UI(
name: "block",
label: "Block editor",
type: FieldType::JSON,
regex: false,
mime: false,
required: true,
nullable: true,
decimals: false,
collections: true,
min: true,
max: true,
default: true,
locked: true,
readonly: true,
options: false,
layout: false,
),
"file" => new UI(
name: "file",
label: "File",
type: FieldType::FILE,
regex: false,
mime: true,
required: false,
nullable: false,
decimals: false,
collections: true,
min: true,
max: true,
default: false,
locked: false,
readonly: false, // feature for later
options: false,
layout: false,
),
"reference" => new UI(
name: "reference",
label: "Reference",
type: FieldType::REFERENCE,
regex: false,
mime: false,
required: false,
nullable: false,
decimals: false,
collections: true,
min: true,
max: true,
default: false,
locked: false,
readonly: false, // feature for later
options: false,
layout: true,
),
"checkbox" => new UI(
name: "checkbox",
label: "Checkbox",
type: FieldType::BOOLEAN,
regex: false,
mime: false,
required: true,
nullable: true,
decimals: false,
collections: false,
min: false,
max: false,
default: true,
locked: true,
readonly: true,
options: false,
layout: false,
),
"number" => new UI(
name: "number",
label: "Number",
type: FieldType::NUMBER,
regex: false,
mime: false,
required: true,
nullable: true,
decimals: true,
collections: false,
min: true,
max: true,
default: true,
locked: true,
readonly: true,
options: true,
layout: false,
),
"date" => new UI(
name: "date",
label: "Date",
type: FieldType::STRING,
regex: false,
mime: false,
required: true,
nullable: true,
decimals: false,
collections: false,
min: true,
max: true,
default: true,
locked: true,
readonly: true,
options: true,
layout: false,
),
"datetime" => new UI(
name: "datetime",
label: "Datetime",
type: FieldType::STRING,
regex: false,
mime: false,
required: true,
nullable: true,
decimals: false,
collections: false,
min: true,
max: true,
default: true,
locked: true,
readonly: true,
options: true,
layout: false,
),
"json" => new UI(
name: "json",
label: "JSON",
type: FieldType::JSON,
regex: false,
mime: false,
required: true,
nullable: true,
decimals: false,
collections: false,
min: false,
max: false,
default: true,
locked: true,
readonly: true,
options: false,
layout: false,
),
"tab" => new UI(
name: "tab",
label: "Tab",
type: FieldType::TAB,
regex: false,
mime: false,
required: false,
nullable: false,
decimals: false,
collections: false,
min: false,
max: false,
default: false,
locked: true,
readonly: false,
options: false,
layout: false,
),
];
}
}
+61
View File
@@ -0,0 +1,61 @@
<?php
namespace Lucent\File;
use Illuminate\Http\UploadedFile;
use Lucent\LucentException;
use Lucent\Record\File;
use Lucent\Schema\Schema;
use Lucent\Schema\Type;
class FileService
{
public function __construct()
{
}
/**
* @throws LucentException
*/
public static function create(Schema $schema, string $uploadFromUrl, array $file): FileUploadResult
{
$emptyUploadUrl = empty($uploadFromUrl);
$emptyFileData = empty($file);
if ($schema->type === Type::FILES && $emptyUploadUrl && $emptyFileData) {
throw new LucentException("No file data submitted");
} elseif ($schema->type !== Type::FILES && !($emptyUploadUrl && $emptyFileData)) {
throw new LucentException("You can't upload a file to a regular record");
} elseif ($schema->type !== Type::FILES) {
return new FileUploadResult(
recordFile: null, duplicateId: "", isDuplicate: false
);
}
if (!$emptyUploadUrl) {
$file = self::uploadFileFromUrl($uploadFromUrl);
return uploadFile($schema, $file);
}
return new FileUploadResult(
recordFile: File::fromArray($file), duplicateId: "", isDuplicate: false
);
}
/**
* @throws LucentException
*/
private static function uploadFileFromUrl(string $url): UploadedFile
{
$pathinfo = pathinfo($url);
$contents = file_get_contents($url);
if ($contents === false) {
throw new LucentException("Failed to upload file from url");
}
$file = '/tmp/' . $pathinfo['basename'];
file_put_contents($file, $contents);
return new UploadedFile($file, $pathinfo['basename']);
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace Lucent\File;
use Lucent\Record\File;
class FileUploadResult
{
public function __construct(
public ?File $recordFile,
public string $duplicateId,
public bool $isDuplicate,
)
{
}
}
+88
View File
@@ -0,0 +1,88 @@
<?php
namespace Lucent\File;
use Exception;
use Illuminate\Log\Logger;
use Intervention\Image\ImageManager;
use Lucent\Channel\ChannelService;
use Lucent\Record\QueryRecord;
class ImageService
{
private string $notFoundImage = "/not-found.jpg";
public function __construct(
public ImageManager $imageManager,
public ChannelService $channelService,
public Logger $logger
)
{
}
public function file(?QueryRecord $record, string $template = ""): string
{
if (empty($record)) {
return $this->notFoundImage;
}
$originalPath = $record->_file->path;
$templateUri = $this->findTemplate($originalPath, $template);
if ($templateUri === false) {
$templateUri = $this->createTemplate($originalPath, $template);
}
return $this->channelService->channel->filesUrl . "/" . $templateUri;
}
private function findTemplate(string $originalPath, string $template): string|false
{
$templateUri = "templates/" . $template . "/" . $originalPath;
$templateFilePath = public_path("storage/" . $templateUri);
if (file_exists($templateFilePath)) {
return $templateUri;
}
return false;
}
private function createTemplate(string $originalPath, string $template): string
{
$originalFilePath = public_path("storage/" . $originalPath);
$templateUri = "/templates/" . $template . "/" . $originalPath;
$templateFilePath = public_path("storage/" . $templateUri);
if (!file_exists($originalFilePath)) {
return $this->notFoundImage;
}
if (!file_exists(pathinfo($templateFilePath, PATHINFO_DIRNAME))) {
$this->make_dir(pathinfo($templateFilePath, PATHINFO_DIRNAME));
}
try {
$image = $this->imageManager->make($originalFilePath);
} catch (Exception $e) {
$this->logger->error($e->getMessage());
return $this->notFoundImage;
}
$image->filter(new $this->channelService->channel->imageFilters[$template]);
try {
$image->encode('webp', 75);
} catch (Exception $e) {
$this->logger->error($e->getMessage());
return $this->notFoundImage;
}
$image->save($templateFilePath);
return $templateUri;
}
private function make_dir(string $path): void
{
is_dir($path) || mkdir($path, 0777, true);
}
}
+133
View File
@@ -0,0 +1,133 @@
<?php
namespace Lucent\File;
use Exception;
use Illuminate\Contracts\Filesystem\Filesystem;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Intervention\Image\ImageManagerStatic;
use Lucent\LucentException;
use Lucent\Record\File as RecordFile;
use Lucent\Schema\Schema;
use Spatie\ImageOptimizer\OptimizerChainFactory;
/**
* @throws LucentException
*/
function uploadFile(Schema $schema, UploadedFile $file): FileUploadResult
{
$originalName = pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME);
$extension = pathinfo($file->getClientOriginalName(), PATHINFO_EXTENSION);
$originalFilename = $file->getClientOriginalName();
$filename = createFileName($originalName, $extension);
$mimetype = $file->getMimeType();
$optimizerChain = OptimizerChainFactory::create();
$optimizerChain->setTimeout(10)->optimize($file->getPathName());
$checksum = sha1_file($file);
$recordId = checkDuplicate($schema->name, $checksum, $file->getSize());
if (!empty($recordId)) {
return new FileUploadResult(
recordFile: null,
duplicateId: $recordId,
isDuplicate: true
);
}
$disk = loadDisk();
$path = $schema->path . "/" . $filename;
$res = $disk->put(
$path,
file_get_contents($file),
// 'public' // now managed by aws policy
);
if ($res === false) {
throw new LucentException("File $filename not uploaded");
}
createThumbnail($disk, $schema->path, $filename, $file);
list($width, $height) = isImage($mimetype) ? getimagesize($file) : [0, 0];
$recordFile = new RecordFile(
originalName: $originalFilename,
mime: $mimetype,
path: $path,
size: $file->getSize(),
width: $width,
height: $height,
checksum: $checksum
);
return new FileUploadResult(
recordFile: $recordFile,
duplicateId: "",
isDuplicate: false
);
}
function createFileName(string $originalName, string $extension): string
{
return Str::slug($originalName, '-') . '-' . uniqid() . '.' . $extension;
}
function isImage(string $mimetype): bool
{
$imageMimes = ['image/webp', 'image/gif', 'image/jpeg', 'image/png', 'image/tiff'];
return in_array($mimetype, $imageMimes);
}
function loadDisk(): Filesystem
{
return Storage::build([
'driver' => 'local',
// 'key' => config("filesystems.disks.s3.key"),
// 'secret' => config("filesystems.disks.s3.secret"),
// 'region' => config("filesystems.disks.s3.region"),
// 'bucket' => config("filesystems.disks.s3.bucket"),
// // 'url' => $schema->objectStorageUrl,
// 'endpoint' => $schema->objectStorageEndpoint,
'use_path_style_endpoint' => false,
'visibility' => 'public', // now managed by aws policy
'root' => storage_path('app/public'),
'throw' => true,
]);
}
function checkDuplicate(string $schemaName, string $checksum, int $filesize): string
{
$record = DB::table("records")
->where("_sys->schema", $schemaName)
->where("_file->checksum", $checksum)
->where("_file->size", $filesize)
->first();
return $record->id ?? "";
}
function createThumbnail(Filesystem $disk, string $schemaPath, string $filename, UploadedFile $file): void
{
try {
$image = ImageManagerStatic::make($file);
} catch (Exception $e) {
return;
}
$image->fit(300, 300);
try {
$image->encode('webp', 75);
$disk->put(
"thumbs/" . $schemaPath . "/" . $filename,
$image,
// 'public' // now managed by aws policy
);
} catch (Exception $e) {
}
}
+50
View File
@@ -0,0 +1,50 @@
<?php
namespace Lucent\Http\Controller;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Lucent\Account\Auth;
use function Lucent\Response\fail;
use function Lucent\Response\ok;
use function Lucent\Svelte\svelte;
class AccountController extends Controller
{
public function profile()
{
return svelte(
layout: "account",
view: "profile",
title: "Profile",
data: []
);
}
public function updateName(Request $request)
{
try {
(new Auth)->updateName(session("user.id"), $request->input("name"));
} catch (\Throwable $th) {
return fail($th);
}
return ok();
}
public function updateEmail(Request $request)
{
try {
(new Auth)->updateEmail(session("user.id"), $request->input("email"));
} catch (\Throwable $th) {
return fail($th);
}
return ok();
}
}
@@ -0,0 +1,51 @@
<?php
namespace Lucent\Http\Controller\Api;
use App\Http\Controllers\Controller;
use Illuminate\Contracts\View\View;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Lucent\Account\AuthService;
use Lucent\LucentException;
use function Lucent\Response\fail;
use function Lucent\Response\ok;
use function Lucent\Svelte\svelte;
class AccountController extends Controller
{
public function __construct(
private readonly AuthService $authService,
)
{
}
public function profile(): View
{
return svelte(
layout: "account",
view: "profile",
title: "Profile",
data: []
);
}
public function updateName(Request $request): Response
{
try {
$this->authService->updateName(session("user.id"), $request->input("name"));
} catch (LucentException $th) {
return fail($th);
}
$request->session()->put("user.name", $request->input("name"));
return ok();
}
}
@@ -0,0 +1,37 @@
<?php
namespace Lucent\Http\Controller\Api;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Lucent\Edge\EdgeService;
use Lucent\LucentException;
use function Lucent\Response\fail;
use function Lucent\Response\ok;
class EdgeController extends Controller
{
public function create(Request $request): Response
{
try {
$edge = EdgeService::create(
source: $request->input("source"),
target: $request->input("target"),
sourceSchema: $request->input("sourceSchema"),
targetSchema: $request->input("targetSchema"),
field: $request->input("field"),
rank: $request->input("rank") ?? "",
);
} catch (LucentException $th) {
return fail($th);
}
return ok([
"edge" => $edge,
]);
}
}
@@ -0,0 +1,39 @@
<?php
namespace Lucent\Http\Controller\Api;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Lucent\Channel\ChannelContext;
use Lucent\Schema\FieldRepo;
use Lucent\Schema\SchemaRepo;
use function Lucent\Response\fail;
use function Lucent\Response\ok;
class FieldController extends Controller
{
public function create(Request $request)
{
try {
$schema = SchemaRepo::findByName($request->input("schema"));
FieldRepo::create($schema, $request->input("field"));
} catch (\Throwable $th) {
return fail($th);
}
return ok();
}
public function update(Request $request)
{
try {
$schema = SchemaRepo::findByName($request->input("schema"));
FieldRepo::update($schema, $request->input("field"));
} catch (\Throwable $th) {
return fail($th);
}
return ok();
}
}
@@ -0,0 +1,62 @@
<?php
namespace Lucent\Http\Controller\Api;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
use Lucent\Account\AuthService;
use Lucent\Channel\ChannelRepo;
use Lucent\File\FileUploadResult;
use Lucent\Query\Query;
use Lucent\Record\RecordService;
use function Lucent\File\uploadFile;
use function Lucent\Response\fail;
use function Lucent\Response\ok;
class FileController extends Controller
{
public function __construct(
private readonly RecordService $recordService,
private readonly Query $query
)
{
}
public function upload(Request $request)
{
$validator = Validator::make(request()->all(), [
'files.*' => 'required|file|max:100000',
]);
if ($validator->fails()) {
return fail($validator->errors()->first());
}
$channel = ChannelRepo::current();
$schema = $channel->schemas->firstWhere("name", $request->input("schema"));
$files = request()->file('files');
$uploadResults = collect($files)->map(fn($file) => uploadFile($schema, $file))->toArray();
$insertedIds = collect($uploadResults)
->filter(fn(FileUploadResult $res) => !$res->isDuplicate)
->values()
->map(function (FileUploadResult $uploadResult) use ($schema, $request) {
return $this->recordService->create(
userId: AuthService::currentUserId($request),
schemaName: $schema->name,
data: [],
file: (array)$uploadResult->recordFile,
edges: [],
status: $request->input("status") ?? "published",
uploadFromUrl: ""
);
})->toArray();
return ok($insertedIds);
}
}
@@ -0,0 +1,107 @@
<?php
namespace Lucent\Http\Controller\Api;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Lucent\Channel\ChannelRepo;
use Lucent\LucentException;
use Lucent\Query\Query;
use Lucent\Record\RecordService;
use Lucent\Schema\Validator\ValidatorException;
use Throwable;
use function Lucent\Response\fail;
use function Lucent\Response\ok;
class RecordController extends Controller
{
public function __construct(
private readonly RecordService $recordService,
private readonly Query $query
)
{
}
public function records(Request $request)
{
$channel = ChannelRepo::current();
$urlParams = $request->all();
$sort = data_get($urlParams, "sort") ?? "-_sys.updatedAt";
$filter = data_get($urlParams, "filter") ?? [];
$arguments = array_merge([
], $filter);
$skip = data_get($urlParams, "skip") ?? 0;
$limit = data_get($urlParams, "limit") ?? 15;
$queryResult = $this->query
->filter($arguments)
->limit($limit)
->skip($skip)
->sort($sort)
->childrenDepth($request->input("childrenDepth") ?? 1)
->parentsDepth($request->input("parentsDepth") ?? 0)
->runWithCount();
$graph = $queryResult->getQueryRecords($channel->schemas);
$total = $queryResult->getTotal();
return ok([
"graph" => $graph->toArray(),
"sort" => $sort,
"limit" => $limit,
"skip" => $skip,
"total" => $total,
]);
}
public function create(Request $request)
{
try {
$recordId = $this->recordService->create(
userId: $request->input("userId"),
schemaName: $request->input("schema"),
data: $request->input("data") ?? [],
file: $request->input("file") ?? [],
edges: $request->input("edges") ?? [],
status: $request->input("status") ?? "draft",
uploadFromUrl: $request->input("uploadFromUrl") ?? ""
);
} catch (ValidatorException $th) {
return fail($th->getValidatorErrors());
} catch (LucentException $th) {
return fail($th);
}
return ok(["id" => $recordId]);
}
public function update(Request $request)
{
try {
$this->recordService->update(
userId: $request->input("userId"),
id: $request->route("id"),
data: $request->input("data"),
status: $request->input("status"),
edges: $request->input("edges") ?? [],
updateEdges: false,
);
} catch (ValidatorException $th) {
return fail($th->getValidatorErrors());
} catch (LucentException $th) {
return fail($th);
} catch (Throwable $th) {
if ($th->getCode() == 11000) {
return fail("ID has to be unique in the channel");
}
return fail($th);
}
return ok();
}
}
@@ -0,0 +1,81 @@
<?php
namespace Lucent\Http\Controller\Api;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Lucent\Schema\SchemaService;
use function Lucent\Response\fail;
use function Lucent\Response\ok;
class SchemaController extends Controller
{
public function __construct(
private readonly SchemaService $schemaService
)
{
}
// public function find(Request $request)
// {
// $cid = $request->header("CHANNEL-ID");
// $channelContext = ChannelContext::fromId($cid)->withSchemas();
// return ok($channelContext->getSchemas());
// }
// public function findOne(Request $request, string $name)
// {
// $cid = $request->header("CHANNEL-ID");
// $channelContext = ChannelContext::fromId($cid)->withSchemas();
// $schema = SchemaRepo::context($channelContext)->findByName($name);
// return ok($schema->toArray());
// }
public function create(Request $request)
{
try {
$schema = $this->schemaService->create(
name: $request->input("name"),
label: $request->input("label"),
type: $request->input("type"),
isEntry: $request->input("isEntry"),
revisionRetentionDays: $request->input("revisionRetentionDays"),
revisionRetentionNumber: $request->input("revisionRetentionNumber"),
trashedRetentionDays: $request->input("trashedRetentionDays"),
fields: $request->input("fields"),
titleTemplate: $request->input("titleTemplate") ?? "",
visible: $request->input("visible") ?? [],
path: $request->input("path") ?? "",
);
} catch (\Throwable $th) {
return fail($th);
}
return ok($schema->toArray());
}
// public function update(Request $request)
// {
// $cid = $request->header("CHANNEL-ID");
// try {
// $channelContext = ChannelContext::fromId($cid);
// SchemaRepo::context($channelContext)->update($request->all());
// } catch (\Throwable $th) {
// return fail($th);
// }
// $schema = SchemaRepo::context($channelContext)->findByName($request->input("name"));
// return ok($schema->toArray());
// }
public function delete(Request $request)
{
try {
$this->schemaService->delete($request->route("name"));
} catch (\Throwable $th) {
return fail($th);
}
return ok();
}
}
+126
View File
@@ -0,0 +1,126 @@
<?php
namespace Lucent\Http\Controller;
use App\Http\Controllers\Controller;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Lucent\Account\AuthService;
use Lucent\Account\UserRepo;
use Lucent\LucentException;
use Lucent\Svelte\Svelte;
use function Lucent\Response\fail;
use function Lucent\Response\ok;
class AuthController extends Controller
{
public function __construct(
private readonly AuthService $authService,
private readonly Svelte $svelte,
private readonly UserRepo $userRepo,
)
{
}
public function register(Request $request): View
{
$userCount = $this->userRepo->count();
$email = $request->input("email");
$token = $request->input("token");
return svelte(
layout: "account",
view: "register",
title: "Create an account",
data: [
'email' => $email,
'token' => $token,
'userCount' => $userCount
]
);
}
public function postRegister(Request $request): Response
{
try {
if ($request->input("isAdmin")) {
$this->authService->registerAdmin(
name: $request->input("name"),
password: $request->input("password"),
email: $request->input("email"),
);
} else {
$this->authService->register(
name: $request->input("name"),
password: $request->input("password"),
email: $request->input("email"),
token: $request->input("token") ?? "",
);
}
} catch (LucentException $th) {
return fail($th);
}
return ok();
}
public function login(): View
{
return $this->svelte->render(
layout: "account",
view: "login",
title: "Log in"
);
}
public function postLogin(Request $request): Response
{
try {
$this->authService->sendLoginEmail($request->input("email"));
} catch (LucentException $th) {
return fail($th);
}
return ok();
}
public function verify(Request $request): View
{
return $this->svelte->render(
layout: "account",
view: "verify",
title: "Verify and enter",
data: [
"email" => $request->input("email"),
"token" => $request->input("token"),
]
);
}
public function postVerify(Request $request): Response
{
try {
$this->authService->login($request->input("email"), $request->input("token"));
} catch (LucentException $th) {
return fail($th);
}
return ok();
}
public function logout(): RedirectResponse
{
session()->flush();
return redirect("/login");
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
namespace Lucent\Http\Controller;
use App\Http\Controllers\Controller;
use Lucent\Account\Auth;
use Lucent\Channel\ChannelContext;
use Lucent\Query\Options;
use Lucent\Query\Reference;
class EdgeController extends Controller
{
}
+138
View File
@@ -0,0 +1,138 @@
<?php
namespace Lucent\Http\Controller;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
use Intervention\Image\ImageManager;
use Lucent\Channel\ChannelRepo;
use Lucent\Channel\ChannelService;
use Lucent\File\FileUploadResult;
use Lucent\Query\Query;
use Lucent\Record\RecordService;
use function Lucent\File\loadDisk;
use function Lucent\File\uploadFile;
use function Lucent\Response\fail;
use function Lucent\Response\ok;
class FileController extends Controller
{
public function __construct(
private readonly ChannelService $channelService,
private readonly RecordService $recordService,
private readonly Query $query
)
{
}
public function download(Request $request)
{
$disk = loadDisk();
return $disk->download($request->input("path"));
}
public function get(Request $request)
{
$manager = new ImageManager(['driver' => 'imagick']);
$filesystem = loadDisk();
$path = $request->route("path");
if ($filesystem->exists($path)) {
$image = $manager->make($filesystem->get($path));
return $image->response();
}
$arr = explode(".", $path);
$ext = end($arr);
$pathWithoutExtension = str_replace("." . $ext, "", $path);
$pathArguments = (function ($path) {
$pathWithArgumentsAr = explode("-", $path);
return collect($pathWithArgumentsAr)
->filter(fn($ar) => str_contains($ar, "_"))
->reduce(function ($carry, $arg) {
[$k, $v] = explode("_", $arg);
$carry[$k] = $v;
return $carry;
});
})($pathWithoutExtension);
$originalPath = (function ($path) use ($ext) {
$arr = explode("-o-", $path);
return $arr[0] . "." . $ext;
})($path);
$image = $manager->make($filesystem->get($originalPath));
if (empty($pathArguments["mode"])) {
if (empty($pathArguments["w"])) {
$image->resize(null, $pathArguments["h"], function ($constraint) {
$constraint->aspectRatio();
});
} elseif (empty($pathArguments["h"])) {
$image->resize($pathArguments["w"], null, function ($constraint) {
$constraint->aspectRatio();
});
} else {
$image->resize($pathArguments["w"], $pathArguments["h"]);
}
} else if ($pathArguments["mode"] === "fit") {
$image->fit($pathArguments["w"], $pathArguments["h"]);
}
$disk = loadDisk();
// $disk->put("cache/" . $path, $image);
$image->save(storage_path("app/public/cache/" . $path));
return $image->response();
}
public function upload(Request $request)
{
$validator = Validator::make(request()->all(), [
'files.*' => 'required|file|max:100000',
]);
if ($validator->fails()) {
return fail($validator->errors()->first());
}
$schema = $this->channelService->channel->schemas->firstWhere("name", $request->input("schema"));
$files = request()->file('files');
$uploadResults = collect($files)->map(fn($file) => uploadFile($schema, $file))->toArray();
collect($uploadResults)
->filter(fn(FileUploadResult $res) => !$res->isDuplicate)
->values()
->map(function (FileUploadResult $uploadResult) use ($schema) {
return $this->recordService->create(
schemaName: $schema->name,
data: [],
file: (array)$uploadResult->recordFile,
edges: [],
status: "published",
uploadFromUrl: ""
);
})->toArray();
$queryResult = $this->query
->filter([
"_sys.schema" => $schema->name
])
->limit(15)
->skip(0)
->sort("-_sys.updatedAt")
->childrenDepth(0)
->parentsDepth(0)
->run();
$graph = $queryResult->getQueryRecords();
return ok($graph->records->toArray());
}
}
+50
View File
@@ -0,0 +1,50 @@
<?php
namespace Lucent\Http\Controller;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Lucent\Schema\FolderRepo;
use function Lucent\Response\fail;
use function Lucent\Response\ok;
class FolderController extends Controller
{
public function create(Request $request)
{
try {
$folder = FolderRepo::create($request->input("name"));
} catch (\Lucent\LucentException $th) {
return fail($th);
}
return ok((array)$folder);
}
public function update(Request $request, string $cid)
{
try {
$folder = FolderRepo::update(
id: $request->input("id"),
name: $request->input("name"),
);
} catch (\Lucent\LucentException $th) {
return fail($th);
}
return ok((array)$folder);
}
public function delete(Request $request, string $cid, string $folderid)
{
try {
FolderRepo::delete($folderid);
} catch (\Lucent\LucentException $th) {
return fail($th);
}
return redirect()->back();
}
}
+68
View File
@@ -0,0 +1,68 @@
<?php
namespace Lucent\Http\Controller;
use App\Http\Controllers\Controller;
use Illuminate\Contracts\View\View;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Lucent\Account\UserRepo;
use Lucent\Channel\ChannelService;
use Lucent\Query\Query;
use Lucent\Svelte\Svelte;
use function Lucent\Response\ok;
class HomeController extends Controller
{
public function __construct(
private readonly ChannelService $channelService,
private readonly Svelte $svelte,
private readonly UserRepo $userRepo,
private readonly Query $query,
)
{
}
public function home(): View
{
return $this->svelte->render(
layout: "channel",
view: "homeIndex",
title: "Records",
);
}
public function records(Request $request): Response
{
$urlParams = $request->all();
$sort = data_get($urlParams, "sort") ?? "-_sys.updatedAt";
$filter = data_get($urlParams, "filter") ?? [];
$arguments = array_merge([
"_sys.status_in" => ["draft", "published"]
], $filter);
$limit = 30;
$queryResult = $this->query
->filter($arguments)
->limit($limit)
->childrenDepth(1)
->parentsDepth(0)
->sort($sort)
->run();
$graph = $queryResult->getQueryRecords();
$users = $this->userRepo->all();
return ok([
"users" => $users,
"records" => $graph->getRootRecords()->toArray(),
"graph" => $graph->toArray(),
"modalUrl" => $request->fullUrl(),
]);
}
}
+70
View File
@@ -0,0 +1,70 @@
<?php
namespace Lucent\Http\Controller;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Lucent\Account\AccountService;
use Lucent\Account\AuthService;
use Lucent\Account\Role;
use Lucent\LucentException;
use Lucent\Svelte\Svelte;
use function Lucent\Response\fail;
class MemberController extends Controller
{
public function __construct(
private readonly AuthService $authService,
private readonly AccountService $accountService,
private readonly Svelte $svelte,
)
{
}
public function index()
{
return $this->svelte->render(
layout: "channel",
view: "members",
title: "Members",
data: [
"users" => $this->accountService->allProfiles()->toArray(),
"roles" => Role::cases()
]
);
}
public function invite(Request $request)
{
if (empty($request->input("role"))) {
return fail("Select a role for the user");
}
try {
$user = $this->authService->invite($request->input("name"), $request->input("email"), $request->input("role"));
} catch (LucentException $th) {
return fail($th);
}
return [
"user" => $user
];
}
public function update(Request $request)
{
try {
$this->authService->changeRole($request->input("id"), $request->input("role"));
} catch (LucentException $th) {
return fail($th);
}
return [
"users" => $this->accountService->allProfiles()->toArray(),
];
}
}
+386
View File
@@ -0,0 +1,386 @@
<?php
namespace Lucent\Http\Controller;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Lucent\Account\AccountService;
use Lucent\Account\AuthService;
use Lucent\Account\UserRepo;
use Lucent\Channel\ChannelService;
use Lucent\Field\System;
use Lucent\LucentException;
use Lucent\Query\Operator;
use Lucent\Query\Query;
use Lucent\Record\Manager;
use Lucent\Record\QueryRecord;
use Lucent\Record\RecordService;
use Lucent\Schema\Validator\ValidatorException;
use Lucent\Svelte\Svelte;
use function Lucent\Response\fail;
use function Lucent\Response\ok;
class RecordController extends Controller
{
public function __construct(
private readonly RecordService $recordService,
private readonly AccountService $accountService,
private readonly AuthService $authService,
private readonly ChannelService $channelService,
private readonly Svelte $svelte,
private readonly UserRepo $userRepo,
private readonly Query $query,
private readonly Manager $recordManager
)
{
}
public function index(Request $request)
{
$schemaName = $request->route("schemaName");
$users = $this->accountService->all();
$schema = $this->channelService->channel->schemas->where("name", $schemaName)->first();
$urlParams = $request->all();
$sort = data_get($urlParams, "sort") ?? "-_sys.updatedAt";
$filter = data_get($urlParams, "filter") ?? [];
$arguments = array_merge([
"_sys.schema" => $schema->name,
"_sys.status_in" => "draft,published",
], $filter);
$skip = data_get($urlParams, "skip") ?? 0;
$limit = 15;
$records = [];
$graphArray = null;
$total = 0;
try {
$queryResult = $this->query
->filter($arguments)
->limit($limit)
->status(explode(",", $arguments["_sys.status_in"]))
->skip($skip)
->sort($sort)
->childrenDepth(1)
->parentsDepth(0)
->runWithCount();
$graph = $queryResult->getQueryRecords();
$graphArray = $graph->toArray();
$total = $queryResult->getTotal();
$records = $graph->getRootRecords()->toArray();
} catch (SubqueryNoResultException) {
}
$data = [
"schemas" => $this->channelService->channel->schemas,
"schema" => $schema,
"users" => $users,
"records" => $records,
"graph" => $graphArray,
"systemFields" => array_values(System::list()),
"operators" => array_values(Operator::list()),
"sort" => $sort,
"limit" => $limit,
"skip" => $skip,
"total" => $total,
"filter" => $request->input("filter") ?? [],
"inModal" => true,
];
if ($request->ajax()) {
$data["modalUrl"] = $request->fullUrl();
return $data;
}
$data["inModal"] = false;
return $this->svelte->render(
layout: "channel",
view: "contentIndex",
title: "Records",
data: $data
);
}
public function exportCSV(Request $request)
{
$schemaName = $request->route("schemaName");
$schema = $this->channelService->channel->schemas->where("name", $schemaName)->first();
$urlParams = $request->all();
$sort = data_get($urlParams, "sort") ?? "-_sys.updatedAt";
$filter = data_get($urlParams, "filter") ?? [];
$arguments = array_merge([
"_sys.schema" => $schema->name,
"_sys.status_in" => "draft,published",
], $filter);
$records = [];
try {
$queryResult = $this->query
->filter($arguments)
// ->limit($limit)
->status(explode(",", $arguments["_sys.status_in"]))
// ->skip($skip)
->sort($sort)
->childrenDepth(0)
->parentsDepth(0)
->run();
$graph = $queryResult->getQueryRecords();
$records = $graph->getRootRecords()->toArray();
} catch (SubqueryNoResultException) {
}
header('Content-Type: application/csv');
header('Content-Disposition: attachment; filename="' . $schemaName . '.csv";');
$handle = fopen('php://output', 'w');
$csvRow = ["id", ...array_keys($records[0]->data->toArray())];
fputcsv($handle, $csvRow, ',');
foreach ($records as $record) {
$csvRow = [$record->id, ...$record->data->toArray()];
$csvRow = array_values($csvRow);
fputcsv($handle, $csvRow, ',');
}
fclose($handle);
echo $handle;
exit;
}
public function new(Request $request)
{
$schema = $this->channelService->channel->schemas->where("name", $request->input("schema"))->first();
$recordHistory = $this->recordManager->fromSession($request->session())->getRecords();
$record = $this->recordService->createEmpty($schema, $this->authService->currentUserId());
$queryRecord = QueryRecord::fromRecord($record);
return $this->svelte->render(
layout: "channel",
view: "recordEdit",
title: "New Record",
data: [
"schema" => $schema,
"record" => $queryRecord,
"recordHistory" => $recordHistory,
"isCreateMode" => true,
]
);
}
//
//
// public function newInline(Request $request)
// {
//
// $channel = ChannelRepo::current();
// $schema = $channel->schemas->where("name.value", $request->input("schema"))->first();
// $record = Record::createEmpty($schema, AuthService::currentUserId($request));
// $queryRecord = QueryRecord::fromRecord($record);
//
// return [
// "schemas" => $channel->schemas,
// "schema" => $schema,
// "record" => $queryRecord,
// "isCreateMode" => true,
// ];
// }
public function edit(Request $request)
{
$rid = $request->route("rid");
$queryResult = $this->query
->filter(["id" => $rid])
->limit(1)
->skip(0)
->childrenDepth(2)
->childrenLimit(100)
->parentsDepth(1)
->parentsLimit(100)
->run();
$graph = $queryResult->getQueryRecords();
if (empty($graph->records[0])) {
return $this->svelte->render(
layout: "channel",
view: "recordNotFound",
title: "Record Not Found",
);
}
$record = $graph->records[0];
$schema = $this->channelService->channel->schemas->where("name", $record->_sys->schema)->first();
$recordHistory = $this->recordManager->fromSession($request->session())->push($rid)->getRecords($rid);
$users = $this->userRepo->all();
return $this->svelte->render(
layout: "channel",
view: "recordEdit",
title: "Edit Record",
data: [
"schema" => $schema,
"graph" => $graph->toArray(),
"record" => $record->toArray(),
"users" => $users,
"recordHistory" => $recordHistory,
"isCreateMode" => $record->_sys->status === "empty",
]
);
}
//
// public function editInline(Request $request)
// {
// $channel = ChannelRepo::current();
// $rid = $request->route("rid");
//
// $queryResult = $this->query
// ->filter(["id" => $rid])
// ->limit(1)
// ->childrenDepth(2)
// ->parentsDepth(1)
// ->run();
//
// $graph = $queryResult->getQueryRecords($channel->schemas);
// $record = $graph->records[0];
// return ok(
// [
// "graph" => $graph->toArray(),
// "record" => $record->toArray(),
// ]
// );
// }
//
//
// public function suggestions(Request $request)
// {
// $arguments = [
// "_sys.schema" => $request->input("schema"),
// ];
//
// if ($request->input("value")) {
// if (in_array($request->input("ui"), ["text", "date"])) {
// $arguments[$request->input("field") . "_regex"] = $request->input("value");
// } elseif ($request->input("ui") == "number") {
// $arguments[$request->input("field") . "_eqnum"] = floatval($request->input("value"));
// } elseif ($request->input("ui") == "date") {
// }
// }
//
//
// $queryResult = $this->query
// ->filter($arguments)
// ->limit(10)
// ->run();
//
// if (!$queryResult->hasResults()) {
// return ok([]);
// }
// $schemas = $this->schemaRepo->all();
// $graph = $queryResult->getQueryRecords($schemas);
//
// return ok($graph->records->toArray());
// }
//
public function save(Request $request)
{
try {
if ($request->input("isCreateMode")) {
$this->recordService->create(
schemaName: $request->input("record._sys.schema"),
data: $request->input("record.data"),
id: $request->input("record.id"),
file: $request->input("record._file") ?? [],
edges: $request->input("edges"),
status: $request->input("record._sys.status"),
uploadFromUrl: ""
);
} else {
$this->recordService->update(
id: $request->input("record.id"),
data: $request->input("record.data"),
status: $request->input("record._sys.status"),
edges: $request->input("edges"),
updateEdges: true,
);
}
$queryResult = $this->query
->filter(["id" => $request->input("record.id")])
->limit(10)
->childrenDepth(2)
->parentsDepth(1)
->run();
$newGraph = $queryResult->getQueryRecords();
} catch (ValidatorException $th) {
return fail($th->getValidatorErrors());
} catch (LucentException $th) {
return fail($th);
}
return ok($newGraph->toArray());
}
public function clone(Request $request)
{
try {
$newRecordId = $this->recordService->clone(
recordId: $request->route("rid"),
);
} catch (LucentException $th) {
return fail($th);
} catch (ValidatorException $e) {
return fail($e);
}
return ok(["id" => $newRecordId]);
}
public function status(Request $request)
{
$ids = array_map(fn($rec) => $rec["id"], $request->input("records"));
$this->recordService->changeStatusBulk(
status: $request->route("status"),
recordsIds: $ids,
);
return ok();
}
//
// public function delete(Request $request)
// {
// $ids = $request->input("ids");
//
// try {
// $this->recordService->deleteMany($ids);
// } catch (Throwable $th) {
// return fail($th);
// }
// return ok();
// }
//
// public function rollback(Request $request)
// {
// try {
// $this->recordService->rollback(
// userId: AuthService::currentUserId($request),
// recordId: $request->route("rid"),
// version: (int)$request->route("version")
// );
// } catch (ValidatorException $th) {
// return fail($th->getFirstValidatorError());
// } catch (LucentException|Throwable $th) {
// return fail($th);
// }
// return ok();
// }
}
@@ -0,0 +1,41 @@
<?php
namespace Lucent\Http\Controller;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Lucent\Account\Auth;
use Lucent\Channel\ChannelContext;
use Lucent\Record\RecordRepo;
use Lucent\Revision\RevisionRepo;
use Lucent\Schema\SchemaRepo;
use function Lucent\Response\fail;
use function Lucent\Response\ok;
class RevisionController extends Controller
{
public function index(Request $request)
{
$revisions = RevisionRepo::getByRecordId($request->route("rid"));
return ok($revisions);
}
public function rollback(Request $request)
{
$schemas = SchemaRepo::all();
$revision = RevisionRepo::getByRecordIdAndVersion($request->route("rid"), (int)$request->route("version"));
try {
RecordRepo::replaceMany($schemas, [$revision->toDB()], Auth::currentUserId());
} catch (\Lucent\Schema\Validator\ValidatorException $th) {
return fail($th->getFirstValidatorError());
} catch (\Lucent\LucentException $th) {
return fail($th);
} catch (\Throwable $th) {
return fail($th);
}
return ok();
}
}
+126
View File
@@ -0,0 +1,126 @@
<?php
namespace Lucent\Http\Controller;
use App\Http\Controllers\Controller;
use Illuminate\Contracts\View\View;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Lucent\Channel\ChannelRepo;
use Lucent\LucentException;
use Lucent\Schema\SchemaRepo;
use Lucent\Schema\SchemaService;
use function Lucent\Response\fail;
use function Lucent\Response\ok;
use function Lucent\Svelte\svelte;
class SchemaController extends Controller
{
public function __construct(
private readonly SchemaRepo $schemaRepo,
private readonly SchemaService $schemaService
)
{
}
/**
* @throws LucentException
*/
public function new(): View
{
$schemas = $this->schemaRepo->all();
return svelte(
layout: "channel",
view: "schemaNew",
title: "Create schema",
data: [
"schemas" => $schemas,
"schema" => Schema::fromArray([]),
]
);
}
public function create(Request $request): Response
{
try {
$this->schemaService->create(
name: $request->input("name"),
label: $request->input("label"),
type: $request->input("type"),
isEntry: $request->input("isEntry"),
revisionRetentionDays: $request->input("revisionRetentionDays"),
revisionRetentionNumber: $request->input("revisionRetentionNumber"),
trashedRetentionDays: $request->input("trashedRetentionDays"),
fields: [],
path: $request->input("path"),
);
} catch (LucentException $th) {
return fail($th);
}
return ok();
}
public function edit(string $name): View
{
$channel = ChannelRepo::current();
return svelte(
layout: "channel",
view: "schemaEdit",
title: "Schemas",
data: [
"schemas" => $channel->schemas,
"schema" => $channel->schemas->where("name.value", $name)->first(),
]
);
}
public function delete(Request $request): View
{
$channel = ChannelRepo::current();
return svelte(
layout: "channel",
view: "schemaDelete",
title: "Schemas",
data: [
"channel" => $channel,
"schemas" => $channel->schemas,
"schema" => $channel->schemas->firstWhere("name", $request->route("name")),
"nav" => "collections",
]
);
}
public function update(Request $request): Response
{
try {
$schema = $this->schemaService->update(
name: $request->input("name"),
label: $request->input("label"),
isEntry: $request->input("isEntry"),
color: $request->input("color") ?? "",
visible: $request->input("visible") ?? [],
titleTemplate: $request->input("titleTemplate") ?? "",
revisionRetentionDays: $request->input("revisionRetentionDays"),
revisionRetentionNumber: $request->input("revisionRetentionNumber"),
trashedRetentionDays: $request->input("trashedRetentionDays"),
path: $request->input("path"),
);
} catch (LucentException $e) {
return fail($e);
}
return ok($schema->toArray());
}
public function postDelete(Request $request): Response
{
$this->schemaService->delete($request->input("name"));
return ok();
}
}
+66
View File
@@ -0,0 +1,66 @@
<?php
namespace Lucent\Http\Controller;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Lucent\Account\Auth;
use Lucent\Channel\ChannelContext;
use Lucent\Schema\SchemaRepo;
use Lucent\View\ViewRepo;
use function Lucent\Response\fail;
use function Lucent\Response\ok;
class ViewController extends Controller
{
public function redirect(Request $request, string $cid, string $schemaName, string $viewName)
{
$schema = SchemaRepo::findByName($schemaName);
$view = $schema->views->whereName($viewName);
parse_str($view->params, $viewParamsArray);
return \redirect("/c/{$cid}/content/{$schemaName}/views/{$viewName}?{$view->params}");
}
public function create(Request $request)
{
try {
$schema = SchemaRepo::findByName($request->input("schemaName"));
ViewRepo::create($schema, $request->input("view"), Auth::currentUserId());
} catch (\Throwable $th) {
return fail($th);
}
return ok();
}
public function update(Request $request)
{
try {
$schema = SchemaRepo::findByName($request->input("schemaName"));
ViewRepo::update($schema, $request->input("view"));
} catch (\Throwable $th) {
return fail($th);
}
$schema = SchemaRepo::findByName($request->input("schemaName"));
$view = $schema->views->whereName($request->input("view.name"));
return ok((array)$view);
}
public function delete(Request $request, string $cid, string $schemaName, string $viewName)
{
try {
$schema = SchemaRepo::findByName($schemaName);
ViewRepo::delete($schema, $viewName);
} catch (\Throwable $th) {
return fail($th);
}
return redirect("c/{$cid}/content/{$schemaName}");
}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace Lucent\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Lucent\Account\AuthService;
use Lucent\Channel\ChannelService;
readonly class AuthMiddleware
{
public function __construct(private AuthService $authService, private ChannelService $channelService)
{
}
public function handle(Request $request, Closure $next)
{
if (!$this->authService->isLoggedIn()) {
return redirect($this->channelService->channel->lucentUrl . "/login");
}
return $next($request);
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace Lucent\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Lucent\Account\AuthService;
readonly class GuestMiddleware
{
public function __construct(private AuthService $authService)
{
}
public function handle(Request $request, Closure $next)
{
if ($this->authService->isLoggedIn()) {
return redirect("/home");
}
return $next($request);
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
use Illuminate\Support\Facades\Route;
use Lucent\Http\Controller\Api\EdgeController;
use Lucent\Http\Controller\Api\FileController;
use Lucent\Http\Controller\Api\RecordController;
use Lucent\Http\Controller\Api\SchemaController;
Route::middleware('auth.api:developer')->group(function () {
Route::post('/schemas', [SchemaController::class, 'create']);
Route::put('/schemas/', [SchemaController::class, 'update']);
Route::delete('/schemas/{name}', [SchemaController::class, 'delete']);
Route::post('/schemas/fields', [SchemaController::class, 'fields']);
});
Route::middleware('auth.api:editor')->group(function () {
Route::post('/records', [RecordController::class, 'create']);
Route::put('/records/{id}', [RecordController::class, 'update']);
Route::post('/edges', [EdgeController::class, 'create']);
Route::post('/files', [FileController::class, 'upload']);
});
Route::middleware('auth.api:reader')->group(function () {
Route::get('/schemas', [SchemaController::class, 'find']);
Route::get('/schemas/{name}', [SchemaController::class, 'findOne']);
Route::get('/records', [RecordController::class, 'records']);
});
// They need testing
// Route::middleware('auth.api')->prefix("/fields")->controller(FieldController::class)->group(function () {
// Route::post('/', 'create');
// Route::put('/', 'update');
// Route::delete('/{id}', 'delete');
// });
//Route::middleware(["auth.api"])->group(function () {
//
//// Route::get('/{rid}', 'findOne');
//
//// Route::delete('/records/{id}', [RecordController::class, 'delete']);
//// Route::post('/bulk', 'bulkCreate');
//// Route::put('/bulk', 'bulkUpdate');
//// Route::delete('/bulk', 'bulkDelete');
//
//
//});
//
//
//Route::middleware('auth.api')->prefix("/files")->controller(FileController::class)->group(function () {
// Route::post('/', 'upload');
//});
+96
View File
@@ -0,0 +1,96 @@
<?php
use Illuminate\Support\Facades\Route;
use Lucent\Http\Controller\Api\AccountController;
use Lucent\Http\Controller\AuthController;
use Lucent\Http\Controller\FileController;
use Lucent\Http\Controller\HomeController;
use Lucent\Http\Controller\MemberController;
use Lucent\Http\Controller\RecordController;
use Lucent\Http\Controller\SchemaController;
use Lucent\Revision\RevisionController;
Route::group([
'middleware' => ['web'],
'prefix' => "lucent"
], function () {
Route::middleware(['lucent.guest'])->group(function () {
Route::get('/', [AuthController::class, 'login']);
Route::get('/register', [AuthController::class, 'register']);
Route::post('/register', [AuthController::class, 'postRegister']);
Route::get('/login', [AuthController::class, 'login']);
Route::post('/login', [AuthController::class, 'postLogin']);
Route::get('/verify', [AuthController::class, 'verify']);
Route::post('/verify', [AuthController::class, 'postVerify']);
});
Route::middleware('lucent.auth')->group(function () {
Route::get('/logout', [AuthController::class, 'logout']);
Route::get('/profile', [AccountController::class, 'profile']);
Route::post('/account/update-name', [AccountController::class, 'updateName']);
});
Route::middleware(["lucent.auth"])->group(function () {
Route::get('/members/', [MemberController::class, 'index']);
Route::post('/members/invite', [MemberController::class, 'invite']);
Route::post('/members/update', [MemberController::class, 'update']);
});
Route::middleware(["lucent.auth"])->prefix("/schemas/")->group(function () {
Route::get('/new/', [SchemaController::class, 'new']);
Route::post('/', [SchemaController::class, 'create']);
Route::get('/{name}/edit', [SchemaController::class, 'edit']);
Route::put('/', [SchemaController::class, 'update']);
Route::get('/{name}/delete', [SchemaController::class, 'delete']);
Route::post('/delete', [SchemaController::class, 'postDelete']);
});
Route::middleware(["lucent.auth"])->prefix("/records")->group(function () {
Route::get('/new', [RecordController::class, 'new']);
Route::get('/newInline', [RecordController::class, 'newInline']);
Route::get('/suggestions', [RecordController::class, 'suggestions']);
Route::get('/{rid}', [RecordController::class, 'edit']);
Route::post('/clone/{rid}', [RecordController::class, 'clone']);
Route::get('/editInline/{rid}', [RecordController::class, 'editInline']);
Route::get('/{rid}/parents', [RecordController::class, 'parents']);
Route::post('/', [RecordController::class, 'save']);
Route::post('/status/{status}', [RecordController::class, 'status']);
Route::post('/delete', [RecordController::class, 'delete']);
Route::post('/{rid}/rollback/{version}', [RecordController::class, 'rollback']);
});
Route::middleware(["lucent.auth"])->group(function () {
Route::get('/records/{rid}/revisions', [RevisionController::class, 'index']);
});
Route::middleware(["lucent.auth"])->group(function () {
Route::get('/', [HomeController::class, 'home']);
Route::get('/home/records', [HomeController::class, 'records']);
});
Route::middleware(["lucent.auth"])->prefix("/content")->group(function () {
Route::get('/{schemaName}', [RecordController::class, 'index']);
Route::get('/{schemaName}/csv', [RecordController::class, 'exportCSV']);
});
Route::middleware(["lucent.auth"])->group(function () {
Route::post('/files/upload', [FileController::class, 'upload']);
Route::get('/files/download', [FileController::class, 'download']);
});
Route::get('/fs/cache/{path}', [FileController::class, 'get'])->where('path', '.*');
Route::get('/fs/{path}', [FileController::class, 'get'])->where('path', '.*');
});
+13
View File
@@ -0,0 +1,13 @@
<?php
namespace Lucent\Id;
use Illuminate\Support\Str;
class Id
{
public static function new(): string
{
return (string) Str::uuid();
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace Lucent;
use Exception;
final class LucentException extends Exception
{
// Redefine the exception so message isn't optional
public function __construct(string $message, int $code = 0, Exception $previous = null)
{
// make sure everything is assigned properly
parent::__construct($message, $code, $previous);
}
}
+37
View File
@@ -0,0 +1,37 @@
<?php
namespace Lucent\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
class LoginMail extends Mailable
{
use Queueable, SerializesModels;
/**
* Create a new message instance.
*
* @return void
*/
public function __construct(
public string $email,
public string $token,
public string $url
)
{
}
/**
* Build the message.
*
* @return $this
*/
public function build()
{
return $this->subject('Login to LucentCMS')->text('lucent::emails.login');
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace Lucent\Primitive;
use Illuminate\Support\Collection as LaravelCollection;
/**
* @template T
* @extends \Illuminate\Support\Collection<int, T>
*/
final class Collection extends LaravelCollection
{
/**
* @param array<T> $values
*/
public function __construct(
$values = []
) {
parent::__construct($values);
}
/**
* @return array<T>
**/
public function toArray(): array
{
return collect($this)->values()->toArray();
}
/**
* @return Collection<T>
**/
public function rejectNull(): Collection
{
return $this->filter(fn($v) => !empty($v))->values();
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace Lucent\Primitive;
use Illuminate\Support\Collection;
/**
* @extends \Illuminate\Support\Collection<int|string, string>
*/
final class StringCollection extends Collection
{
public function __construct(
string ...$array
) {
parent::__construct($array);
}
/**
* @return string[]
**/
public function toArray(): array
{
return collect($this)->values()->toArray();
}
public static function fromArray(array $data): StringCollection
{
return new StringCollection(...$data);
}
public static function fromDB(string $data): StringCollection
{
return new StringCollection(...\json_decode($data,true));
}
}
+185
View File
@@ -0,0 +1,185 @@
<?php
namespace Lucent\Query;
use Illuminate\Support\Facades\DB;
final class Filter
{
private array $operatorsList;
public function __construct(
public array $arguments = [],
)
{
}
public function add(array $arguments): Filter
{
$this->arguments = $arguments;
return $this;
}
private function formatArguments(array $arguments): array
{
return (array)collect($arguments)->reduce(fn($c, $v, $k) => $this->formatArgument($c, $v, $k), []);
}
private function formatArgument(array $arguments, mixed $value, string $filter): array
{
$operator = $this->detectOperator($filter);
$field = $this->detectField($filter, $operator);
$formattedValue = match ($operator) {
"eq" => $this->formatText($value),
"ne" => $this->formatText($value),
"eqnum" => $this->formatNumber($value),
"nenum" => $this->formatNumber($value),
"object" => $this->formatText($value),
"in" => $this->formatListString($value),
"nin" => $this->formatListString($value),
"innum" => $this->formatListNum($value),
"ninnum" => $this->formatListNum($value),
"eqtrue" => true,
"eqfalse" => false,
"netrue" => true,
"nefalse" => false,
"regex" => "%{$value}%",
"gt" => \is_numeric($value) ? floatval($value) : $value,
"gte" => \is_numeric($value) ? floatval($value) : $value,
"lt" => \is_numeric($value) ? floatval($value) : $value,
"lte" => \is_numeric($value) ? floatval($value) : $value,
"null" => null,
"nnull" => null,
"exists" => true,
"nexists" => false,
default => $value,
};
$matchedOperator = $this->operatorsList[$operator];
$arguments[] = [
"field" => str_replace(".", "->", $field),
"operator" => $matchedOperator->db,
"value" => $formattedValue
];
return $arguments;
}
private function formatText(string $value): string
{
return trim($value);
}
private function formatNumber(string $value): float
{
return \floatval($value);
}
private function formatListString(mixed $value): array
{
if (\is_string($value)) {
$value = explode(",", $value);
}
return \array_map(fn($v) => $this->formatText($v), $value);
}
private function formatListNum(mixed $value): array
{
if (\is_string($value)) {
$value = explode(",", $value);
}
return \array_map(fn($v) => $this->formatNumber($v), $value);
}
private function detectOperator(string $filter): string
{
$exploded = \explode("_", $filter);
$candidate = end($exploded);
$operatorsListNames = collect($this->operatorsList)->map(fn($o) => $o->name)->toArray();
if (\in_array($candidate, $operatorsListNames)) {
return $candidate;
}
return 'eq';
}
private function detectField(string $filter, string $operator): string
{
$exploded = \explode("_", $filter);
$candidate = array_pop($exploded);
if ($candidate == $operator) {
return \implode("_", $exploded);
}
return $filter;
}
private function formatReferences(Query $query): array
{
[$arguments, $referenceArguments] = $this->separateMainFromReferenceArguments();
if (empty($referenceArguments)) {
return [$arguments, []];
};
$subqueries = collect($referenceArguments)->reduce(function ($c, $v, $k) {
$keyWithoutRef = str_replace("children.", "", $k);
[$field] = explode(".", $keyWithoutRef);
$referenceField = str_replace($field . ".", "", $keyWithoutRef);
$c[$field][$referenceField] = $v;
return $c;
}, []);
$sourceIds = collect($subqueries)->reduce(function ($c, $subquery, $k) use ($query) {
$graph = $query->filter($subquery)->run();
if (!$graph->hasResults()) {
return $c;
}
$targetIds = collect($graph->records)->pluck("id");
$sourceIds = DB::table("edges")->whereIn("target", $targetIds)->where("field", $k)->get()->pluck("source");
return array_merge($c, $sourceIds->toArray());
}, []);
return [$arguments, [
"field" => "id",
"operator" => "in",
"value" => $sourceIds
]];
}
private function separateMainFromReferenceArguments(): array
{
return collect($this->arguments)->partition(function ($v, $k) {
if (!str_starts_with($k, "children.")) {
return true;
}
return false;
})->toArray();
}
public function run(Query $query): array
{
[$argumentsWithoutReferences, $referencesFilter] = $this->formatReferences($query);
$this->operatorsList = Operator::list();
$formattedArguments = $this->formatArguments($argumentsWithoutReferences);
if (!empty($referencesFilter)) {
$formattedArguments[] = $referencesFilter;
}
return $formattedArguments;
}
}
+102
View File
@@ -0,0 +1,102 @@
<?php
namespace Lucent\Query;
use Lucent\Edge\Edge;
use Lucent\Primitive\Collection;
use Lucent\Record\QueryRecord;
final class Graph
{
/**
* @param Collection<QueryRecord> $records
* @param Collection<Edge> $edges
* */
public function __construct(
public Collection $records,
public Collection $edges,
public ?int $total = null,
)
{
}
/**
* @return Collection<QueryRecord>
* */
public function getRootRecords(): Collection
{
return $this->records->where("isRoot", true)->values();
}
public function hasResults(): bool
{
return !empty($this->records);
}
// public function getRootRecordsWithChildren(): Collection
// {
// $rootRecords = $this->records->where("isRoot", true)->values();
// return $rootRecords->map([$this, 'findChildren']);
// }
//
// public function findChildren(QueryRecord $record): QueryRecord
// {
// $recordEdges = $this->edges
// ->where("source", $record->id)
// ->values()
// ->sortBy("rank")
// ->groupBy("field")->toArray();
//
//
// foreach ($recordEdges as $field => $edges) {
// $recordEdges[$field] = [];
// foreach ($edges as $anEdge) {
// $aRecord = $this->records->where("id", $anEdge->target)->first();
// if (empty($aRecord)) {
// continue;
// }
// $recordEdges[$field][] = $this->findChildren($aRecord);
// }
// }
//
// $record->_children = new Collection($recordEdges);
// return $record;
// }
public function tree(): Collection
{
return $this->records->filter(function (QueryRecord $record) {
return $this->edges->filter(fn(Edge $ed) => $ed->target == $record->id)->isEmpty();
})->values()
->map([$this, 'findChildren']);
}
public function findChildren(QueryRecord $record): QueryRecord
{
$recordEdges = $this->edges->filter(fn(Edge $ed) => $ed->source === $record->id)->values()->sort(fn($a, $b) => $a->rank <=> $b->rank)->values();
$groupRecordEdges = [];
foreach ($recordEdges as $element) {
$groupRecordEdges[$element->field][] = $element;
}
$children = [];
foreach ($groupRecordEdges as $field => $edges) {
$children[$field] = [];
foreach ($edges as $anEdge) {
$aRecord = $this->records->filter(fn(QueryRecord $rec) => $rec->id == $anEdge->target)->values();
if (empty($aRecord[0])) {
continue;
}
$children[$field][] = $this->findChildren($aRecord[0]);
}
}
$record->_children = $children;
return $record;
}
}
+186
View File
@@ -0,0 +1,186 @@
<?php
namespace Lucent\Query;
final class Operator
{
/**
* @psalm-param string[] $uis
*/
public function __construct(
public string $name,
public string $label,
public string $symbol,
public string $db,
public array $uis,
)
{
}
/**
* @return array<string, Operator>
*/
public static function list(): array
{
return [
"regex" => new Operator(
name: "regex",
label: "Search",
symbol: "~",
db: 'like',
uis: ["id", "text", "textarea", "url", "color", "date", "datetime"],
),
"eq" => new Operator(
name: "eq",
label: "Equals",
symbol: "is",
db: '=',
uis: ["id", "text", "textarea", "url", "color", "date", "datetime"],
),
"ne" => new Operator(
name: "ne",
label: "Not Equals",
symbol: "is not",
db: '!=',
uis: ["id", "text", "textarea", "url", "color", "date", "datetime"],
),
"eqnum" => new Operator(
name: "eqnum",
label: "Equals number",
symbol: "is",
db: '=',
uis: ["number"],
),
"neqnum" => new Operator(
name: "nenum",
label: "Not Equals number",
symbol: "is not",
db: '$ne',
uis: ["number"],
),
"object" => new Operator(
name: "object",
label: "Equals Object",
symbol: "is",
db: 'eqobject',
uis: [],
),
"eqtrue" => new Operator(
name: "eqtrue",
label: "Equals true",
symbol: "is",
db: '=',
uis: ["checkbox"],
),
"eqfalse" => new Operator(
name: "eqfalse",
label: "Equals false",
symbol: "is not",
db: '=',
uis: ["checkbox"],
),
"netrue" => new Operator(
name: "netrue",
label: "Not equals true",
symbol: "!=",
db: '$ne',
uis: ["checkbox"],
),
"nefalse" => new Operator(
name: "nefalse",
label: "Not equals false",
symbol: "!=",
db: '$ne',
uis: ["checkbox"],
),
"in" => new Operator(
name: "in",
label: "In list",
symbol: "in",
db: 'in',
uis: ["id", "text", "textarea", "url", "color", "date", "datetime"],
),
"innum" => new Operator(
name: "innum",
label: "In list of numbers",
symbol: "in",
db: '$in',
uis: ["number"],
),
"nin" => new Operator(
name: "nin",
label: "Not in list",
symbol: "not in",
db: 'nin',
uis: ["id", "text", "textarea", "url", "color", "date", "datetime"],
),
"ninnum" => new Operator(
name: "ninnum",
label: "Not In list of numbers",
symbol: "not in",
db: '$nin',
uis: ["number"],
),
"lt" => new Operator(
name: "lt",
label: "Less than",
symbol: "<",
db: '<',
uis: ["number", "date", "datetime"],
),
"lte" => new Operator(
name: "lte",
label: "Less than equals",
symbol: "<=",
db: '<=',
uis: ["number", "date", "datetime"],
),
"gt" => new Operator(
name: "gt",
label: "Greater than",
symbol: ">",
db: '>',
uis: ["number", "date", "datetime"],
),
"gte" => new Operator(
name: "gte",
label: "Greater than equals",
symbol: ">=",
db: '>=',
uis: ["number", "date", "datetime"],
),
"null" => new Operator(
name: "null",
label: "Is null",
symbol: "=",
db: '$eq',
uis: ["*"],
),
"nnull" => new Operator(
name: "nnull",
label: "Not null",
symbol: "!=",
db: '$ne',
uis: ["*"],
),
"exists" => new Operator(
name: "exists",
label: "Exists",
symbol: "exists",
db: '$exists',
uis: ["*"],
),
"nexists" => new Operator(
name: "nexists",
label: "Not exists",
symbol: "not exists",
db: '$exists',
uis: ["*"],
),
];
}
}
+292
View File
@@ -0,0 +1,292 @@
<?php
namespace Lucent\Query;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\DB;
use Lucent\Channel\ChannelService;
use Lucent\Edge\Edge;
use Lucent\Primitive\Collection;
use Lucent\Record\InputFormatter;
use Lucent\Record\QueryRecord;
use Lucent\Record\Record;
final class Query
{
public Filter $filter;
public QueryOptions $options;
public function __construct(
public readonly ChannelService $channelService,
public readonly InputFormatter $inputFormatter,
)
{
$this->options = new QueryOptions();
}
public function filter(array $filterArguments): Query
{
$this->filter = new Filter($filterArguments);
return $this;
}
public function run(): Graph
{
$resultsRecords = $this->mainQuery();
$ids = array_map(function ($rec) {
return $rec->id;
}, $resultsRecords);
$resultChildrenEdgesTargetIds = [];
$resultChildrenEdges = [];
if ($this->options->childrenDepth > 0 && !empty($ids)) {
$resultChildrenEdges = $this->getChildren($ids);
$resultChildrenEdgesTargetIds = array_map(fn($e) => $e->target, $resultChildrenEdges);
}
$resultParentSourceTargetIds = [];
$resultParentEdges = [];
if ($this->options->parentsDepth > 0 && !empty($ids)) {
$resultParentEdges = $this->getParents($ids);
$resultParentSourceTargetIds = array_map(fn($e) => $e->source, $resultParentEdges);
}
$edgesIds = collect($resultParentSourceTargetIds)->merge($resultChildrenEdgesTargetIds)->unique()->values()->toArray();
$edgeRecords = [];
if (!empty($edgesIds)) {
$edgeRecords = DB::table('records')
->whereIn("id", $edgesIds)
->whereIn("_sys->status", $this->options->status)
->get()->toArray();
}
$resultsRecordsUnique = collect(array_merge($resultsRecords, $edgeRecords))->unique("id")->values()->toArray();
$resultEdges = collect(array_merge($resultChildrenEdges, $resultParentEdges))
->unique(fn($edge) => $edge->source . $edge->target . $edge->field)
->toArray();
return $this->formatRecords($resultsRecordsUnique, $resultEdges);
}
private function formatRecords(array $records, array $edges): Graph
{
$queryRecords = collect($records)->map(function ($recordData) {
$record = Record::fromDB($recordData);
$record->data = $this->inputFormatter->fill($record->_sys->schema, $record->data);
$queryRecord = QueryRecord::fromRecord($record);
$queryRecord->isRoot = data_get($recordData, "isRoot") === true;
return $queryRecord;
})->toArray();
$queryEdges = collect($edges)->map(function ($edgeData) {
return Edge::fromArray((array)$edgeData);
})->sortBy("rank")->values()->toArray();
return new Graph(
new Collection($queryRecords),
new Collection($queryEdges),
);
}
public function tree(): Collection
{
return $this->run()->tree();
}
/**
* @throws SubqueryNoResultException
*/
private function parseFilters(Builder $query): Builder
{
$filters = $this->filter->run(new Query($this->channelService, $this->inputFormatter));
$ignoredFilters = [];
foreach ($filters as $filter) {
if (in_array($filter["field"], $ignoredFilters)) {
continue;
} else if ($filter["operator"] == "in") {
$query->whereIn($filter["field"], $filter["value"]);
} else if ($filter["operator"] == "nin") {
$query->whereNotIn($filter["field"], $filter["value"]);
} elseif ($filter["operator"] == "eqobject") {
$object = $filter["value"];
// unset related filters used here
$addToIgnored = collect($filters)
->filter(fn($f) => str_starts_with($f["field"], $object))
->values()
->map(fn($f) => $f["field"])
->toArray();
$ignoredFilters = array_merge($ignoredFilters, $addToIgnored);
$objectFilters = collect($filters)
->filter(fn($f) => str_starts_with($f["field"], $object))
->values()
->reduce(function ($c, $f) use ($object) {
$field = str_replace($object . "->", "", $f["field"]);
$c[$field] = $f["value"];
return $c;
});
// target result
// filter[data.previousNames_object]=previousNames&filter[previousNames.name_eq]=alpha&filter[previousNames.id_eqnum]=24
// $query->whereJsonContains("data->previousNames", [["name" => "alpha", "id" => 24]]);
// $query->whereJsonContains($filter["field"], [$objectFilters]);
} else {
$query->where($filter["field"], $filter["operator"], $filter["value"]);
}
}
$query->whereIn("_sys->status", $this->options->status);
return $query;
}
/**
* @throws SubqueryNoResultException
*/
private function mainQuery(): array
{
$query = DB::table("records");
$query = $this->parseFilters($query);
$query->limit($this->options->limit);
$query->offset($this->options->skip);
$query = $this->orderByQuery($query);
return $query->get()->map(function ($r) {
$r->isRoot = true;
return $r;
})->toArray();
}
private
function getChildren(array $ids): array
{
$subquery = DB::table('edges AS g')
->select(DB::raw('g.source,g.target,g.rank,"g"."sourceSchema","g"."targetSchema",g.field, 0 as depth '))
->whereIn('source', $ids)
->limit($this->options->childrenLimit)
->unionAll(
DB::table(DB::raw("edges AS g, search_graph AS sg "))
->selectRaw('g.source,g.target,g.rank,"g"."sourceSchema","g"."targetSchema",g.field,sg.depth + 1 as depth')
->whereRaw("g.source = sg.target")
->where("sg.depth", "<=", $this->options->childrenDepth)
->orderBy("rank")
);
return DB::table('search_graph')
// ->select(DB::raw("*, 1 as depth "))
->withRecursiveExpression('search_graph', $subquery)
->get()->toArray();
}
private
function getParents(array $ids): array
{
$subquery = DB::table('edges AS g')
->select(DB::raw('g.source,g.target,g.rank,"g"."sourceSchema","g"."targetSchema",g.field, 0 as depth '))
->limit($this->options->parentsLimit)
->whereIn('g.target', $ids)
->unionAll(
DB::table(DB::raw("edges AS g, search_graph AS sg "))
->selectRaw('g.source,g.target,g.rank,"g"."sourceSchema","g"."targetSchema",g.field,sg.depth + 1 as depth')
->whereRaw("g.target = sg.source")
->where("sg.depth", "<=", $this->options->parentsDepth)
->orderBy("rank")
);
return DB::table('search_graph')
// ->select(DB::raw('sg.source,sg.target,sg.rank,sg."sourceSchema",sg."targetSchema",sg.field,sg.depth'))
->withRecursiveExpression('search_graph', $subquery)
->get()->toArray();
}
/**
* @throws SubqueryNoResultException
*/
public
function runWithCount(): Graph
{
$query = DB::table("records");
$query = $this->parseFilters($query);
$graph = $this->run();
$graph->total = $query->count();
return $graph;
}
public
function limit(int $limit): Query
{
$this->options->limit = $limit;
return $this;
}
public
function skip(int $skip): Query
{
$this->options->skip = $skip;
return $this;
}
public function childrenDepth(int $depth): Query
{
$this->options->childrenDepth = $depth;
return $this;
}
public function childrenLimit(int $limit): Query
{
$this->options->childrenLimit = $limit;
return $this;
}
public function parentsDepth(int $depth): Query
{
$this->options->parentsDepth = $depth;
return $this;
}
public function parentsLimit(int $limit): Query
{
$this->options->parentsLimit = $limit;
return $this;
}
public function sort(string $sort): Query
{
$this->options->sort[] = $sort;
return $this;
}
public function status(array $status): Query
{
$this->options->status = $status;
return $this;
}
public
function orderByQuery(Builder $query): Builder
{
foreach ($this->options->sort as $item) {
$field = str_replace(".", "->", ltrim($item, '-'));
$dir = str_starts_with($item, '-') ? "desc" : "asc";
if ($field) {
$query->orderBy($field, $dir);
}
}
return $query;
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace Lucent\Query;
final class QueryOptions
{
public function __construct(
public int $limit = 20,
public int $skip = 0,
public int $childrenDepth = -1,
public int $parentsDepth = -1,
public int $childrenLimit = -1,
public int $parentsLimit = -1,
public array $sort = [],
public array $status = ["published", "draft"]
)
{
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace Lucent\Query;
use Lucent\Edge\Edge;
use Lucent\Primitive\Collection;
use Lucent\Record\QueryRecord;
final class QueryResult
{
/**
* @param Collection<QueryRecord> $records
* @param Collection<Edge> $edges
* @param int|null $total
*/
public function __construct(
public Collection $records,
public Collection $edges,
public ?int $total = null,
)
{
}
public function getTotal(): ?int
{
return $this->total;
}
public function hasResults(): bool
{
return !empty($this->records);
}
public function graph(): Graph
{
return new Graph($this->records, $this->edges);
}
}
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace Lucent\Query;
use Exception;
final class SubqueryNoResultException extends Exception
{
// Redefine the exception so message isn't optional
public function __construct(string $message, int $code = 0, Exception $previous = null)
{
// make sure everything is assigned properly
parent::__construct($message, $code, $previous);
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace Lucent\Record;
class File
{
function __construct(
public readonly string $originalName,
public readonly string $mime,
public readonly string $path,
public readonly int $size,
public readonly int $width,
public readonly int $height,
public readonly string $checksum,
)
{
}
public static function fromArray(array $data): File
{
return new File(
originalName: data_get($data, "originalName"),
mime: data_get($data, "mime"),
path: data_get($data, "path"),
size: data_get($data, "size"),
width: data_get($data, "width"),
height: data_get($data, "height"),
checksum: data_get($data, "checksum"),
);
}
public function toArray(): array
{
return \json_decode(\json_encode($this), true);
}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace Lucent\Record;
use Lucent\Channel\ChannelService;
use Lucent\Schema\FieldInterface;
class InputFormatter
{
public function __construct(
public ChannelService $channelService,
)
{
}
public function fill(string $schemaName, RecordData $input): RecordData
{
$schema = $this->channelService->getSchema($schemaName)->get();
$data = $schema->fields->reduce(fn(array $carry, FieldInterface $field) => $field->format($input->toArray(), $carry), []);
return new RecordData($data);
}
}
+98
View File
@@ -0,0 +1,98 @@
<?php
namespace Lucent\Record;
use Illuminate\Contracts\Session\Session;
use Lucent\Primitive\Collection;
use Lucent\Query\Query;
use Lucent\Schema\Schema;
class Manager
{
public array $records = [];
public Session $session;
public function fromSession(Session $session): Manager
{
$this->session = $session;
$this->records = $session->get("manager") ?? [];
return $this;
}
public function __construct(
private readonly Query $query
)
{
}
/**
* @param string[] $records
*/
public function addRecords(array $records): Manager
{
$this->records = $records;
return $this;
}
public function push(string $recordId): Manager
{
$records = $this->getIdsExcept($recordId);
$records[] = $recordId;
$records = array_unique($records);
$records = array_values($records);
$records = array_slice($records, -5);
$records = array_values($records);
$this->records = $records;
$this->save();
return $this;
}
private function save(): void
{
$this->session->put("manager", $this->records);
}
public function getIdsExcept(?string $id): array
{
return collect($this->records)->filter(fn($arec) => $arec !== $id)->values()->toArray();
}
/**
* @param QueryRecord[] $records
* @return QueryRecord[] $records
*/
public function order(array $records): array
{
$recordsById = collect($records)->keyBy("id")->toArray();
return collect($this->records)->reverse()->values()->reduce(function ($carry, $arecId) use ($recordsById) {
if (isset($recordsById[$arecId])) {
$carry[] = $recordsById[$arecId];
}
return $carry;
}, []);
}
/**
* @param Collection<Schema> $schemas
*/
public function getRecords(?string $ignoreId = null): array
{
$queryResult = $this->query
->filter(["id_in" => $this->getIdsExcept($ignoreId)])
->limit(7)
->run();
$graph = $queryResult->getQueryRecords();
return $this->order($graph->records->toArray());
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
namespace Lucent\Record;
use Lucent\LucentException;
class QueryRecord
{
function __construct(
public string $id,
public System $_sys,
public RecordData $data,
public bool $isRoot,
public ?File $_file = null,
public array $_children = [],
)
{
}
public function toArray(): array
{
return json_decode(json_encode($this), true);
}
/**
* @throws LucentException
*/
public static function fromArray(array $data): QueryRecord
{
return new QueryRecord(
id: $data["id"],
_sys: System::fromArray($data["_sys"]),
data: new RecordData($data["data"]),
isRoot: $data["isRoot"] ?? false,
_file: $data["_file"] ? new File(...$data["_file"]) : null,
);
}
public static function fromRecord(Record $record): QueryRecord
{
return new QueryRecord(
id: $record->id,
_sys: $record->_sys,
data: $record->data,
isRoot: false,
_file: $record->_file,
);
}
}
+77
View File
@@ -0,0 +1,77 @@
<?php
namespace Lucent\Record;
use JsonSerializable;
use stdClass;
class Record implements JsonSerializable
{
function __construct(
public string $id,
public System $_sys,
public RecordData $data,
public ?File $_file = null,
)
{
}
public function toArray(): array
{
return \json_decode(\json_encode($this), true);
}
public function toDB(): array
{
return [
"id" => $this->id,
"_sys" => json_encode($this->_sys),
"_file" => json_encode($this->_file),
"data" => json_encode($this->data),
];
}
public static function fromDB(stdClass $data): Record
{
$file = json_decode($data->_file, true);
if (!empty($file)) {
$file = new File(...$file);
} else {
$file = null;
}
return new Record(
id: $data->id,
_sys: System::fromArray(json_decode($data->_sys, true)),
data: new RecordData(json_decode($data->data, true)),
_file: $file,
);
}
public function jsonSerialize(): static
{
return $this;
}
public static function fromArray(array $data): Record
{
$file = null;
if (!empty($data["_file"])) {
$file = File::fromArray($data["_file"]);
}
return new Record(
id: $data["id"],
_sys: System::fromArray($data["_sys"]),
data: new RecordData($data["data"]),
_file: $file,
);
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace Lucent\Record;
use Lucent\ArrayContainer;
class RecordData extends ArrayContainer
{
public function merge(RecordData $data): RecordData
{
$this->data = array_merge($this->data, $data->toArray());
return $this;
}
public function toArray(): array
{
return $this->data;
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace Lucent\Record;
use Lucent\Edge\QueryEdge;
class RecordGraph
{
/**
* @param \Lucent\Edge\QueryEdge[] $edges
* @param \Lucent\Record\EdgeRecord[] $nodes
*/
function __construct(
public readonly array $edges,
public readonly array $nodes,
) {
}
public function toArray(): array
{
return \json_decode(\json_encode($this), true);
}
public static function fromArray(array $data): RecordGraph
{
return new RecordGraph(
edges: collect($data["edges"] ?? [])->map(fn ($edge) => new QueryEdge(...$edge))->toArray(),
nodes: collect($data["nodes"] ?? [])->map(fn ($node) => EdgeRecord::fromArray($node))->toArray(),
);
}
}
+50
View File
@@ -0,0 +1,50 @@
<?php
namespace Lucent\Record;
use Illuminate\Support\Facades\DB;
class RecordRepo
{
public static function create(Record $record): void
{
$recordToDB = $record->toDB();
DB::table("records")->insert($recordToDB);
}
/**
* @param array<string> $ids
*/
public static function updateStatusBulk(RecordStatus $status, array $ids): void
{
DB::table("records")->whereIn("id", $ids)->update([
'_sys->status' => $status->value()
]);
}
public static function update(Record $record): void
{
$recordToDB = $record->toDB();
DB::table("records")->where("id", $record->id)->update($recordToDB);
}
/**
* @param string[] $ids
*/
public static function deleteMany(
array $ids,
): void
{
DB::table("records")
->whereIn("id", $ids)->delete();
DB::table("edges")->whereIn("source", $ids)->delete();
DB::table("edges")->whereIn("target", $ids)->delete();
DB::table("revisions")->whereIn("recordId", $ids)->delete();
}
}
+263
View File
@@ -0,0 +1,263 @@
<?php
namespace Lucent\Record;
use Illuminate\Support\Str;
use Lucent\Account\AuthService;
use Lucent\Channel\ChannelService;
use Lucent\Edge\Edge;
use Lucent\Edge\EdgeCollection;
use Lucent\Edge\EdgeRepo;
use Lucent\File\FileService;
use Lucent\Id\Id;
use Lucent\LucentException;
use Lucent\Query\Query;
use Lucent\Revision\RevisionService;
use Lucent\Schema\FieldInterface;
use Lucent\Schema\Schema;
use Lucent\Schema\Validator\Validator;
use Lucent\Schema\Validator\ValidatorException;
readonly class RecordService
{
public function __construct(
private AuthService $authService,
private RevisionService $revisionService,
private ChannelService $channelService,
private Validator $recordValidator,
private Query $query,
private InputFormatter $inputFormatter
)
{
}
/**
* @throws LucentException
* @throws ValidatorException
*/
public
function create(
string $schemaName,
array $data,
?string $id = null,
array $file = [],
array $edges = [],
string $status = "draft",
string $uploadFromUrl = "",
): string
{
$schema = $this->channelService->channel->schemas->where("name", $schemaName)->first();
if (empty($schema)) {
throw new LucentException("The schema " . $schemaName . " does not exist");
}
$formattedData = $this->inputFormatter->fill($schemaName, new RecordData($data));
if (empty($formattedData["id"])) {
$formattedData["id"] = Id::new();
}
$uploadResult = FileService::create($schema, $uploadFromUrl, $file);
$uniqueEdges = collect($edges)
->map(fn($e) => (array)(new Edge(...$e)))
->map(function ($edge, $index) {
$edgeData = (array)(new Edge(...$edge));
$edgeData["rank"] = $index;
return $edgeData;
})
->unique(fn($e) => $e['field'] . $e['source'] . $e['target'] . $e['sourceSchema'])
->values()->toArray();
$uniqueEdgesCollection = EdgeCollection::fromArray($uniqueEdges);
if ($uploadResult->isDuplicate) {
EdgeRepo::update($uploadResult->duplicateId, $uniqueEdgesCollection);
return $uploadResult->duplicateId;
}
$record = new Record(
id: $id ?? Id::new(),
_sys: System::newRecord($schema, $this->authService->currentUserId(), $status),
data: $formattedData,
_file: $uploadResult->recordFile,
);
$errors = $this->recordValidator->check($schemaName, $record->data, $uniqueEdgesCollection);
if ($errors->isNotEmpty()) {
$this->recordValidator->throwException($errors);
}
RecordRepo::create($record);
EdgeRepo::update($record->id, $uniqueEdgesCollection);
$this->revisionService->create($record);
return $record->id;
}
/**
* @throws LucentException
* @throws ValidatorException
*/
public
function update(
string $id,
array $data,
string $status = "draft",
array $edges = [],
bool $updateEdges = false
): void
{
$queryResult = $this->query->filter(["id" => $id])->run();
$record = $queryResult->getQueryRecords()->records[0] ?? null;
if (empty($record)) {
throw new LucentException("Record id is missing");
}
$formattedData = $this->inputFormatter->fill($record->_sys->schema, new RecordData($data));
if ($updateEdges) {
$uniqueEdges = collect($edges)
->map(function ($edge, $index) {
$edgeData = (array)(new Edge(...$edge));
$edgeData["rank"] = $index;
return $edgeData;
})
->unique(fn($e) => $e['field'] . $e['source'] . $e['target'] . $e['sourceSchema'])
->values()->toArray();
$uniqueEdgesCollection = EdgeCollection::fromArray($uniqueEdges);
$errors = $this->recordValidator->check($record->_sys->schema, $formattedData, $uniqueEdgesCollection);
} else {
$errors = $this->recordValidator->check($record->_sys->schema, $formattedData, null);
}
$newRecord = new Record(
id: $record->id,
_sys: $record->_sys->update($this->authService->currentUserId(), $status),
data: $record->data->merge($formattedData),
_file: $record->_file,
);
if ($errors->isNotEmpty()) {
$this->recordValidator->throwException($errors);
}
RecordRepo::update($newRecord);
if ($updateEdges) {
EdgeRepo::update($newRecord->id, $uniqueEdgesCollection);
}
$this->revisionService->create($newRecord);
}
/**
*/
public
function changeStatusBulk(
string $status,
array $recordsIds,
): void
{
$recordsStatus = (new RecordStatus($status));
RecordRepo::updateStatusBulk($recordsStatus, $recordsIds);
}
/**
* @throws LucentException
* @throws ValidatorException
*/
public
function clone(
string $recordId,
): string
{
$queryResult = $this->query
->filter(["id" => $recordId])
->limit(1)
->childrenDepth(1)
->runWithCount();
$graph = $queryResult->getQueryRecords();
$record = $graph->records[0] ?? null;
if (empty($record)) {
throw new LucentException("Record id is missing");
}
$newRecordId = (string)Str::uuid();
$newEdgesData = $graph->edges
->filter(fn(Edge $edge) => $edge->source == $recordId)
->values()
->map(function (Edge $edge) use ($newRecordId) {
$edge->source = $newRecordId;
return $edge->toArray();
})->toArray();
$record->id = $newRecordId;
return $this->create(
schemaName: $record->_sys->schema,
data: $record->data->toArray(),
id: $record->id,
file: $record->_file?->toArray() ?? [],
edges: $newEdgesData,
status: "draft"
);
}
public function deleteMany(
array $recordsIds,
): void
{
RecordRepo::deleteMany($recordsIds);
}
/**
* @throws LucentException
* @throws ValidatorException
*/
public function rollback(
string $userId,
string $recordId,
int $version,
): void
{
$revision = $this->revisionService->getByRecordIdAndVersion($recordId, $version)->get();
$this->update(
userId: $userId,
id: $revision->recordId,
data: $revision->data->toArray(),
status: $revision->_sys->status,
updateEdges: false
);
}
public function createEmpty(
Schema $schema,
string $userId,
): Record
{
$defaultValues = $schema->fields->reduce(function ($carry, FieldInterface $f) {
$carry[$f->name] = $f->default ?? null;
return $carry;
}, []);
$formattedData = $this->inputFormatter->fill($schema->name, new RecordData($defaultValues));
return new Record(
id: Id::new(),
_sys: System::newRecord($schema, $userId),
data: $formattedData,
_file: null,
);
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace Lucent\Record;
class RecordStatus
{
private string $value;
public function __construct(
public readonly ?string $status = null,
)
{
if (empty($status)) {
$this->value = "draft";
return;
}
$validStatuses = ["trashed", "published", "draft"];
if (!in_array($status, $validStatuses)) {
$status = "draft";
}
$this->value = $status;
}
public function value(): string
{
return $this->value;
}
}
+71
View File
@@ -0,0 +1,71 @@
<?php
namespace Lucent\Record;
use Carbon\Carbon;
use Lucent\Schema\Schema;
class System
{
function __construct(
public readonly string $schema,
public readonly int $version,
public readonly string $status,
public readonly string $createdBy,
public readonly string $updatedBy,
public readonly string $createdAt,
public readonly string $updatedAt,
)
{
}
public static function fromArray(array $data): System
{
return new System(
schema: data_get($data, "schema"),
version: data_get($data, "version"),
status: data_get($data, "status"),
createdBy: data_get($data, "createdBy"),
updatedBy: data_get($data, "updatedBy"),
createdAt: data_get($data, "createdAt"),
updatedAt: data_get($data, "updatedAt"),
);
}
public static function newRecord(Schema $schema, string $userId, ?string $status = null): System
{
$now = Carbon::now()->toJson();
return new System(
schema: $schema->name,
version: 1,
status: (new RecordStatus($status))->value(),
createdBy: $userId,
updatedBy: $userId,
createdAt: $now,
updatedAt: $now,
);
}
public function update(string $userId, ?string $status = null): System
{
$now = Carbon::now()->toJson();
$newStatus = $status ?? $this->status;
return new System(
schema: $this->schema,
version: $this->version + 1,
status: (new RecordStatus($newStatus))->value(),
createdBy: $this->createdBy,
updatedBy: $userId,
createdAt: $this->createdAt,
updatedAt: $now,
);
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace Lucent\Response;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Log;
use Lucent\LucentException;
use Throwable;
function fail(Throwable|string|array $error, int $code = 400): Response
{
$message = "Something went wrong";
if (is_string($error) || is_array($error)) {
$message = $error;
} elseif ($error instanceof LucentException) {
$message = $error->getMessage();
} elseif ($error instanceof Throwable) {
Log::error($error->getMessage());
}
return response([
"error" => $message
], $code);
}
function ok(array $data = [], int $code = 200): Response
{
return response($data, $code);
}
+75
View File
@@ -0,0 +1,75 @@
<?php
namespace Lucent\Revision;
use Illuminate\Support\Str;
use Lucent\Record\File;
use Lucent\Record\Record;
use Lucent\Record\RecordData;
use Lucent\Record\System;
use stdClass;
readonly class Revision
{
function __construct(
public string $id,
public string $recordId,
public System $_sys,
public RecordData $data,
public ?File $_file = null,
)
{
}
public function toArray(): array
{
return json_decode(json_encode($this), true);
}
public static function fromDB(stdClass $data): Revision
{
$file = json_decode($data->_file, true);
if (!empty($file)) {
$file = new File(...$file);
} else {
$file = null;
}
return new Revision(
id: $data->id,
recordId: $data->recordId,
_sys: System::fromArray(json_decode($data->_sys, true)),
data: new RecordData(json_decode($data->data, true)),
_file: $file,
);
}
public function toDB(): array
{
return [
"id" => $this->id,
"recordId" => $this->recordId,
"_sys" => json_encode($this->_sys),
"_file" => json_encode($this->_file),
"data" => json_encode($this->data),
];
}
public static function fromRecord(Record $record): Revision
{
return new Revision(
id: (string)Str::uuid(),
recordId: $record->id,
_sys: $record->_sys,
data: $record->data,
_file: $record->_file,
);
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace Lucent\Revision;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use function Lucent\Response\ok;
class RevisionController extends Controller
{
public function __construct(
private readonly RevisionRepo $revisionRepo,
)
{
}
public function index(Request $request): Response
{
$revisions = $this->revisionRepo->getByRecordId($request->route("rid"));
return ok($revisions->toArray());
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
namespace Lucent\Revision;
use Illuminate\Support\Facades\DB;
use Lucent\Primitive\Collection;
use PhpOption\Option;
class RevisionRepo
{
private string $table = "revisions";
public function create(Revision $revision): string
{
$revisionDB = $revision->toDB();
DB::table($this->table)->insert($revisionDB);
return $revision->id;
}
/**
* @return Collection<Revision>
**/
public function getByRecordId(string $rid): Collection
{
$revisions = DB::table($this->table)
->where("recordId", $rid)
->get()
->map([Revision::class, 'fromDB'])
->sortByDesc("_sys.version")
->toArray();
return new Collection($revisions);
}
/**
* @return Option<Revision>
*/
public function getByRecordIdAndVersion(string $rid, int $version): Option
{
$res = DB::table($this->table)
->where("recordId", $rid)
->where('_sys->version', $version)->first();
if (empty($res)) {
return none();
}
return some(Revision::fromDB($res));
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace Lucent\Revision;
use Lucent\Record\Record;
use PhpOption\Option;
readonly class RevisionService
{
public function __construct(
private RevisionRepo $revisionRepo,
)
{
}
/**
* @return Option<Revision>
*/
public function getByRecordIdAndVersion(string $recordId, int $version): Option
{
return $this->revisionRepo->getByRecordIdAndVersion($recordId, $version);
}
public function create(Record $record): string
{
$revision = Revision::fromRecord($record);
return $this->revisionRepo->create($revision);
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace Lucent\Schema;
use Lucent\Primitive\Collection;
class CollectionSchema implements Schema
{
public Type $type = Type::COLLECTION;
/**
* @param Collection<FieldInterface> $fields
* @param array<string> $visible
*/
function __construct(
public string $name,
public string $label,
public array $visible,
public Collection $fields,
public bool $isEntry = false,
public string $color = "",
public string $titleTemplate = "",
)
{
}
}
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace Lucent\Schema;
class FieldInfo
{
public function __construct(
public string $name,
public string $label,
public FieldType $type,
)
{
}
}
+11
View File
@@ -0,0 +1,11 @@
<?php
namespace Lucent\Schema;
interface FieldInterface
{
public function format(array $input, array $output): array;
}
+15
View File
@@ -0,0 +1,15 @@
<?php
namespace Lucent\Schema;
enum FieldType: string
{
case STRING = 'string';
case NUMBER = 'number';
case BOOLEAN = 'boolean';
case FILE = 'file';
case JSON = 'json';
case REFERENCE = 'reference';
case TAB = 'tab';
}
+28
View File
@@ -0,0 +1,28 @@
<?php
namespace Lucent\Schema;
use Lucent\Primitive\Collection;
class FilesSchema implements Schema
{
public Type $type = Type::FILES;
/**
* @param Collection<FieldInterface> $fields
*/
function __construct(
public string $name,
public string $label,
public Collection $fields,
public string $path,
public bool $isEntry = false,
public string $color = "",
public string $titleTemplate = "",
)
{
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace Lucent\Schema;
use Lucent\Field\Field;
use Lucent\View\View;
/**
* @return string[]
**/
function visibleFields(Schema $schema, ?View $view = null): array
{
$visibleFieldNames = $schema->visible;
if (!empty($view)) {
$visibleFieldNames = $view->visible;
}
return $schema->fields
->filter(function (Field $f) use ($visibleFieldNames) {
if ($f->trashed || $f->ui == "tab") {
return false;
}
return $visibleFieldNames->contains($f->name->value);
})
->values()
->map(fn(Field $f) => $f->name->value)
->toArray();
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace Lucent\Schema;
final class Nullable
{
public function __construct(
public bool $nullable,
public mixed $value,
public mixed $default,
)
{
}
public function value(): mixed
{
if (!empty($this->value)) {
return $this->value;
}
if ($this->nullable) {
return null;
}
return $this->default;
}
}
+9
View File
@@ -0,0 +1,9 @@
<?php
namespace Lucent\Schema;
interface Schema
{
}
+144
View File
@@ -0,0 +1,144 @@
<?php
namespace Lucent\Schema;
use Lucent\Primitive\Collection;
class SchemaService
{
public function __construct()
{
}
public function fromArray(array $schemaArr): Schema
{
return match ($schemaArr["type"]) {
"collection" => new CollectionSchema(
name: $schemaArr["name"],
label: $schemaArr["label"],
visible: $schemaArr["visible"] ?? [],
fields: (new Collection($schemaArr["fields"]))->map([$this, 'mapFields']),
isEntry: $schemaArr["isEntry"],
color: $schemaArr["color"],
titleTemplate: $schemaArr["titleTemplate"],
),
"files" => new FilesSchema(
name: $schemaArr["name"],
label: $schemaArr["label"],
fields: (new Collection($schemaArr["fields"]))->map([$this, 'mapFields']),
path: $schemaArr["path"],
isEntry: $schemaArr["isEntry"],
color: $schemaArr["color"],
titleTemplate: $schemaArr["titleTemplate"]
)
};
}
public function mapFields(array $field): FieldInterface
{
$className = "\\Lucent\Schema\Ui\\" . ucfirst($field["ui"]);
unset($field["ui"]);
return new $className(...$field);
}
//
// /**
// * @param array<string> $visible
// * @throws LucentException
// */
// public function create(
// string $name,
// string $label,
// string $type,
// bool $isEntry,
// int $revisionRetentionDays,
// int $revisionRetentionNumber,
// int $trashedRetentionDays,
// array $fields,
// string $titleTemplate = "",
// array $visible = [],
// string $path = ""
// ): Schema
// {
// if (empty($name) || empty($label)) {
// throw new LucentException("Name and Label are required");
// }
//
// $newFields = [];
// if (!empty($fields)) {
// $newFields = array_map([Field::class, 'fromArray'], $fields);
// }
//
// $schema = new Schema(
// name: new SchemaName($name),
// label: $label,
// type: Type::from($type),
// visible: new Collection($visible),
// fields: new Collection($newFields),
// isEntry: $isEntry,
// color: "",
// titleTemplate: $titleTemplate,
// views: new Collection(),
// revisionRetentionDays: $revisionRetentionDays,
// revisionRetentionNumber: $revisionRetentionNumber,
// trashedRetentionDays: $trashedRetentionDays,
// path: $path,
// );
//
// $this->schemaRepo->insert($schema);
// return $schema;
//
// }
//
// /**
// * @param array<string> $visible
// * @throws LucentException
// */
// public function update(
// string $name,
// string $label,
// bool $isEntry,
// string $color,
// array $visible,
// string $titleTemplate,
// int $revisionRetentionDays,
// int $revisionRetentionNumber,
// int $trashedRetentionDays,
// string $path = ""
// ): Schema
// {
// if (empty($name) || empty($label)) {
// throw new LucentException("Name and Label are required");
// }
//
//
// $channel = ChannelRepo::current();
// $schema = $channel->schemas->firstWhere("name", $name);
// $schema->label = $label;
// $schema->isEntry = $isEntry;
// $schema->color = $color;
// $schema->visible = new Collection($visible);
// $schema->titleTemplate = $titleTemplate;
// $schema->revisionRetentionDays = $revisionRetentionDays;
// $schema->revisionRetentionNumber = $revisionRetentionNumber;
// $schema->trashedRetentionDays = $trashedRetentionDays;
// $schema->path = $path;
// $this->schemaRepo->update($schema);
// return $schema;
// }
//
// public function delete(string $name): void
// {
// $channel = ChannelRepo::current();
// $schema = $channel->schemas->firstWhere("name", $name);
// if ($schema) {
// $this->schemaRepo->delete($schema);
// }
//
// }
}
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace Lucent\Schema;
enum Type: string
{
case COLLECTION = 'collection';
case FILES = 'files';
}
+44
View File
@@ -0,0 +1,44 @@
<?php
namespace Lucent\Schema\Ui;
use Lucent\Schema\FieldInfo;
use Lucent\Schema\FieldInterface;
use Lucent\Schema\FieldType;
use Lucent\Schema\Nullable;
use Lucent\Schema\Validator\RequiredInterface;
class Block implements FieldInterface, RequiredInterface
{
public FieldInfo $info;
public function __construct(
public string $name,
public string $label,
public bool $nullable = false,
public bool $required = false,
public string $default = "",
public bool $readonly = false,
public string $group = "",
)
{
$this->info = new FieldInfo("block", "Block editor", FieldType::JSON);
}
public function format(array $input, array $output): array
{
$value = $input[$this->name] ?? null;
if (is_string($value)) {
$value = json_decode($value, true);
}
$output[$this->name] = (new Nullable($this->nullable, $value, []))->value();
return $output;
}
public function failRequired(mixed $value): bool
{
return empty($value);
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
namespace Lucent\Schema\Ui;
use Lucent\Schema\FieldInfo;
use Lucent\Schema\FieldInterface;
use Lucent\Schema\FieldType;
use Lucent\Schema\Nullable;
use Lucent\Schema\Validator\RequiredInterface;
use function is_bool;
class Checkbox implements FieldInterface, RequiredInterface
{
public FieldInfo $info;
public function __construct(
public string $name,
public string $label,
public bool $required = false,
public bool $nullable = false,
public string $default = "",
public bool $readonly = false,
public string $group = "",
)
{
$this->info = new FieldInfo("checkbox", "Block Checkbox", FieldType::BOOLEAN);
}
public function format(array $input, array $output): array
{
$value = $input[$this->name] ?? null;
if (is_bool($value)) {
$newValue = $value;
} else {
$newValue = (new Nullable($this->nullable, $value, false))->value();
}
$output[$this->name] = $newValue;
return $output;
}
public function failRequired(mixed $value): bool
{
return (bool)$value !== true;
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
namespace Lucent\Schema\Ui;
use Lucent\Schema\FieldInfo;
use Lucent\Schema\FieldInterface;
use Lucent\Schema\FieldType;
use Lucent\Schema\Nullable;
use Lucent\Schema\Validator\RequiredInterface;
class Color implements FieldInterface, RequiredInterface
{
public FieldInfo $info;
public function __construct(
public string $name,
public string $label,
public bool $required = false,
public bool $nullable = false,
public string $default = "",
public bool $readonly = false,
public string $optionsFrom = "",
public string $optionsField = "",
public bool $optionsSuggest = false,
public string $group = "",
)
{
$this->info = new FieldInfo("color", "Color", FieldType::STRING);
}
public function format(array $input, array $output): array
{
$value = $input[$this->name] ?? null;
$output[$this->name] = (new Nullable($this->nullable, $value, ""))->value();
return $output;
}
public function failRequired(mixed $value): bool
{
return empty(trim($value));
}
}
+73
View File
@@ -0,0 +1,73 @@
<?php
namespace Lucent\Schema\Ui;
use Carbon\Carbon;
use Lucent\Schema\FieldInfo;
use Lucent\Schema\FieldInterface;
use Lucent\Schema\FieldType;
use Lucent\Schema\Nullable;
use Lucent\Schema\Validator\MinMaxInterface;
use Lucent\Schema\Validator\RequiredInterface;
class Date implements FieldInterface, RequiredInterface, MinMaxInterface
{
public FieldInfo $info;
public function __construct(
public string $name,
public string $label,
public bool $required = false,
public bool $nullable = false,
public ?Carbon $min = null,
public ?Carbon $max = null,
public string $default = "",
public bool $readonly = false,
public string $optionsFrom = "",
public string $optionsField = "",
public bool $optionsSuggest = false,
public string $group = "",
)
{
$this->info = new FieldInfo("date", "Date", FieldType::STRING);
}
public function format(array $input, array $output): array
{
$value = $input[$this->name] ?? null;
if (empty($value)) {
$newValue = (new Nullable($this->nullable, null, ""))->value();
} else {
$date = Carbon::parse($value);
$dateFormatted = $date->format("Y-m-d");
$newValue = (new Nullable($this->nullable, $dateFormatted, ""))->value();
}
$output[$this->name] = $newValue;
return $output;
}
public function failRequired(mixed $value): bool
{
return empty(trim($value));
}
public function failMin(mixed $value): bool
{
if (empty($this->value)) {
return false;
}
return $value->lessThanOrEqualTo($this->min);
}
public function failMax(mixed $value): bool
{
if (empty($this->value)) {
return false;
}
return $value->greaterThan($this->max);
}
}

Some files were not shown because too many files have changed in this diff Show More