diff --git a/.gitignore b/.gitignore index c989633..22d225b 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,9 @@ App/config/auth_users.php /App/logs/* !/App/logs/.gitkeep +/App/cache/* +!/App/cache/.gitkeep + **/files/**/* !**/files/**/ !**/files/**/.gitkeep diff --git a/App/Controller/AccountsController.php b/App/Controller/AccountsController.php index c86f255..7e7006d 100644 --- a/App/Controller/AccountsController.php +++ b/App/Controller/AccountsController.php @@ -11,6 +11,7 @@ namespace App\Controller; use App\Classes\Currency; use App\Repositories\AccountRateRepository; use App\Repositories\AccountRepository; +use App\Repositories\GoalRepository; use App\Repositories\UserRepository; use Services\Auth; use System\Classes\Controller; @@ -52,6 +53,12 @@ class AccountsController extends Controller $type_acc = $request->post('type_acc'); $rate_percent = $request->post('rate_percent'); + [$goal_target, $goal_deadline, $goal_error] = $this->parseGoal($type_acc, $request->post('goal_target_summa'), $request->post('goal_deadline')); + + if ($goal_error !== null) { + return $this->json(['success' => false, 'errors' => ['goal_target_summa' => $goal_error]], 422); + } + $data = [ 'user_id' => $user->id, 'type_acc' => $type_acc, @@ -59,14 +66,16 @@ class AccountsController extends Controller 'summa' => $summa, 'currency' => strtoupper((string)$request->post('currency')), 'description' => $request->post('description') !== '' ? $request->post('description') : null, - 'include_in_total' => $request->post('include_in_total') !== null, + 'color' => $request->post('color') !== '' ? $request->post('color') : null, + 'privacy' => in_array($request->post('privacy'), ['shared', 'personal'], true) ? $request->post('privacy') : 'shared', + 'include_in_total' => $request->post('include_in_total') === '1', 'is_delete' => false, 'order' => 0, ]; $account_repo = new AccountRepository(); - $account_repo->transaction(static function () use ($account_repo, $data, $type_acc, $rate_percent) { + $account_repo->transaction(static function () use ($account_repo, $data, $type_acc, $rate_percent, $goal_target, $goal_deadline) { $account_id = $account_repo->create($data); if ($type_acc === 'savings' && $rate_percent !== null && $rate_percent !== '') { @@ -78,6 +87,14 @@ class AccountsController extends Controller ]); } + if ($type_acc === 'savings' && $goal_target !== null && $goal_deadline !== null) { + (new GoalRepository())->create([ + 'account_id' => $account_id, + 'target_summa' => $goal_target, + 'deadline' => $goal_deadline, + ]); + } + return $account_id; }); @@ -100,22 +117,45 @@ class AccountsController extends Controller return $this->json(['success' => false, 'errors' => $validation->errors()], 422); } - $summa = (float)$request->post('summa'); + $summa = (float)$request->post('summa'); + $type_acc = $request->post('type_acc'); if ($summa < 0) { return $this->json(['success' => false, 'errors' => ['summa' => 'Сумма не может быть отрицательной']], 422); } + [$goal_target, $goal_deadline, $goal_error] = $this->parseGoal($type_acc, $request->post('goal_target_summa'), $request->post('goal_deadline')); + + if ($goal_error !== null) { + return $this->json(['success' => false, 'errors' => ['goal_target_summa' => $goal_error]], 422); + } + $account_repo->update([ 'id' => $account->id, - 'type_acc' => $request->post('type_acc'), + 'type_acc' => $type_acc, 'title' => $request->post('title'), 'summa' => $summa, 'currency' => strtoupper((string)$request->post('currency')), 'description' => $request->post('description') !== '' ? $request->post('description') : null, - 'include_in_total' => $request->post('include_in_total') !== null, + 'color' => $request->post('color') !== '' ? $request->post('color') : null, + 'privacy' => in_array($request->post('privacy'), ['shared', 'personal'], true) ? $request->post('privacy') : 'shared', + 'include_in_total' => $request->post('include_in_total') === '1', ]); + $goal_repo = new GoalRepository(); + $goal = $goal_repo->getByAccountId($account->id); + + if ($type_acc === 'savings' && $goal_target !== null && $goal_deadline !== null) { + if ($goal) { + $goal_repo->update(['id' => $goal->id, 'target_summa' => $goal_target, 'deadline' => $goal_deadline]); + } else { + $goal_repo->create(['account_id' => $account->id, 'target_summa' => $goal_target, 'deadline' => $goal_deadline]); + } + } elseif ($goal) { + // Тип сменили с savings, либо оба поля цели очистили в форме — цель больше не нужна. + $goal_repo->delete($goal->id); + } + return $this->json(['success' => true]); } @@ -153,6 +193,40 @@ class AccountsController extends Controller return $this->json(['success' => true]); } + /** + * Сохраняет ручной порядок карточек счетов (режим drag&drop на дашборде, см. + * App/media/js/accounts.js) — id[] уже в новом порядке, просто проставляем order по позиции + * в массиве. Чужие/несуществующие id молча пропускаются (не валят весь запрос), это не + * критичная операция — максимум, что может пойти не так, порядок не сохранится полностью. + * + * @return string JSON + * @throws MyException + */ + public function reorderAction(): string + { + $request = Request::$current; + $user = $this->currentUser(); + $account_repo = new AccountRepository(); + + $ids = $request->post('ids'); + + if (!is_array($ids)) { + return $this->json(['success' => false, 'errors' => ['ids' => 'Некорректный список']], 422); + } + + foreach (array_values($ids) as $position => $id) { + $account = $account_repo->get((int)$id); + + if (!$account || (int)$account->user_id !== (int)$user->id) { + continue; + } + + $account_repo->update(['id' => $account->id, 'order' => $position]); + } + + return $this->json(['success' => true]); + } + /** * Общие правила валидации формы счёта (create/edit). * @@ -174,7 +248,45 @@ class AccountsController extends Controller ->rule('summa', 'numeric') ->rule('currency', 'required') ->rule('currency', 'in', [array_keys(Currency::LIST)]) - ->rule('description', 'max_length', [255]); + ->rule('description', 'max_length', [255]) + ->rule('color', 'max_length', [20]) + ->rule('goal_target_summa', 'numeric') + ->rule('goal_deadline', 'regex', ['/^\d{4}-\d{2}-\d{2}$/']); + } + + /** + * «Цель накопления» + «Дата цели» — необязательная пара полей формы счёта, доступна только + * для type_acc=savings (см. дизайн-хендофф, App/design_handoff_personal_finance). Оба поля + * заполняются вместе — тот же паттерн, что и формульная связь категорий + * (App\Controller\CategoriesController::parseFormulaLink). + * + * @param string|null $type_acc + * @param mixed $raw_target + * @param mixed $raw_deadline + * @return array{0:float|null,1:string|null,2:string|null} [target_summa, deadline, error] + */ + protected function parseGoal(?string $type_acc, mixed $raw_target, mixed $raw_deadline): array + { + $has_target = $raw_target !== null && $raw_target !== ''; + $has_deadline = $raw_deadline !== null && $raw_deadline !== ''; + + if (!$has_target && !$has_deadline) { + return [null, null, null]; + } + + if ($type_acc !== 'savings') { + return [null, null, 'Цель доступна только для накопительного счёта']; + } + + if ($has_target !== $has_deadline) { + return [null, null, 'Укажите и сумму цели, и дату']; + } + + if (!is_numeric($raw_target) || (float)$raw_target <= 0) { + return [null, null, 'Сумма цели должна быть больше 0']; + } + + return [(float)$raw_target, (string)$raw_deadline, null]; } /** diff --git a/App/Controller/AnalyticsController.php b/App/Controller/AnalyticsController.php new file mode 100644 index 0000000..1965c3b --- /dev/null +++ b/App/Controller/AnalyticsController.php @@ -0,0 +1,71 @@ +getUser(); + $user = (new UserRepository())->getByLogin($auth_user['login']); + + if (!$user) { + throw HTTPException::factory(404); + } + + $year = (int)(Request::$current->query('year') ?: date('Y')); + $month = (int)date('n'); + + $tx_repo = new TransactionRepository(); + $budget_repo = new BudgetRepository(); + + $monthly = $tx_repo->getMonthlyTotals($user->id, $year); + $monthly_prev = $tx_repo->getMonthlyTotals($user->id, $year - 1); + $expense_cats = $tx_repo->getMonthExpenseByCategory($user->id, $year, $month); + + // План текущего месяца по тем же expense-категориям, что и факт (для панели «План vs факт»). + $plan_year = $budget_repo->getYear($user->id, $year); + $plan_month = []; + + foreach ($expense_cats as $cat) { + $plan_month[(int)$cat->categorie_id] = $plan_year[(int)$cat->categorie_id][$month]['plan_summ'] ?? 0.0; + } + + return $this->render('index', [ + 'user' => $auth_user, + 'year' => $year, + 'month' => $month, + 'monthly' => $monthly, + 'monthly_prev' => $monthly_prev, + 'expense_cats' => $expense_cats, + 'plan_month' => $plan_month, + ]); + } +} diff --git a/App/Controller/BudgetsController.php b/App/Controller/BudgetsController.php index 5b8d5b5..8fe4db8 100644 --- a/App/Controller/BudgetsController.php +++ b/App/Controller/BudgetsController.php @@ -9,6 +9,7 @@ namespace App\Controller; use App\Repositories\BudgetRepository; +use App\Repositories\CategoryGroupRepository; use App\Repositories\CategoryRepository; use App\Repositories\UserRepository; use Services\Auth; @@ -36,16 +37,22 @@ class BudgetsController extends Controller $year = (int)(Request::$current->query('year') ?: (int)date('Y')); $category_repo = new CategoryRepository(); + $group_repo = new CategoryGroupRepository(); $budget_repo = new BudgetRepository(); - $tree = $category_repo->getTree($user->id); - $plan = $budget_repo->getYear($user->id, $year); + $tree = $category_repo->getTree($user->id); + $groups = $group_repo->getForUser($user->id); + $plan = $budget_repo->getYear($user->id, $year); + $archived = $category_repo->getList('*', ['user_id' => $user->id, 'is_delete' => 1], 'title'); + $archived_groups = $group_repo->getList('*', ['user_id' => $user->id, 'is_delete' => 1], 'title'); return $this->render('budgets', [ - 'tree' => $tree, - 'groups' => $tree[0] ?? [], - 'plan' => $plan, - 'year' => $year, + 'tree' => $tree, + 'groups' => $groups, + 'plan' => $plan, + 'year' => $year, + 'archived' => $archived ?: [], + 'archived_groups' => $archived_groups ?: [], ]); } diff --git a/App/Controller/CategoriesController.php b/App/Controller/CategoriesController.php index 720dbd2..5f413ac 100644 --- a/App/Controller/CategoriesController.php +++ b/App/Controller/CategoriesController.php @@ -8,6 +8,7 @@ namespace App\Controller; +use App\Repositories\CategoryGroupRepository; use App\Repositories\CategoryRepository; use App\Repositories\UserRepository; use Services\Auth; @@ -18,31 +19,15 @@ use System\Classes\Request; use System\Classes\Validation; /** - * CRUD статей (categories) — доходы/расходы с группами (parent_id). Своя страница /categories - * (в отличие от счетов, для которых достаточно модалки на дашборде — см. AccountsController). + * CRUD статей (categories) — доходы/расходы, опционально в группе (group_id, см. + * App\Controller\CategoryGroupsController — группа теперь отдельная сущность, не статья с + * детьми). JSON API, вызывается ajax'ом из модалки «Статьи» на странице /budgets + * (см. App/view/Budgets/budgets.html, App/media/js/categories.js). */ class CategoriesController extends Controller { protected bool $_auth_protection = true; - /** - * @return string - * @throws MyException - */ - public function indexAction(): string - { - $user = $this->currentUser(); - $category_repo = new CategoryRepository(); - - $tree = $category_repo->getTree($user->id); - $archived = $category_repo->getList('*', ['user_id' => $user->id, 'is_delete' => 1], 'title'); - - return $this->render('categories', [ - 'tree' => $tree, - 'archived' => $archived ?: [], - ]); - } - /** * @return string JSON * @throws MyException @@ -62,10 +47,10 @@ class CategoriesController extends Controller $type = $request->post('type'); $title = $request->post('title'); - $parent_id = $this->parseParentId($category_repo, $user, $type, $request->post('parent_id')); + $group_id = $this->parseGroupId($user, $type, $request->post('group_id')); - if ($parent_id === false) { - return $this->json(['success' => false, 'errors' => ['parent_id' => 'Родительская статья недоступна']], 422); + if ($group_id === false) { + return $this->json(['success' => false, 'errors' => ['group_id' => 'Группа недоступна']], 422); } [$linked_id, $formula_pct, $link_error] = $this->parseFormulaLink( @@ -76,11 +61,11 @@ class CategoriesController extends Controller return $this->json(['success' => false, 'errors' => ['linked_expense_categorie_id' => $link_error]], 422); } - if ($category_repo->findActiveDuplicate($user->id, $type, $parent_id, $title)) { + if ($category_repo->findActiveDuplicate($user->id, $type, $group_id, $title)) { return $this->json(['success' => false, 'errors' => ['title' => 'Такая статья уже есть']], 422); } - $archived_duplicate = $category_repo->findArchivedDuplicate($user->id, $type, $parent_id, $title); + $archived_duplicate = $category_repo->findArchivedDuplicate($user->id, $type, $group_id, $title); if ($archived_duplicate) { return $this->json([ @@ -91,7 +76,7 @@ class CategoriesController extends Controller $category_repo->create([ 'user_id' => $user->id, - 'parent_id' => $parent_id, + 'group_id' => $group_id, 'title' => $title, 'type' => $type, 'is_delete' => false, @@ -123,10 +108,10 @@ class CategoriesController extends Controller $type = $request->post('type'); $title = $request->post('title'); - $parent_id = $this->parseParentId($category_repo, $user, $type, $request->post('parent_id')); + $group_id = $this->parseGroupId($user, $type, $request->post('group_id')); - if ($parent_id === false || $parent_id === (int)$category->id) { - return $this->json(['success' => false, 'errors' => ['parent_id' => 'Родительская статья недоступна']], 422); + if ($group_id === false) { + return $this->json(['success' => false, 'errors' => ['group_id' => 'Группа недоступна']], 422); } [$linked_id, $formula_pct, $link_error] = $this->parseFormulaLink( @@ -137,11 +122,11 @@ class CategoriesController extends Controller return $this->json(['success' => false, 'errors' => ['linked_expense_categorie_id' => $link_error]], 422); } - if ($category_repo->findActiveDuplicate($user->id, $type, $parent_id, $title, (int)$category->id)) { + if ($category_repo->findActiveDuplicate($user->id, $type, $group_id, $title, (int)$category->id)) { return $this->json(['success' => false, 'errors' => ['title' => 'Такая статья уже есть']], 422); } - $archived_duplicate = $category_repo->findArchivedDuplicate($user->id, $type, $parent_id, $title, (int)$category->id); + $archived_duplicate = $category_repo->findArchivedDuplicate($user->id, $type, $group_id, $title, (int)$category->id); if ($archived_duplicate) { return $this->json([ @@ -152,7 +137,7 @@ class CategoriesController extends Controller $category_repo->update([ 'id' => $category->id, - 'parent_id' => $parent_id, + 'group_id' => $group_id, 'title' => $title, 'type' => $type, 'linked_expense_categorie_id' => $linked_id, @@ -217,27 +202,29 @@ class CategoriesController extends Controller } /** - * Родительская статья (группа) из сырого значения формы — с проверкой владения/уровня/типа. + * Группа статьи из сырого значения формы — с проверкой владения/типа. Группа теперь + * самостоятельная сущность (App\Repositories\CategoryGroupRepository), поэтому, в отличие от + * старой parent_id-модели, тут не нужно отдельно проверять "родитель не должен сам быть + * подстатьёй" — group_id физически не может указывать ни на что, кроме реальной группы. * - * @param CategoryRepository $category_repo - * @param object $user - * @param string $type Тип создаваемой/правимой статьи — родитель должен совпадать - * @param mixed $raw Сырое значение поля parent_id - * @return int|false|null null — верхний уровень (родителя нет); false — родитель недопустим + * @param object $user + * @param string $type Тип статьи — группа должна совпадать + * @param mixed $raw Сырое значение поля group_id + * @return int|false|null null — без группы; false — группа недоступна */ - protected function parseParentId(CategoryRepository $category_repo, object $user, string $type, mixed $raw): int|false|null + protected function parseGroupId(object $user, string $type, mixed $raw): int|false|null { if ($raw === null || $raw === '') { return null; } - $parent = $category_repo->get((int)$raw); + $group = (new CategoryGroupRepository())->get((int)$raw); - if (!$parent || (int)$parent->user_id !== (int)$user->id || $parent->parent_id !== null || $parent->type !== $type) { + if (!$group || (int)$group->user_id !== (int)$user->id || $group->type !== $type || $group->is_delete) { return false; } - return (int)$parent->id; + return (int)$group->id; } /** diff --git a/App/Controller/CategoryGroupsController.php b/App/Controller/CategoryGroupsController.php new file mode 100644 index 0000000..d34c309 --- /dev/null +++ b/App/Controller/CategoryGroupsController.php @@ -0,0 +1,200 @@ +currentUser(); + $group_repo = new CategoryGroupRepository(); + + $validation = $this->buildValidation($request->post()); + + if (!$validation->check()) { + return $this->json(['success' => false, 'errors' => $validation->errors()], 422); + } + + $type = $request->post('type'); + $title = $request->post('title'); + + if ($group_repo->findActiveDuplicate($user->id, $type, $title)) { + return $this->json(['success' => false, 'errors' => ['title' => 'Такая группа уже есть']], 422); + } + + $archived_duplicate = $group_repo->findArchivedDuplicate($user->id, $type, $title); + + if ($archived_duplicate) { + return $this->json([ + 'success' => false, + 'archived' => ['id' => $archived_duplicate->id, 'title' => $archived_duplicate->title], + ], 422); + } + + $group_repo->create([ + 'user_id' => $user->id, + 'title' => $title, + 'type' => $type, + 'is_delete' => false, + 'order' => 0, + ]); + + return $this->json(['success' => true]); + } + + /** + * @return string JSON + * @throws HTTPException|MyException + */ + public function editAction(): string + { + $request = Request::$current; + $user = $this->currentUser(); + $group_repo = new CategoryGroupRepository(); + $group = $this->ownGroup($group_repo, $user, (int)$request->param(0)); + + $validation = $this->buildValidation($request->post()); + + if (!$validation->check()) { + return $this->json(['success' => false, 'errors' => $validation->errors()], 422); + } + + $type = $request->post('type'); + $title = $request->post('title'); + + if ($group_repo->findActiveDuplicate($user->id, $type, $title, (int)$group->id)) { + return $this->json(['success' => false, 'errors' => ['title' => 'Такая группа уже есть']], 422); + } + + $archived_duplicate = $group_repo->findArchivedDuplicate($user->id, $type, $title, (int)$group->id); + + if ($archived_duplicate) { + return $this->json([ + 'success' => false, + 'archived' => ['id' => $archived_duplicate->id, 'title' => $archived_duplicate->title], + ], 422); + } + + $group_repo->update(['id' => $group->id, 'title' => $title, 'type' => $type]); + + return $this->json(['success' => true]); + } + + /** + * Архивация группы. Статьи внутри — не архивируются автоматически, а просто становятся без + * группы (group_id = NULL): архивация группы не должна "тихо" утаскивать за собой все статьи — + * это уже было бы массовым удалением без явного подтверждения. ON DELETE SET NULL в схеме + * сработал бы так же, но только при физическом удалении группы, а тут — архивация (is_delete), + * поэтому group_id у статей чистим явно сами. + * + * @return string JSON + * @throws HTTPException|MyException + */ + public function deleteAction(): string + { + $request = Request::$current; + $user = $this->currentUser(); + $group_repo = new CategoryGroupRepository(); + $group = $this->ownGroup($group_repo, $user, (int)$request->param(0)); + + $group_repo->update(['id' => $group->id, 'is_delete' => true]); + (new CategoryRepository())->clearGroup((int)$group->id, (int)$user->id); + + return $this->json(['success' => true]); + } + + /** + * Возврат группы из архива. + * + * @return string JSON + * @throws HTTPException|MyException + */ + public function restoreAction(): string + { + $request = Request::$current; + $user = $this->currentUser(); + $group_repo = new CategoryGroupRepository(); + $group = $this->ownGroup($group_repo, $user, (int)$request->param(0)); + + $group_repo->update(['id' => $group->id, 'is_delete' => false]); + + return $this->json(['success' => true]); + } + + /** + * @param array $data + * @return Validation + */ + protected function buildValidation(array $data): Validation + { + return Validation::factory($data) + ->label('title', 'Название') + ->label('type', 'Тип') + ->rule('title', 'required') + ->rule('title', 'max_length', [128]) + ->rule('type', 'required') + ->rule('type', 'in', [['income', 'expense']]); + } + + /** + * @return object + * @throws HTTPException|MyException + */ + protected function currentUser(): object + { + $auth_user = Auth::instance()->getUser(); + $user = (new UserRepository())->getByLogin($auth_user['login']); + + if (!$user) { + throw HTTPException::factory(404); + } + + return $user; + } + + /** + * @param CategoryGroupRepository $group_repo + * @param object $user + * @param int $id + * @return object + * @throws HTTPException|MyException + */ + protected function ownGroup(CategoryGroupRepository $group_repo, object $user, int $id): object + { + $group = $group_repo->get($id); + + if (!$group || (int)$group->user_id !== (int)$user->id) { + throw HTTPException::factory(404); + } + + return $group; + } +} diff --git a/App/Controller/GoalsController.php b/App/Controller/GoalsController.php new file mode 100644 index 0000000..959c100 --- /dev/null +++ b/App/Controller/GoalsController.php @@ -0,0 +1,70 @@ +currentUser(); + $goal_repo = new GoalRepository(); + $goal = $goal_repo->get((int)$request->param(0)); + + if (!$goal) { + throw HTTPException::factory(404); + } + + $account = (new AccountRepository())->get((int)$goal->account_id); + + if (!$account || (int)$account->user_id !== (int)$user->id) { + throw HTTPException::factory(404); + } + + $goal_repo->delete($goal->id); + + return $this->json(['success' => true]); + } + + /** + * @return object + * @throws HTTPException|MyException + */ + protected function currentUser(): object + { + $auth_user = Auth::instance()->getUser(); + $user = (new UserRepository())->getByLogin($auth_user['login']); + + if (!$user) { + throw HTTPException::factory(404); + } + + return $user; + } +} diff --git a/App/Controller/IndexController.php b/App/Controller/IndexController.php index 63214bb..ff9c555 100644 --- a/App/Controller/IndexController.php +++ b/App/Controller/IndexController.php @@ -10,11 +10,13 @@ namespace App\Controller; use App\Repositories\AccountRepository; use App\Repositories\BudgetRepository; +use App\Repositories\CategoryGroupRepository; use App\Repositories\CategoryRepository; use App\Repositories\GoalRepository; use App\Repositories\TransactionRepository; use App\Repositories\UserRepository; use Services\Auth; +use Services\CurrencyRate; use System\Classes\Controller; use System\Classes\MyException; @@ -39,19 +41,36 @@ class IndexController extends Controller $accounts = $user ? $account_repo->getList('*', ['user_id' => $user->id, 'is_delete' => 0], '`order`') : []; $archived_accounts = $user ? $account_repo->getList('*', ['user_id' => $user->id, 'is_delete' => 1], 'title') : []; + // Курс ЦБ РФ на не-RUB счета — Services\CurrencyRate; при недоступности источника rate() + // возвращает null для конкретного кода, тогда счёт просто не участвует в переводе в рубли + // (не показываем непересчитанную сумму как есть — это исказило бы «Доступно сейчас»). + $rate_service = CurrencyRate::instance(); + $rates = []; + + foreach ($accounts ?: [] as $account) { + if ($account->currency !== 'RUB' && !array_key_exists($account->currency, $rates)) { + $rates[$account->currency] = $rate_service->rate($account->currency); + } + } + + $rubValue = static function (object $account) use ($rates): float { + if ($account->currency === 'RUB') { + return (float)$account->summa; + } + + $rate = $rates[$account->currency] ?? null; + + return $rate !== null ? (float)$account->summa * $rate : 0.0; + }; + $total = array_sum(array_map( - static fn ($account) => $account->include_in_total && $account->currency === 'RUB' ? (float)$account->summa : 0, + static fn ($account) => $account->include_in_total ? $rubValue($account) : 0, $accounts ?: [] )); // «Всего по всем счетам» — без фильтра include_in_total, для строки под «Доступно сейчас» - // (см. дизайн-хендофф App/design_handoff_personal_finance). Валютные счета — та же - // сознательная недоделка, что и у $total выше: без Services\CurrencyRate конвертировать - // не во что, поэтому считаем только RUB (не показываем непересчитанную сумму как есть). - $total_all = array_sum(array_map( - static fn ($account) => $account->currency === 'RUB' ? (float)$account->summa : 0, - $accounts ?: [] - )); + // (см. дизайн-хендофф App/design_handoff_personal_finance). + $total_all = array_sum(array_map($rubValue, $accounts ?: [])); $month = (int)date('n'); $year = (int)date('Y'); @@ -60,6 +79,7 @@ class IndexController extends Controller $budget_repo = new BudgetRepository(); $tree = $user ? $category_repo->getTree($user->id) : []; + $groups = $user ? (new CategoryGroupRepository())->getForUser($user->id) : []; $plan_month = $user ? array_map( static fn ($months) => $months[$month]['plan_summ'] ?? 0.0, $budget_repo->getYear($user->id, $year) @@ -86,6 +106,16 @@ class IndexController extends Controller } $pct_expense = $plan_expense > 0 ? round($fact_expense / $plan_expense * 100) : null; + $fact_income = 0.0; + foreach ($tree as $items) { + foreach ($items as $c) { + if ($c->type === 'income') { + $fact_income += $fact_month[(int)$c->id] ?? 0.0; + } + } + } + $month_delta = $fact_income - $fact_expense; + $goals = $user ? (new GoalRepository())->getForUser($user->id) : []; return $this->render('index', [ @@ -95,11 +125,23 @@ class IndexController extends Controller 'total' => $total, 'total_all' => $total_all, 'tree' => $tree, + 'groups' => $groups, 'plan_month' => $plan_month, 'fact_month' => $fact_month, 'pct_expense' => $pct_expense, + 'month_delta' => $month_delta, 'goals' => $goals ?: [], 'month' => $month, + 'rates' => $rates, ]); } + + public function testAction(): string + { + return str_replace( + ['href="nocturne.css"', 'src="./support.js"'], + ['href="/App/design_handoff_personal_finance/nocturne.css"', ''], + file_get_contents(APPPATH . '/design_handoff_personal_finance/Личные финансы.dc.html') + ); + } } diff --git a/App/Controller/SettingsController.php b/App/Controller/SettingsController.php new file mode 100644 index 0000000..2fca675 --- /dev/null +++ b/App/Controller/SettingsController.php @@ -0,0 +1,42 @@ +getUser(); + $user = (new UserRepository())->getByLogin($auth_user['login']); + + if (!$user) { + throw HTTPException::factory(404); + } + + return $this->render('index', [ + 'auth_user' => $auth_user, + 'user' => $user, + ]); + } +} diff --git a/App/Controller/TransactionsController.php b/App/Controller/TransactionsController.php new file mode 100644 index 0000000..264fa86 --- /dev/null +++ b/App/Controller/TransactionsController.php @@ -0,0 +1,45 @@ +getUser(); + $user = (new UserRepository())->getByLogin($auth_user['login']); + + if (!$user) { + throw HTTPException::factory(404); + } + + $transactions = (new TransactionRepository())->getRecentForUser($user->id, 50); + + return $this->render('index', [ + 'user' => $auth_user, + 'transactions' => $transactions, + ]); + } +} diff --git a/App/Repositories/CategoryGroupRepository.php b/App/Repositories/CategoryGroupRepository.php new file mode 100644 index 0000000..85183fd --- /dev/null +++ b/App/Repositories/CategoryGroupRepository.php @@ -0,0 +1,90 @@ +findDuplicate($user_id, $type, $title, false, $exclude_id); + } + + /** + * Архивная (is_delete=1) группа с тем же названием у пользователя. + * + * @param int $user_id + * @param string $type + * @param string $title + * @param int|null $exclude_id + * @return object|false + */ + public function findArchivedDuplicate(int $user_id, string $type, string $title, ?int $exclude_id = null): mixed + { + return $this->findDuplicate($user_id, $type, $title, true, $exclude_id); + } + + /** + * @param int $user_id + * @param string $type + * @param string $title + * @param bool $is_delete + * @param int|null $exclude_id + * @return object|false + */ + protected function findDuplicate(int $user_id, string $type, string $title, bool $is_delete, ?int $exclude_id): mixed + { + $pdo = $this->pdo->pdo(); + + $where = "user_id = $user_id" + . ' AND type = ' . $pdo->quote($type) + . ' AND is_delete = ' . ($is_delete ? 1 : 0) + . ' AND title = ' . $pdo->quote($title); + + if ($exclude_id !== null) { + $where .= " AND id != $exclude_id"; + } + + return $this->getItemWhere($where); + } + + /** + * Активные группы пользователя. + * + * @param int $user_id + * @return object[] + */ + public function getForUser(int $user_id): array + { + return $this->getList('*', ['user_id' => $user_id, 'is_delete' => 0], '`order`') ?: []; + } +} diff --git a/App/Repositories/CategoryRepository.php b/App/Repositories/CategoryRepository.php index 06c9369..983d4aa 100644 --- a/App/Repositories/CategoryRepository.php +++ b/App/Repositories/CategoryRepository.php @@ -11,7 +11,8 @@ namespace App\Repositories; use System\Classes\Repository; /** - * Репозиторий таблицы categories (статьи income/expense, с подкатегориями через parent_id). + * Репозиторий таблицы categories (статьи income/expense; группа — отдельная сущность, + * см. App\Repositories\CategoryGroupRepository, статья ссылается на неё через group_id). */ class CategoryRepository extends Repository { @@ -24,18 +25,18 @@ class CategoryRepository extends Repository } /** - * Активная (is_delete=0) статья с тем же названием у пользователя, в рамках type+parent_id. + * Активная (is_delete=0) статья с тем же названием у пользователя, в рамках type+group_id. * * @param int $user_id * @param string $type income|expense - * @param int|null $parent_id null — верхний уровень + * @param int|null $group_id null — без группы * @param string $title * @param int|null $exclude_id Исключить этот id (правка своего же названия — не дубль) * @return object|false */ - public function findActiveDuplicate(int $user_id, string $type, ?int $parent_id, string $title, ?int $exclude_id = null): mixed + public function findActiveDuplicate(int $user_id, string $type, ?int $group_id, string $title, ?int $exclude_id = null): mixed { - return $this->findDuplicate($user_id, $type, $parent_id, $title, false, $exclude_id); + return $this->findDuplicate($user_id, $type, $group_id, $title, false, $exclude_id); } /** @@ -43,26 +44,26 @@ class CategoryRepository extends Repository * * @param int $user_id * @param string $type income|expense - * @param int|null $parent_id + * @param int|null $group_id * @param string $title * @param int|null $exclude_id * @return object|false */ - public function findArchivedDuplicate(int $user_id, string $type, ?int $parent_id, string $title, ?int $exclude_id = null): mixed + public function findArchivedDuplicate(int $user_id, string $type, ?int $group_id, string $title, ?int $exclude_id = null): mixed { - return $this->findDuplicate($user_id, $type, $parent_id, $title, true, $exclude_id); + return $this->findDuplicate($user_id, $type, $group_id, $title, true, $exclude_id); } /** * @param int $user_id * @param string $type - * @param int|null $parent_id + * @param int|null $group_id * @param string $title * @param bool $is_delete * @param int|null $exclude_id * @return object|false */ - protected function findDuplicate(int $user_id, string $type, ?int $parent_id, string $title, bool $is_delete, ?int $exclude_id): mixed + protected function findDuplicate(int $user_id, string $type, ?int $group_id, string $title, bool $is_delete, ?int $exclude_id): mixed { $pdo = $this->pdo->pdo(); @@ -70,7 +71,7 @@ class CategoryRepository extends Repository . ' AND type = ' . $pdo->quote($type) . ' AND is_delete = ' . ($is_delete ? 1 : 0) . ' AND title = ' . $pdo->quote($title) - . ' AND parent_id ' . ($parent_id === null ? 'IS NULL' : "= $parent_id"); + . ' AND group_id ' . ($group_id === null ? 'IS NULL' : "= $group_id"); if ($exclude_id !== null) { $where .= " AND id != $exclude_id"; @@ -80,8 +81,8 @@ class CategoryRepository extends Repository } /** - * Активные статьи пользователя, сгруппированные по parent_id (0 — верхний уровень, вместо null, - * чтобы использовать как ключ массива) — для рендера дерева на странице статей. + * Активные статьи пользователя, сгруппированные по group_id (0 — без группы, вместо null, + * чтобы использовать как ключ массива) — для рендера списка статей по группам. * * @param int $user_id * @return array @@ -92,10 +93,25 @@ class CategoryRepository extends Repository $tree = []; foreach ($categories ?: [] as $category) { - $key = $category->parent_id === null ? 0 : (int)$category->parent_id; + $key = $category->group_id === null ? 0 : (int)$category->group_id; $tree[$key][] = $category; } return $tree; } + + /** + * Отвязать все статьи пользователя от группы (group_id = NULL) — используется при архивации + * группы (App\Controller\CategoryGroupsController::deleteAction), не при её физическом + * удалении (то делает fk_categories_group ... ON DELETE SET NULL само). + * + * @param int $group_id + * @param int $user_id + * @return void + */ + public function clearGroup(int $group_id, int $user_id): void + { + $this->pdo->prepare("UPDATE $this->table SET group_id = NULL WHERE group_id = :group_id AND user_id = :user_id") + ->execute(['group_id' => $group_id, 'user_id' => $user_id]); + } } diff --git a/App/Repositories/GoalRepository.php b/App/Repositories/GoalRepository.php index db6b8c4..7d23ad9 100644 --- a/App/Repositories/GoalRepository.php +++ b/App/Repositories/GoalRepository.php @@ -51,4 +51,15 @@ class GoalRepository extends Repository // Model — и так дефолт конструктора Repository, GoalRepository его не переопределяет. return $stmt->fetchAll(PDO::FETCH_CLASS, Model::class); } + + /** + * Цель конкретного savings-счёта (у счёта максимум одна цель — uq_goals_account_id). + * + * @param int $account_id + * @return object|false|null + */ + public function getByAccountId(int $account_id): object|false|null + { + return $this->getItemWhere("account_id = " . $account_id); + } } diff --git a/App/Repositories/TransactionRepository.php b/App/Repositories/TransactionRepository.php index ee5267e..816d5b5 100644 --- a/App/Repositories/TransactionRepository.php +++ b/App/Repositories/TransactionRepository.php @@ -24,6 +24,82 @@ class TransactionRepository extends Repository parent::__construct('transactions', connection: $connection); } + /** + * Последние N транзакций пользователя с джойнами на категорию и счета. + * + * @param int $user_id + * @param int $limit + * @return object[] + */ + public function getRecentForUser(int $user_id, int $limit = 50): array + { + $limit = (int)$limit; + $sql = "SELECT t.id, t.date, t.type, t.summa, t.currency, t.rate_to_rub, + c.title AS category_title, + af.title AS account_f_title, + ai.title AS account_in_title + FROM {$this->table} t + LEFT JOIN categories c ON c.id = t.categorie_id + LEFT JOIN accounts af ON af.id = t.account_f_id + LEFT JOIN accounts ai ON ai.id = t.account_in_id + WHERE t.user_id = :user_id + ORDER BY t.date DESC, t.id DESC + LIMIT {$limit}"; + + $stmt = $this->pdo->prepare($sql); + $stmt->execute(['user_id' => $user_id]); + return $stmt->fetchAll(PDO::FETCH_OBJ); + } + + /** + * Агрегат income/expense по месяцам за год. + * + * @param int $user_id + * @param int $year + * @return array> [month => ['income'=>..., 'expense'=>...]] + */ + public function getMonthlyTotals(int $user_id, int $year): array + { + $sql = "SELECT MONTH(date) AS month, type, SUM(summa * COALESCE(rate_to_rub, 1)) AS total + FROM {$this->table} + WHERE user_id = :user_id AND YEAR(date) = :year AND type IN ('income','expense') + GROUP BY MONTH(date), type + ORDER BY MONTH(date)"; + + $stmt = $this->pdo->prepare($sql); + $stmt->execute(['user_id' => $user_id, 'year' => $year]); + + $result = []; + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { + $result[(int)$row['month']][$row['type']] = (float)$row['total']; + } + return $result; + } + + /** + * Расходы за месяц по категориям (топ 8) — для кругового распределения на аналитике. + * + * @param int $user_id + * @param int $year + * @param int $month + * @return object[] [{title, total}] + */ + public function getMonthExpenseByCategory(int $user_id, int $year, int $month): array + { + $sql = "SELECT c.id AS categorie_id, c.title, SUM(t.summa * COALESCE(t.rate_to_rub, 1)) AS total + FROM {$this->table} t + JOIN categories c ON c.id = t.categorie_id + WHERE t.user_id = :user_id AND t.type = 'expense' + AND YEAR(t.date) = :year AND MONTH(t.date) = :month + GROUP BY c.id, c.title + ORDER BY total DESC + LIMIT 8"; + + $stmt = $this->pdo->prepare($sql); + $stmt->execute(['user_id' => $user_id, 'year' => $year, 'month' => $month]); + return $stmt->fetchAll(PDO::FETCH_OBJ); + } + /** * Факт за месяц по статьям — сумма реальных операций (income/expense, не transfer — у тех * categorie_id = null), в рублях (см. бюджет_текущий_план.md — summa * COALESCE(rate_to_rub, 1)). diff --git a/App/cache/.gitkeep b/App/cache/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/App/config/config.php b/App/config/config.php index cff98bb..9b7b308 100644 --- a/App/config/config.php +++ b/App/config/config.php @@ -9,8 +9,8 @@ return [ 'db' => [ 'default' => [ 'dialect' => 'mysql', // mysql|pgsql|sqlite — какой DSN собирает PdoConnection::buildDsn() - 'host' => '192.168.1.36', - 'port' => 3307, + 'host' => 'db', + 'port' => 3306, 'user' => 'root', 'password' => 'qwerty', 'dbname' => 'budget' @@ -33,6 +33,15 @@ return [ ], ], + 'currency_rate' => [ + 'default' => [ + 'url' => 'https://www.cbr.ru/scripts/XML_daily.asp', + 'timeout' => 10, + 'cache_path' => APPPATH . '/cache/currency_rate.json', + 'ttl' => 86400, + ], + ], + 'session' => [ 'cookie_params' => [ 'lifetime' => 0, diff --git a/App/design_handoff_personal_finance/README.md b/App/design_handoff_personal_finance/README.md deleted file mode 100644 index 97eda97..0000000 --- a/App/design_handoff_personal_finance/README.md +++ /dev/null @@ -1,34 +0,0 @@ -# Handoff: Личные финансы (Dashboard / План на год / Аналитика) - -## Overview -Адаптивный (комп/планшет/телефон) прототип системы личных финансов: дашборд, годовой план по месяцам с CRUD статей доходов/расходов (категории + подкатегории, с формулой-привязкой доход→расход), экран транзакций, аналитика. Нижняя навигация: Дашборд / Бюджет / Транзакции / Аналитика / Настройки. - -## About the Design Files -Файлы в этой папке (`Личные финансы.dc.html`, `nocturne.css`) — **дизайн-референс на HTML**, не production-код. Задача разработчика — **пересобрать эти экраны в целевом окружении проекта** (в вашем случае — существующее приложение "Bicycle", PHP-бэкенд + фронтенд-шаблоны в `App/view/`), используя его текущие паттерны (layout.html, контроллеры, существующий CSS), а не копировать HTML как есть. - -## Fidelity -**Hi-fi.** Финальные цвета, типографика, отступы и интерактивные состояния — DC-файл можно открыть в браузере и использовать как эталон 1:1. - -## Design system -Nocturne (dark, mono-accent). Токены — в `nocturne.css` (`:root`): `--color-bg #161826`, `--color-surface #232532`, `--color-text #e9e9ed`, `--color-accent #9184d9` (+ тональные рампы 100–900 для accent/accent-2/neutral). Шрифт: Inter (`--font-heading`/`--font-body`). Радиусы/тени — `--radius-*`, `--shadow-sm/md/lg`. Разноцветные акценты счетов/статей в дашборде заданы прямыми `oklch()` — не входят в токены Nocturne, подобраны под ту же светлоту/насыщенность. - -## Screens -1. **Дашборд** — статус-бар (дата, бюджет %, дельта, курс), доступно сейчас (крупная сумма), карточки счетов (⋮-меню: редактировать/удалить, кнопка «+» → модалка «Новый счёт» → «Новый кредит»), копилки/цели (кольцевой прогресс, ⋮-меню), список статей доходов (прогресс-бар план/факт), бюджет по группам (то же, с перерасходом >100% — красный). -2. **План на год** (`planStyle`) — таблица статей × 12 месяцев (план), группы доходов/расходов, инлайн-редактируемые ячейки; на телефоне — аккордеон по группам. Кнопка «Статьи» открывает CRUD-модалку категорий (группы + подкатегории, add/edit/delete, поле формулы для доходов). -3. **Транзакции** (`transactionsStyle`) — список операций (дата, категория, сумма, доход зелёным). -4. **Аналитика** (`analyticsStyle`) — динамика доходов/расходов (SVG-полилиния), структура расходов (donut), план vs факт (бары), тренд/прогноз, год к году (столбцы по кварталам). - -Модалки: «Новый счёт» (тип: расчётный/накопительный/наличные/кредит-карта, цвет, валюта, приватность, учёт в общем балансе), «Новый кредит» (новый/уже выплачиваю, 4 типа: потребительский/ипотека/авто/кредитная карта, поля ставки/срока/платежа/остатка), «Статьи доходов и расходов» (CRUD дерева категорий). - -## Interactions -- Верхний свитчер экрана + свитчер устройства (комп/планшет/телефон, 1280/834/390px) — только для превью в самом DC-файле, в продакшене не нужен. -- Нижняя навигация — плавающая пилюля, 5 иконок, активная подсвечена акцентным кружком (важно: во React/Vue-эквиваленте стилизовать активный пункт целиком одним объектом стилей — не смешивать динамический style-объект с литеральной строкой CSS в одном атрибуте, как в исходном DC один раз аукнулось). -- ⋮-меню на карточках счетов/целей — клик открывает мини-дропдаун (редактировать/удалить), закрывается повторным кликом. -- CRUD статей: добавление/редактирование через инлайн-форму внутри модалки, удаление — сразу. - -## Assets -Иконки — инлайн SVG-спрайт (`` в начале файла), в духе Phosphor (design-system требует Phosphor — при переносе на реальный icon-set заменить на настоящие Phosphor-иконки: home, chart, sliders, gear, wallet, card, piggy-bank, folder, trash, pencil, plus, bank, bag, bolt, car, coin). - -## Files -- `Личные финансы.dc.html` — весь прототип (все экраны + модалки + логика состояния в одном файле). -- `nocturne.css` — токены и базовые компоненты дизайн-системы Nocturne. diff --git a/App/design_handoff_personal_finance/support.js b/App/design_handoff_personal_finance/support.js new file mode 100644 index 0000000..cb009b6 --- /dev/null +++ b/App/design_handoff_personal_finance/support.js @@ -0,0 +1,1911 @@ +// GENERATED from dc-runtime/src/*.ts — do not edit. Rebuild with `cd dc-runtime && bun run build`. +"use strict"; +(() => { + var __defProp = Object.defineProperty; + var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; + var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); + + // src/react.ts + function getReact() { + const R = window.React; + if (!R) throw new Error("dc-runtime: window.React is not available yet"); + return R; + } + function getReactDOM() { + const RD = window.ReactDOM; + if (!RD) throw new Error("dc-runtime: window.ReactDOM is not available yet"); + return RD; + } + var h = ((...args) => getReact().createElement( + ...args + )); + + // src/parse.ts + function parseDcDocument(doc) { + const dc = doc.querySelector("x-dc"); + if (!dc) return null; + const scriptEl = doc.querySelector("script[data-dc-script]"); + const { props, preview } = parseDataProps( + scriptEl?.getAttribute("data-props") ?? null + ); + return { + template: dc.innerHTML, + js: scriptEl ? scriptEl.textContent || "" : "", + props, + preview + }; + } + function parseDcText(src) { + const openMatch = /]*)?>/.exec(src); + if (!openMatch) return null; + const close = src.lastIndexOf(""); + if (close === -1 || close < openMatch.index) return null; + const template = src.slice(openMatch.index + openMatch[0].length, close); + const doc = new DOMParser().parseFromString(src, "text/html"); + const scriptEl = doc.querySelector("script[data-dc-script]"); + const { props, preview } = parseDataProps( + scriptEl?.getAttribute("data-props") ?? null + ); + return { + template, + js: scriptEl ? scriptEl.textContent || "" : "", + props, + preview + }; + } + function parseDataProps(raw) { + if (!raw) return { props: null, preview: null }; + let parsed; + try { + parsed = JSON.parse(raw); + } catch { + return { props: null, preview: null }; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return { props: null, preview: null }; + } + const obj = parsed; + const preview = obj.$preview && typeof obj.$preview === "object" ? obj.$preview : null; + const rest = {}; + for (const k of Object.keys(obj)) { + if (k[0] !== "$") rest[k] = obj[k]; + } + return { props: Object.keys(rest).length ? rest : null, preview }; + } + function dcNameFromPath(pathname) { + let p = pathname || ""; + try { + p = decodeURIComponent(p); + } catch { + } + const base = p.split("/").pop() || "Root"; + return base.replace(/\.dc\.html$/, "").replace(/\.html?$/, "") || "Root"; + } + + // src/boot.ts + var BASE_CSS = ` + .sc-placeholder{background:color-mix(in srgb,currentColor 8%,transparent); + border:1px solid color-mix(in srgb,currentColor 50%,transparent); + border-radius:2px;box-sizing:border-box;overflow:hidden} + @keyframes sc-shine{0%{background-position:100% 50%}100%{background-position:0% 50%}} + html.sc-dc-streaming .sc-placeholder, + html.sc-dc-streaming .sc-interp.sc-missing{position:relative; + background:color-mix(in srgb,currentColor 5%,transparent); + border-color:transparent} + html.sc-dc-streaming .sc-placeholder::before, + html.sc-dc-streaming .sc-interp.sc-missing::before{content:''; + position:absolute;inset:0;pointer-events:none; + background:linear-gradient(90deg,rgba(217,119,87,0) 25%,rgba(247,225,211,.95) 37%,rgba(217,119,87,0) 63%); + background-size:400% 100%;animation:sc-shine 1.4s ease infinite} + html.sc-dc-streaming .sc-placeholder:nth-child(n+9 of .sc-placeholder)::before, + html.sc-dc-streaming .sc-interp.sc-missing:nth-child(n+9 of .sc-interp.sc-missing)::before{animation:none; + background:color-mix(in srgb,currentColor 8%,transparent)} + .sc-placeholder-error{padding:4px 8px;font:11px/1.4 ui-monospace,monospace; + color:color-mix(in srgb,currentColor 70%,transparent);word-break:break-word} + .sc-interp.sc-missing{display:inline-block;width:2em;height:1em;overflow:hidden; + vertical-align:text-bottom;background:rgba(255,255,255,.3);border:1px solid rgba(0,0,0,.5); + border-radius:2px;box-sizing:border-box;color:transparent; + user-select:none} + .sc-interp.sc-unresolved{font-family:ui-monospace,monospace;font-size:.85em; + color:color-mix(in srgb,currentColor 50%,transparent); + background:color-mix(in srgb,currentColor 10%,transparent);border-radius:3px; + padding:0 3px} + .sc-host.sc-has-error{position:relative} + .sc-logic-error{position:absolute;top:8px;left:8px;z-index:2147483647;max-width:60ch; + padding:6px 10px;background:#b00020;color:#fff;font:12px/1.4 ui-monospace,monospace; + border-radius:4px;white-space:pre-wrap;pointer-events:none} + /* Mirrors PRINT_BASELINE_CSS in apps/web deck-stage-export.ts \u2014 keep both + in sync until dc-runtime regains a build step. */ + @media print { + @page { margin: 0.5cm; } + figure, table { break-inside: avoid; } + #dc-root, #dc-root > .sc-host { height: auto; } + *, *::before, *::after { + print-color-adjust: exact; -webkit-print-color-adjust: exact; + backdrop-filter: none !important; -webkit-backdrop-filter: none !important; + animation-delay: -99s !important; animation-duration: .001s !important; + animation-iteration-count: 1 !important; animation-fill-mode: both !important; + animation-play-state: running !important; transition-duration: 0s !important; + } + } + `; + var FULL_PAGE_CSS = "html,body{height:100%;margin:0}#dc-root,#dc-root>.sc-host{height:100%}"; + function rootNameForDocument(doc, loc) { + let bootPath = loc.pathname || ""; + if (!/\.dc\.html?$/i.test(safeDecode(bootPath))) { + try { + bootPath = new URL(doc.baseURI || "/").pathname; + } catch { + } + } + return dcNameFromPath(bootPath); + } + function safeDecode(s) { + try { + return decodeURIComponent(s); + } catch { + return s; + } + } + function boot(runtime, doc = document) { + const parsed = parseDcDocument(doc); + if (!parsed) return null; + const React = getReact(); + const rootName = rootNameForDocument(doc, location); + runtime.markFetched(rootName); + runtime.setRootName(rootName); + runtime.adoptParsed(rootName, parsed); + if (!window.__resources) { + fetch(location.href).then((res) => res.ok ? res.text() : "").then((t) => { + const raw = t ? parseDcText(t) : null; + if (raw?.template) runtime.updateHtml(rootName, raw.template); + }).catch(() => { + }); + } + const dc = doc.querySelector("x-dc"); + const hostEl = doc.createElement("div"); + hostEl.id = "dc-root"; + dc.replaceWith(hostEl); + if (!parsed.preview) { + const s = doc.createElement("style"); + s.textContent = FULL_PAGE_CSS; + doc.head.appendChild(s); + } + const Root = runtime.getDC(rootName); + const entry = runtime.registry.get(rootName); + function StandaloneRoot() { + const [, setTick] = React.useState(0); + React.useEffect(() => { + const sub = () => setTick((n) => n + 1); + entry.subs.add(sub); + return () => { + entry.subs.delete(sub); + }; + }, []); + const defaults = React.useMemo(() => { + const d = {}; + for (const k in entry.propsMeta || {}) { + const v = entry.propsMeta?.[k]?.default; + if (v !== void 0) d[k] = v; + } + return d; + }, [entry.propsMeta]); + return h(Root, { ...defaults, ...entry.propOverrides || {} }); + } + const ReactDOM = getReactDOM(); + if (ReactDOM.createRoot) + ReactDOM.createRoot(hostEl).render(h(StandaloneRoot)); + else ReactDOM.render(h(StandaloneRoot), hostEl); + return rootName; + } + + // src/expr.ts + var IDENT_RE = /^[A-Za-z_$][A-Za-z0-9_$]*/; + var NUMBER_RE = /^-?\d+(\.\d+)?$/; + function resolve(vals, src) { + const expr = String(src).trim(); + if (!expr) return void 0; + if (expr[0] === "(" && expr[expr.length - 1] === ")" && parensWrapWhole(expr)) { + return resolve(vals, expr.slice(1, -1)); + } + const eq = findTopLevelEquality(expr); + if (eq) { + const lv = resolve(vals, expr.slice(0, eq.index)); + const rv = resolve(vals, expr.slice(eq.index + eq.op.length)); + switch (eq.op) { + case "===": + return lv === rv; + case "!==": + return lv !== rv; + case "==": + return lv == rv; + default: + return lv != rv; + } + } + if (expr[0] === "!") return !resolve(vals, expr.slice(1)); + if (expr === "true") return true; + if (expr === "false") return false; + if (expr === "null") return null; + if (expr === "undefined") return void 0; + if (NUMBER_RE.test(expr)) return Number(expr); + if (expr.length >= 2 && (expr[0] === '"' || expr[0] === "'") && expr[expr.length - 1] === expr[0]) { + return expr.slice(1, -1); + } + return resolvePath(vals, expr); + } + function parensWrapWhole(expr) { + let depth = 0; + for (let i = 0; i < expr.length - 1; i++) { + if (expr[i] === "(") depth++; + else if (expr[i] === ")") { + depth--; + if (depth === 0) return false; + } + } + return true; + } + function findTopLevelEquality(expr) { + let depth = 0; + for (let i = 0; i < expr.length; i++) { + const c = expr[i]; + if (c === "[" || c === "(") depth++; + else if (c === "]" || c === ")") depth--; + else if (depth === 0 && (c === "=" || c === "!") && expr[i + 1] === "=") { + if (i > 0 && (expr[i - 1] === "=" || expr[i - 1] === "!")) continue; + if (!expr.slice(0, i).trim()) continue; + const op = expr[i + 2] === "=" ? c + "==" : c + "="; + return { index: i, op }; + } + } + return null; + } + function resolvePath(vals, expr) { + const head = expr.match(IDENT_RE); + if (!head) return void 0; + let cur = vals == null ? void 0 : vals[head[0]]; + let i = head[0].length; + while (i < expr.length) { + if (expr[i] === ".") { + const m = expr.slice(i + 1).match(IDENT_RE) || expr.slice(i + 1).match(/^\d+/); + if (!m) return void 0; + cur = cur == null ? void 0 : cur[m[0]]; + i += 1 + m[0].length; + } else if (expr[i] === "[") { + let depth = 1; + let j = i + 1; + while (j < expr.length && depth > 0) { + if (expr[j] === "[") depth++; + else if (expr[j] === "]") { + depth--; + if (depth === 0) break; + } + j++; + } + if (depth !== 0) return void 0; + const key = resolve(vals, expr.slice(i + 1, j)); + cur = cur == null ? void 0 : cur[key]; + i = j + 1; + } else { + return void 0; + } + } + return cur; + } + + // src/encode.ts + var CAMEL_ATTR = "sc-camel-"; + var INLINE_TEXT_TAGS = new Set( + "a abbr b bdi bdo br cite code del dfn em i ins kbd mark q s samp small span strike strong sub sup u var wbr".split( + " " + ) + ); + var RAW_WRAP = { + select: "sc-raw-select", + table: "sc-raw-table", + tbody: "sc-raw-tbody", + thead: "sc-raw-thead", + tfoot: "sc-raw-tfoot", + tr: "sc-raw-tr", + td: "sc-raw-td", + th: "sc-raw-th", + caption: "sc-raw-caption" + }; + var RAW_UNWRAP = Object.fromEntries( + Object.entries(RAW_WRAP).map(([k, v]) => [v, k]) + ); + var EVENT_MAP = { + onclick: "onClick", + onchange: "onChange", + oninput: "onInput", + onsubmit: "onSubmit", + onkeydown: "onKeyDown", + onkeyup: "onKeyUp", + onkeypress: "onKeyPress", + onmousedown: "onMouseDown", + onmouseup: "onMouseUp", + onmouseenter: "onMouseEnter", + onmouseleave: "onMouseLeave", + onfocus: "onFocus", + onblur: "onBlur", + ondoubleclick: "onDoubleClick", + oncontextmenu: "onContextMenu", + onmousemove: "onMouseMove", + onmouseover: "onMouseOver", + onmouseout: "onMouseOut", + onpointerdown: "onPointerDown", + onpointerup: "onPointerUp", + onpointermove: "onPointerMove", + onpointerenter: "onPointerEnter", + onpointerleave: "onPointerLeave", + onpointercancel: "onPointerCancel", + onpointerover: "onPointerOver", + onpointerout: "onPointerOut", + ongotpointercapture: "onGotPointerCapture", + onlostpointercapture: "onLostPointerCapture", + ontouchstart: "onTouchStart", + ontouchend: "onTouchEnd", + ontouchmove: "onTouchMove", + ontouchcancel: "onTouchCancel", + ondragstart: "onDragStart", + ondragend: "onDragEnd", + ondragenter: "onDragEnter", + ondragleave: "onDragLeave", + ondragover: "onDragOver", + onanimationstart: "onAnimationStart", + onanimationend: "onAnimationEnd", + onanimationiteration: "onAnimationIteration", + ontransitionend: "onTransitionEnd" + }; + var ATTRS = `(?:[^>"']|"[^"]*"|'[^']*')*`; + var IMPORT_SELF_CLOSE_RE = new RegExp( + "<(x-import|dc-import)(" + ATTRS + ")/>", + "gi" + ); + var CAMEL_ATTR_RE = /(\s)([a-z]+[A-Z][A-Za-z0-9]*)(\s*=)/g; + function encodeCamelAttrs(html) { + return html.replace( + CAMEL_ATTR_RE, + (_, sp, name, eq) => sp + CAMEL_ATTR + name.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase()) + eq + ); + } + function encodeCase(html) { + html = html.replace( + IMPORT_SELF_CLOSE_RE, + (_, t, a) => "<" + t + a + ">" + ); + html = html.replace(/)/gi, "/gi, ""); + html = encodeCamelAttrs(html); + for (const [real, alias] of Object.entries(RAW_WRAP)) { + html = html.replace( + new RegExp("(])", "gi"), + "$1" + alias + ); + } + return html; + } + function kebabToCamel(s) { + return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase()); + } + function cssToObj(css) { + const o = {}; + for (const decl of css.split(";")) { + const i = decl.indexOf(":"); + if (i < 0) continue; + const prop = decl.slice(0, i).trim(); + o[prop.startsWith("--") ? prop : kebabToCamel(prop)] = decl.slice(i + 1).trim(); + } + return o; + } + function compileAttr(raw) { + const whole = raw.match(/^\s*\{\{([\s\S]+?)\}\}\s*$/); + if (whole) { + const path = whole[1]; + return (vals) => resolve(vals, path); + } + if (raw.includes("{{")) { + const parts = raw.split(/\{\{([\s\S]+?)\}\}/g); + return (vals) => parts.map((s, i) => i & 1 ? resolve(vals, s) ?? "" : s).join(""); + } + return () => raw; + } + + // src/compile.ts + function collectProps(node, kind, host) { + const propGetters = []; + const pseudoClasses = []; + let hintSize = null; + for (const { name, value } of [...node.attributes]) { + if (name === "sc-name" || name === "data-dc-tpl") continue; + let key = name; + if (key.startsWith(CAMEL_ATTR)) + key = kebabToCamel(key.slice(CAMEL_ATTR.length)); + if (key === "hint-size") { + hintSize = value; + continue; + } + if (key.startsWith("style-")) { + pseudoClasses.push(host.pseudoClass(key.slice(6), value)); + continue; + } + if (kind !== "dom") { + if (key.includes("-") && !(kind === "x-import" && (key.startsWith("aria-") || key.startsWith("data-")))) + key = kebabToCamel(key); + } else { + if (key === "class") key = "className"; + else if (key === "for") key = "htmlFor"; + else if (key.startsWith("on")) + key = EVENT_MAP[key] || "on" + key[2].toUpperCase() + key.slice(3); + } + propGetters.push([key, compileAttr(value)]); + } + return { propGetters, pseudoClasses, hintSize }; + } + var HOST_STYLE_PROPS = /* @__PURE__ */ new Set([ + "position", + "left", + "right", + "top", + "bottom", + "inset", + "width", + "height", + "z-index", + "transform" + ]); + function hostPositionStyle(style) { + const all = typeof style === "string" ? cssToObj(style) : style != null && typeof style === "object" ? style : null; + if (!all) return void 0; + const out = {}; + for (const [k, v] of Object.entries(all)) { + const kebab = k.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase()); + if (HOST_STYLE_PROPS.has(kebab)) out[k] = v; + } + return Object.keys(out).length ? out : void 0; + } + function compileTemplate(html, host) { + const tpl = document.createElement("template"); + //! nosemgrep: direct-inner-html-assignment + tpl.innerHTML = encodeCase(html); + let tplN = 0; + (function stamp(node) { + if (node.nodeType === Node.ELEMENT_NODE) { + node.setAttribute("data-dc-tpl", String(tplN++)); + } + for (const c of node.childNodes) stamp(c); + })(tpl.content); + const builders = walkChildren(tpl.content, host); + const render = ((vals, ctx) => builders.map((b, i) => b(vals || {}, ctx, i))); + render.__annotated = tpl.innerHTML; + return render; + } + function walkChildren(node, host) { + return [...node.childNodes].map((c) => walk(c, host)).filter((b) => b != null); + } + var SLIDE_ID_VALUE_RE = /^[0-9a-f]{8}$/; + var DECK_CONTROL_FLOW_RE = /^(sc-if|sc-for|sc-else|dc-import|x-import)$/; + var DECK_AUX_RE = /^(template|script|style|sc-helmet|helmet)$/; + function isDeckMountTag(el) { + if (el.localName === "deck-stage") return true; + return el.localName === "x-import" && (el.getAttribute("component-from-global-scope") || "") === "deck-stage"; + } + function walkDeckChildren(el, host) { + const pairs = [...el.childNodes].map((c) => ({ c, b: walk(c, host) })).filter((p) => p.b !== null); + const kids = pairs.map((p) => p.b); + const seen = /* @__PURE__ */ new Set(); + const wsSeen = /* @__PURE__ */ new Map(); + const keys = []; + const nextSlideId = new Array(pairs.length); + { + let upcoming = null; + for (let j = pairs.length - 1; j >= 0; j--) { + const n = pairs[j].c; + if (n.nodeType === Node.ELEMENT_NODE) { + const t = n.localName; + upcoming = !DECK_AUX_RE.test(t) && !DECK_CONTROL_FLOW_RE.test(t) ? n.getAttribute("data-om-slide-id") : null; + } + nextSlideId[j] = upcoming; + } + } + for (let j = 0; j < pairs.length; j++) { + const { c } = pairs[j]; + if (c.nodeType === Node.TEXT_NODE) { + if ((c.nodeValue ?? "").trim() === "") { + const base = nextSlideId[j] ? "omid-ws:" + nextSlideId[j] : "omid-ws:aux"; + const n = wsSeen.get(base) ?? 0; + wsSeen.set(base, n + 1); + keys.push(n === 0 ? base : base + ":" + n); + continue; + } + return { kids, keys: null }; + } + if (c.nodeType !== Node.ELEMENT_NODE) { + keys.push(j); + continue; + } + const child = c; + const tag = child.localName; + if (DECK_AUX_RE.test(tag)) { + keys.push(j); + continue; + } + if (DECK_CONTROL_FLOW_RE.test(tag)) return { kids, keys: null }; + const v = child.getAttribute("data-om-slide-id"); + if (!v || !SLIDE_ID_VALUE_RE.test(v) || seen.has(v)) { + return { kids, keys: null }; + } + seen.add(v); + keys.push("omid:" + v); + } + return { kids, keys }; + } + function renderDeckKids(kids, kidKeys, vals, ctx) { + return kids.map((b, j) => { + const k = kidKeys ? kidKeys[j] : j; + const out = b(vals, ctx, k); + return kidKeys != null && typeof out === "string" ? h(getReact().Fragment, { key: k }, out) : out; + }); + } + function walk(node, host) { + if (node.nodeType === Node.TEXT_NODE) return walkText(node); + if (node.nodeType !== Node.ELEMENT_NODE) return null; + const el = node; + const tag = el.tagName.toLowerCase(); + if (tag === "sc-for") return walkFor(el, host); + if (tag === "sc-if") return walkIf(el, host); + if (tag === "x-import") return walkXImport(el, host); + if (tag === "sc-helmet") return host.helmet(el); + if (tag === "dc-import") return walkComponent(el, host); + return walkElement(el, host); + } + var warnedHoles = /* @__PURE__ */ new Set(); + function warnUnresolved(ctx, what) { + const key = (ctx?.__name || "?") + "\0" + what; + if (warnedHoles.has(key)) return; + warnedHoles.add(key); + console.warn("[dc-runtime] " + (ctx?.__name || "template") + ": " + what); + } + function walkText(node) { + const txt = node.nodeValue ?? ""; + if (!txt.includes("{{")) { + if (!txt.trim() && !txt.includes(" ")) return null; + return () => txt; + } + const parts = txt.split(/\{\{([\s\S]+?)\}\}/g); + return (vals, ctx, key) => h( + getReact().Fragment, + { key }, + ...parts.map((p, i) => { + if (!(i & 1)) return p; + const v = resolve(vals, p); + if (v === void 0) { + if (!ctx?.__streamingNow) { + if (document.body?.hasAttribute("data-dc-editor-on")) { + return h( + "span", + { key: i, className: "sc-interp sc-unresolved" }, + "{{ " + p.trim() + " }}" + ); + } + warnUnresolved( + ctx, + "{{ " + p.trim() + " }} never resolved \u2014 rendered as empty" + ); + return null; + } + return h( + "span", + { key: i, className: "sc-interp sc-missing" }, + p.trim() + ); + } + if (getReact().isValidElement(v) || Array.isArray(v)) { + return h(getReact().Fragment, { key: i }, v); + } + if (v === null || typeof v === "boolean") return null; + return h("span", { key: i, className: "sc-interp" }, String(v)); + }) + ); + } + function walkFor(el, host) { + const listGet = compileAttr(el.getAttribute("list") || ""); + const asName = el.getAttribute("as") || "item"; + const hintN = parseInt(el.getAttribute("hint-placeholder-count") || "0", 10); + const kids = walkChildren(el, host); + const listSrc = el.getAttribute("list") || ""; + return (vals, ctx, key) => { + let list = listGet(vals); + if (!Array.isArray(list)) { + if (!ctx?.__streamingNow) { + if (list !== void 0 && list !== null) { + warnUnresolved( + ctx, + 'sc-for list="' + listSrc + '" is not an array (' + typeof list + ")" + ); + } + list = []; + } else { + list = hintN > 0 ? Array(hintN).fill(void 0) : []; + } + } + return h( + getReact().Fragment, + { key }, + list.map((item, i) => { + const sub = { ...vals, [asName]: item, $index: i }; + return h( + getReact().Fragment, + { key: i }, + kids.map((b, j) => b(sub, ctx, j)) + ); + }) + ); + }; + } + function walkIf(el, host) { + const valGet = compileAttr(el.getAttribute("value") || ""); + const hintRaw = el.getAttribute("hint-placeholder-val"); + const hintGet = hintRaw != null ? compileAttr(hintRaw) : null; + const kids = walkChildren(el, host); + return (vals, ctx, key) => { + let v = valGet(vals); + if (v === void 0 && hintGet && ctx?.__streamingNow) v = hintGet(vals); + return v ? h( + getReact().Fragment, + { key }, + kids.map((b, j) => b(vals, ctx, j)) + ) : null; + }; + } + function walkComponent(el, host) { + const name = el.getAttribute("name") || el.getAttribute("component") || ""; + el.removeAttribute("name"); + el.removeAttribute("component"); + const tplId = el.getAttribute("data-dc-tpl"); + const styleRaw = el.getAttribute("style"); + el.removeAttribute("style"); + const styleGet = styleRaw != null ? compileAttr(styleRaw) : null; + const { propGetters, hintSize } = collectProps(el, "dc-import", host); + const kids = walkChildren(el, host); + return (vals, ctx, key) => { + const props = { + key, + __hintSize: hintSize, + __tplId: tplId, + __hostStyle: styleGet ? hostPositionStyle(styleGet(vals)) : void 0 + }; + for (const [k, g] of propGetters) { + const v = g(vals); + if (k === "dcProps") { + if (v && typeof v === "object") Object.assign(props, v); + continue; + } + props[k] = v; + } + if (kids.length) props.children = kids.map((b, j) => b(vals, ctx, j)); + return h(host.component(name), props); + }; + } + function walkXImport(el, host) { + const globalNameGet = compileAttr( + el.getAttribute("component-from-global-scope") || "" + ); + const exportNameGet = compileAttr( + el.getAttribute("component") || el.getAttribute("name") || "" + ); + const fromRaw = el.getAttribute("from") || (el.getAttribute("component-from-global-scope") ? "" : el.getAttribute("src") || el.getAttribute("import") || ""); + const urls = fromRaw.trim() ? fromRaw.trim().split(/\s+/) : []; + const url = urls.length ? urls[urls.length - 1] : ""; + const kindOf = (u) => /\.(jsx|tsx)(\?|#|$)/i.test(u) ? "jsx" : "js"; + const tplId = el.getAttribute("data-dc-tpl"); + const styleRaw = el.getAttribute("style"); + el.removeAttribute("style"); + const styleGet = styleRaw != null ? compileAttr(styleRaw) : null; + const wrap = tplId != null || styleGet != null; + const { propGetters, hintSize } = collectProps(el, "x-import", host); + const hasContent = el.children.length > 0 || !!(el.textContent || "").trim(); + const deckKeyed = hasContent && isDeckMountTag(el) ? walkDeckChildren(el, host) : null; + const kids = deckKeyed ? deckKeyed.kids : hasContent ? walkChildren(el, host) : []; + const kidKeys = deckKeyed?.keys ?? null; + const urlBindable = fromRaw.includes("{{"); + if (urls.length && !urlBindable) { + let prev; + for (const u of urls) prev = host.loadExternal(kindOf(u), u, prev); + } + const evalName = (g, vals) => { + const v = g(vals); + const s = v == null ? "" : String(v); + return s.includes("{{") ? "" : s; + }; + return (vals, ctx, key) => { + const globalName = evalName(globalNameGet, vals); + const name = globalName || evalName(exportNameGet, vals); + const C = !name || urlBindable ? null : globalName ? host.resolveExternalGlobal(url, globalName) : host.resolveExternal(url, name); + const hostStyle = styleGet ? hostPositionStyle(styleGet(vals)) : void 0; + const wrapper = wrap ? { + key, + className: "sc-host-x", + "data-dc-tpl": tplId, + style: hostStyle || { display: "contents" } + } : null; + if (!C) { + const error = urlBindable ? "x-import `from` cannot contain {{ \u2026 }} \u2014 module URLs are resolved at parse time; use a literal URL" : host.resolveExternalError(url, name); + const ph = host.placeholder({ + key: wrapper ? void 0 : key, + name, + hintSize, + error + }); + return wrapper ? h("div", wrapper, ph) : ph; + } + const props = wrapper ? {} : { key }; + let unresolvedHole = false; + for (const [k, g] of propGetters) { + if (k === "component" || k === "componentFromGlobalScope" || k === "from") { + continue; + } + const v = g(vals); + if (v === void 0) unresolvedHole = true; + if (k === "dcProps") { + if (v && typeof v === "object") Object.assign(props, v); + continue; + } + props[k] = v; + } + if (unresolvedHole && ctx?.__htmlStreamingNow) { + const ph = host.placeholder({ + key: wrapper ? void 0 : key, + name, + hintSize, + error: null + }); + return wrapper ? h("div", wrapper, ph) : ph; + } + if (kids.length) { + props.children = renderDeckKids(kids, kidKeys, vals, ctx); + } + return wrapper ? h("div", wrapper, h(C, props)) : h(C, props); + }; + } + function contentKey(el) { + const clone = el.cloneNode(true); + for (const d of clone.querySelectorAll("*")) { + while (d.attributes.length) d.removeAttribute(d.attributes[0].name); + } + const s = clone.innerHTML; + let h2 = 5381; + for (let i = 0; i < s.length; i++) h2 = (h2 << 5) + h2 + s.charCodeAt(i) | 0; + return s.length + "." + (h2 >>> 0).toString(36); + } + var NEVER_CONTENT_KEYED = new Set( + "script style textarea option title select canvas iframe video audio".split( + " " + ) + ); + var NOT_INLINE_SELECTOR = ":not(" + [...INLINE_TEXT_TAGS].join(",") + ")"; + function walkElement(el, host) { + const realTag = RAW_UNWRAP[el.localName] || el.localName; + const tplId = el.getAttribute("data-dc-tpl"); + const inlineOnly = el.childNodes.length > 0 && !NEVER_CONTENT_KEYED.has(realTag) && el.querySelector(NOT_INLINE_SELECTOR) === null; + const keySuffix = inlineOnly ? "|" + contentKey(el) : ""; + const { propGetters, pseudoClasses } = collectProps(el, "dom", host); + const deckKeyed = isDeckMountTag(el) ? walkDeckChildren(el, host) : null; + const kids = deckKeyed ? deckKeyed.kids : walkChildren(el, host); + const kidKeys = deckKeyed?.keys ?? null; + return (vals, ctx, key) => { + const props = { + key: key + keySuffix, + "data-dc-tpl": tplId + }; + for (const [k, g] of propGetters) { + let v = g(vals); + if (k === "style" && typeof v === "string") v = cssToObj(v); + if ((k === "value" || k === "checked") && v === void 0) { + v = k === "checked" ? false : ""; + } + props[k] = v; + } + if (pseudoClasses.length) { + props.className = [props.className, ...pseudoClasses].filter(Boolean).join(" "); + } + return h(realTag, props, ...renderDeckKids(kids, kidKeys, vals, ctx)); + }; + } + + // src/logic.ts + var StreamableLogic = class { + constructor(props) { + __publicField(this, "props"); + __publicField(this, "state", {}); + /** Back-pointer to the wrapper component, installed after construction. */ + __publicField(this, "__host"); + this.props = props || {}; + } + setState(update, cb) { + this.__host && this.__host.__setLogicState(update, cb); + } + forceUpdate() { + this.__host && this.__host.forceUpdate(); + } + componentDidMount() { + } + componentDidUpdate(_prevProps) { + } + componentWillUnmount() { + } + /** The flat object the template renders against (merged over props). */ + renderVals() { + return {}; + } + }; + function evalDcLogic(src) { + //! nosemgrep: eval-and-function-constructor + const fn = new Function( + "DCLogic", + "StreamableLogic", + "React", + src + '\n;return (typeof Component!=="undefined"&&Component)||undefined;' + ); + return fn(StreamableLogic, StreamableLogic, getReact()); + } + + // src/component.ts + function shallowEqual(a, b) { + if (!b) return false; + const ak = Object.keys(a).filter((k) => k !== "children"); + const bk = Object.keys(b).filter((k) => k !== "children"); + if (ak.length !== bk.length) return false; + for (const k of ak) if (a[k] !== b[k]) return false; + return true; + } + function Placeholder({ + name, + hintSize, + streaming, + error + }) { + const [w, hgt] = (hintSize || "100%,60px").split(","); + return h( + "div", + { + className: "sc-placeholder" + (streaming ? " sc-streaming" : ""), + style: { width: w.trim(), height: hgt && hgt.trim() }, + title: name + }, + error ? h( + "div", + { className: "sc-placeholder-error" }, + (name ? name + ": " : "") + error + ) : null + ); + } + function hintToMin(hint) { + if (!hint) return void 0; + const [w, hgt] = hint.split(","); + return { minWidth: w.trim(), minHeight: hgt && hgt.trim() }; + } + function createComponentFactory(registry, ensureFetched) { + const React = getReact(); + const AncestorContext = React.createContext([]); + class StreamableComponent extends React.Component { + constructor(props) { + super(props); + __publicField(this, "__name"); + __publicField(this, "__sub"); + __publicField(this, "__needsDidMount", false); + /** Snapshot of the registry's streaming flags taken at render time — + * builders read it off the RenderCtx (this) to pick placeholder vs + * render-nothing for unresolved values. */ + __publicField(this, "__streamingNow", false); + __publicField(this, "__htmlStreamingNow", false); + /** When a construct throws, remember the (class, registry.ver, props) + * triple so render-time reconcile doesn't re-attempt it on every parent + * re-render. A registry bump (new class, template, external module + * resolving via bumpAll) changes `ver` and breaks the memo so an + * env-dependent constructor can self-heal. */ + __publicField(this, "__failedLogic", null); + __publicField(this, "__failedUserProps", null); + __publicField(this, "__failedVer", -1); + /** Per-instance constructor error — kept here (not on the registry entry) + * so one instance's successful construct can't hide a sibling's failure, + * and a construct can never wipe an eval error `updateJs` recorded on + * `r.logicError`. */ + __publicField(this, "__ctorError", null); + __publicField(this, "logic"); + this.__name = props.__name; + this.state = { __v: 0, __err: null }; + this.__sub = () => { + if (this.state.__err) this.setState({ __err: null }); + this.forceUpdate(); + }; + this.__makeLogic(registry.get(this.__name).Logic, null); + ensureFetched(this.__name); + } + /** Error-boundary hook: a render crash anywhere in this DC's subtree + * (its own template, an x-import'd component, a child DC without its + * own deeper boundary) lands here instead of unmounting the page. */ + static getDerivedStateFromError(e) { + return { __err: e instanceof Error && e.message ? e.message : String(e) }; + } + componentDidCatch(e, info) { + console.error( + "[dc-runtime] render error in <" + this.__name + ">:", + e, + info?.componentStack || "" + ); + } + /** Instantiate the logic class (or the no-op base) and adopt `prevState` + * over its initial state — used both at mount and on hot-swap. */ + __makeLogic(Logic, prevState) { + const L = Logic || StreamableLogic; + try { + this.logic = new L(this.__userProps()); + this.__failedLogic = null; + this.__failedUserProps = null; + this.__ctorError = null; + } catch (e) { + console.error(e); + this.__failedLogic = Logic; + this.__failedUserProps = this.__userProps(); + this.__failedVer = registry.get(this.__name).ver; + this.__ctorError = this.__name + ": " + (e instanceof Error && e.message ? e.message : String(e)); + this.logic = new StreamableLogic( + this.__userProps() + ); + } + this.logic.__host = this; + if (prevState) + this.logic.state = { ...this.logic.state || {}, ...prevState }; + } + /** The props the author's logic + template see — internal __-prefixed + * wiring stripped. */ + __userProps() { + const { __name, __hintSize, __tplId, __hostStyle, ...rest } = this.props; + return rest; + } + __setLogicState(update, cb) { + const prev = this.logic.state; + const patch = typeof update === "function" ? update(prev) : update; + this.logic.state = { ...prev, ...patch }; + this.setState((s) => ({ __v: s.__v + 1 }), cb); + } + /** Swap the logic instance when the registry's Logic class changed + * (streaming completion, hot reload). State carries over; didMount + * re-fires after the swap commits so refs exist. */ + __reconcileLogic() { + const r = registry.get(this.__name); + const Next = r.Logic; + const Cur = this.logic.constructor; + if (Next === Cur || !Next && Cur === StreamableLogic || Next === this.__failedLogic && r.ver === this.__failedVer && shallowEqual(this.__userProps(), this.__failedUserProps)) { + return; + } + if (!this.__needsDidMount) { + try { + this.logic.componentWillUnmount(); + } catch (e) { + console.error(e); + } + } + this.__makeLogic(Next, this.logic.state); + this.__needsDidMount = true; + } + componentDidMount() { + registry.get(this.__name).subs.add(this.__sub); + try { + this.logic.componentDidMount(); + } catch (e) { + console.error(e); + } + } + componentDidUpdate(prevProps) { + this.logic.props = this.__userProps(); + if (this.__needsDidMount) { + if (this.state.__err || !registry.get(this.__name).tpl) return; + this.__needsDidMount = false; + try { + this.logic.componentDidMount(); + } catch (e) { + console.error(e); + } + } else { + try { + this.logic.componentDidUpdate(prevProps); + } catch (e) { + console.error(e); + } + } + } + componentWillUnmount() { + registry.get(this.__name).subs.delete(this.__sub); + if (!this.__needsDidMount) { + try { + this.logic.componentWillUnmount(); + } catch (e) { + console.error(e); + } + } + } + render() { + const r = registry.get(this.__name); + const cls = "sc-host" + (r.htmlStreaming ? " sc-streaming-html" : "") + (r.jsStreaming ? " sc-streaming-js" : ""); + const hintStyle = r.htmlStreaming ? hintToMin(this.props.__hintSize) : void 0; + const hostStyle = this.props.__hostStyle || hintStyle ? { ...hintStyle || {}, ...this.props.__hostStyle || {} } : void 0; + const hostBase = { + className: cls, + style: hostStyle, + "data-sc-name": this.__name, + "data-dc-tpl": this.props.__tplId + }; + const chain = Array.isArray(this.context) ? this.context : []; + if (chain.includes(this.__name)) { + const cycle = [ + ...chain.slice(chain.indexOf(this.__name)), + this.__name + ].join(" \u2192 "); + return h( + "div", + { ...hostBase, className: cls + " sc-has-error" }, + h(Placeholder, { + name: this.__name, + hintSize: this.props.__hintSize, + error: "circular import: " + cycle + }) + ); + } + if (this.state.__err) { + return h( + "div", + { ...hostBase, className: cls + " sc-has-error" }, + h( + "div", + { className: "sc-logic-error", "data-omelette-chrome": "" }, + this.__name + ": " + this.state.__err + ), + h(Placeholder, { + name: this.__name, + hintSize: this.props.__hintSize, + error: this.state.__err + }) + ); + } + this.__reconcileLogic(); + if (!r.tpl) { + return h( + "div", + hostBase, + h(Placeholder, { name: this.__name, hintSize: this.props.__hintSize }) + ); + } + const userProps = this.__userProps(); + this.logic.props = userProps; + let vals = userProps; + let renderErr = r.logicError || this.__ctorError; + try { + vals = { ...userProps, ...this.logic.renderVals() || {} }; + } catch (e) { + console.error(e); + renderErr = this.__name + ".renderVals(): " + (e instanceof Error && e.message ? e.message : String(e)); + } + this.__streamingNow = !!(r.htmlStreaming || r.jsStreaming); + this.__htmlStreamingNow = !!r.htmlStreaming; + return h( + "div", + { ...hostBase, className: cls + (renderErr ? " sc-has-error" : "") }, + renderErr && h( + "div", + { className: "sc-logic-error", "data-omelette-chrome": "" }, + renderErr + ), + h( + AncestorContext.Provider, + { value: [...chain, this.__name] }, + r.tpl(vals, this) + ) + ); + } + } + __publicField(StreamableComponent, "contextType", AncestorContext); + const named = /* @__PURE__ */ new Map(); + function getDC(name) { + const hit = named.get(name); + if (hit) return hit; + function Dispatcher(p) { + const [, setTick] = React.useState(0); + React.useEffect(() => { + const sub = () => setTick((n) => n + 1); + registry.get(name).subs.add(sub); + return () => { + registry.get(name).subs.delete(sub); + }; + }, []); + ensureFetched(name); + return h(StreamableComponent, { ...p, __name: name }); + } + Dispatcher.displayName = name; + named.set(name, Dispatcher); + return Dispatcher; + } + return { + getDC, + StreamableComponent + }; + } + + // src/bundled.ts + function bundledBlob(url) { + const blobs = window.__resourceBlobs; + const b = blobs ? blobs[url.split("#")[0]] : void 0; + return b instanceof Blob ? b : null; + } + + // src/cdn.ts + var REACT_URL = "https://unpkg.com/react@18.3.1/umd/react.production.min.js"; + var REACT_SRI = "sha384-DGyLxAyjq0f9SPpVevD6IgztCFlnMF6oW/XQGmfe+IsZ8TqEiDrcHkMLKI6fiB/Z"; + var REACT_DOM_URL = "https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"; + var REACT_DOM_SRI = "sha384-gTGxhz21lVGYNMcdJOyq01Edg0jhn/c22nsx0kyqP0TxaV5WVdsSH1fSDUf5YJj1"; + var BABEL_URL = "https://unpkg.com/@babel/standalone@7.29.0/babel.min.js"; + var BABEL_SRI = "sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y"; + function cdnScriptFor(url, sri) { + const res = window.__resources; + const v = res ? res[url] : void 0; + return typeof v === "string" && v ? { src: v } : { src: url, integrity: sri }; + } + + // src/external.ts + var isCustomElementName = (n) => !n.includes(".") && n.includes("-"); + function isRenderableType(g) { + if (typeof g === "function") return !isElementClass(g); + return typeof g === "object" && g !== null && typeof g.$$typeof === "symbol"; + } + function resolveDottedPath(root, name) { + let cur = root; + for (const seg of name.split(".")) { + if (cur == null) return void 0; + cur = cur[seg]; + } + return cur; + } + var GLOBAL_POLL_INTERVAL_MS = 50; + var GLOBAL_POLL_TIMEOUT_MS = 3e4; + function createExternalModules(onResolved) { + const cache = /* @__PURE__ */ new Map(); + let babelLoading = null; + const reportedMissing = /* @__PURE__ */ new Map(); + const polling = /* @__PURE__ */ new Set(); + function ensureBabel() { + if (window.Babel) return Promise.resolve(); + if (babelLoading) return babelLoading; + const babel = cdnScriptFor(BABEL_URL, BABEL_SRI); + babelLoading = new Promise((res, rej) => { + const s = document.createElement("script"); + s.src = babel.src; + if (babel.integrity) { + s.integrity = babel.integrity; + s.crossOrigin = "anonymous"; + } + s.onload = () => res(); + s.onerror = rej; + document.head.appendChild(s); + }); + return babelLoading; + } + const pending = /* @__PURE__ */ new Map(); + function load(kind, url, after) { + const existing = pending.get(url); + if (existing) return existing; + cache.set(url, null); + console.info("[dc-runtime] x-import: loading", url, "(" + kind + ")"); + const ready = Promise.all([ + kind === "jsx" ? ensureBabel() : Promise.resolve(), + after ?? Promise.resolve() + ]); + const p = ready.then(() => { + const pre = bundledBlob(url); + if (pre) return pre.text(); + return fetch(url).then((r) => { + if (!r.ok) throw new Error("HTTP " + r.status); + return r.text(); + }); + }).then((src) => { + const code = kind === "jsx" ? window.Babel.transform(src, { + filename: url, + presets: ["react", "typescript"] + }).code : src; + const module = { exports: {} }; + const before = new Set(Object.keys(window)); + //! nosemgrep: eval-and-function-constructor + new Function("React", "module", "exports", "require", code)( + getReact(), + module, + module.exports, + () => ({}) + ); + const globals = {}; + for (const k of Object.keys(window)) { + if (!before.has(k) && typeof window[k] === "function") { + globals[k] = window[k]; + } + } + cache.set(url, { mod: module.exports, globals }); + console.info( + "[dc-runtime] x-import: loaded", + url, + "\u2014 exports:", + Object.keys(module.exports), + "window globals:", + Object.keys(globals) + ); + onResolved(); + }).catch((e) => { + cache.set(url, { + mod: {}, + globals: {}, + error: "failed to load: " + (e instanceof Error && e.message ? e.message : String(e)) + }); + console.error( + "[dc-runtime] x-import: FAILED to load", + url, + "(" + kind + ")", + e + ); + onResolved(); + }); + pending.set(url, p); + return p; + } + function resolve2(url, name) { + const entry = cache.get(url); + if (!entry) return null; + const { mod, globals } = entry; + const C = mod && mod[name] || globals && globals[name] || typeof window !== "undefined" && window[name] || mod && mod.default; + if (typeof C === "function") return C; + const key = url + "\0" + name; + if (!reportedMissing.has(key)) { + reportedMissing.set( + key, + entry.error || 'no export named "' + name + '" (has: ' + Object.keys(mod).join(", ") + ")" + ); + console.error( + "[dc-runtime] x-import: module", + url, + "loaded but has no component named", + JSON.stringify(name), + "\u2014 available exports:", + Object.keys(mod), + "window globals:", + Object.keys(globals), + ". The module must `module.exports = {" + name + "}` or set `window." + name + "`." + ); + } + return null; + } + function waitForGlobal(name) { + if (polling.has(name)) return; + polling.add(name); + const started = Date.now(); + const isCE = isCustomElementName(name); + const tick = () => { + const found = isCE ? customElements.get(name) : isRenderableType(resolveDottedPath(window, name)); + if (found) { + polling.delete(name); + onResolved(); + return; + } + if (Date.now() - started >= GLOBAL_POLL_TIMEOUT_MS) { + console.warn( + "[dc-runtime] x-import: global", + JSON.stringify(name), + "never appeared on window after " + GLOBAL_POLL_TIMEOUT_MS + "ms" + ); + return; + } + setTimeout(tick, GLOBAL_POLL_INTERVAL_MS); + }; + setTimeout(tick, GLOBAL_POLL_INTERVAL_MS); + } + function resolveGlobal(url, name) { + const isCE = isCustomElementName(name); + if (!url) { + if (isCE) { + if (customElements.get(name)) return name; + waitForGlobal(name); + return null; + } + const g2 = resolveDottedPath(window, name); + if (isRenderableType(g2)) return g2; + waitForGlobal(name); + return null; + } + const entry = cache.get(url); + if (!entry) return null; + if (isCE && customElements.get(name)) return name; + const g = entry.globals[name] ?? resolveDottedPath(window, name); + if (isRenderableType(g)) return g; + if (name.includes(".")) return null; + const key = url + "\0global\0" + name; + if (!reportedMissing.has(key)) { + reportedMissing.set(key, null); + if (isCE && !customElements.get(name)) { + console.warn( + "[dc-runtime] x-import:", + url, + "loaded but no custom element", + JSON.stringify(name), + "is registered and window." + name + " is not a function \u2014 rendering <" + name + "> as an unknown element." + ); + } + } + return name; + } + function getError(url, name) { + const entry = cache.get(url); + if (entry?.error) return entry.error; + return reportedMissing.get(url + "\0" + name) || null; + } + return { load, resolve: resolve2, resolveGlobal, getError }; + } + function isElementClass(g) { + try { + return typeof g === "function" && typeof HTMLElement !== "undefined" && g.prototype instanceof HTMLElement; + } catch { + return false; + } + } + + // src/atomics.ts + var ATOMIC_CSS = ( + // layout + ".fx{display:flex}.col{display:flex;flex-direction:column}.grid{display:grid}.ac{align-items:center}.jc{justify-content:center}.jb{justify-content:space-between}.f1{flex:1}.noshrink{flex-shrink:0}.wrap{flex-wrap:wrap}.fw5{font-weight:500}.fw6{font-weight:600}.fw7{font-weight:700}.fw8{font-weight:800}.fs11{font-size:11px}.fs12{font-size:12px}.fs13{font-size:13px}.fs14{font-size:14px}.fs15{font-size:15px}.fs16{font-size:16px}.fs20{font-size:20px}.fs22{font-size:22px}.upper{text-transform:uppercase}.tc{text-align:center}.nowrap{white-space:nowrap}.gap8{gap:8px}.gap10{gap:10px}.gap12{gap:12px}.gap16{gap:16px}.gap24{gap:24px}.m0{margin:0}.mt8{margin-top:8px}.mt12{margin-top:12px}.mt16{margin-top:16px}.mb8{margin-bottom:8px}.mb12{margin-bottom:12px}.mb16{margin-bottom:16px}.posrel{position:relative}.posabs{position:absolute}.round{border-radius:50%}.ohide{overflow:hidden}.bbox{box-sizing:border-box}.pointer{cursor:pointer}.w100{width:100%}.b0{border:none}" + ); + + // src/helmet.ts + var DESIGN_DOC_MODE_RE = /]*\bname\s*=\s*["']design_doc_mode["'][^>]*\b(?:content|value)\s*=\s*["'](\w+)["']/i; + var CANVAS_BG_LIGHT = "#f0eee6"; + var CANVAS_BG_DARK = "#2e2c26"; + function createHelmetManager(doc, isStreaming) { + const mounted = /* @__PURE__ */ new Set(); + const live = /* @__PURE__ */ new Map(); + let designDocMode = null; + let canvasStyleEl = null; + let appTheme = "light"; + try { + const ds = doc.documentElement.dataset.theme; + appTheme = ds === "dark" || ds === "light" ? ds : new URLSearchParams(doc.defaultView?.location.search ?? "").get( + "theme" + ) === "dark" ? "dark" : "light"; + } catch { + } + function applyCanvasBg() { + if (!canvasStyleEl) return; + const bg = appTheme === "dark" ? CANVAS_BG_DARK : CANVAS_BG_LIGHT; + canvasStyleEl.textContent = `html,body{background:${bg}}#dc-root>.sc-host{position:relative}`; + } + function postDesignMode(mode) { + if (window.parent === window) return; + try { + window.parent.postMessage({ type: "__dc_design_mode", mode }, "*"); + } catch { + } + } + function setDesignDocMode(mode) { + if (mode === designDocMode) return; + designDocMode = mode; + postDesignMode(mode); + if (mode === "canvas") { + doc.documentElement.setAttribute("data-dc-canvas", ""); + canvasStyleEl = doc.createElement("style"); + canvasStyleEl.setAttribute("data-dc-canvas", ""); + applyCanvasBg(); + doc.head.appendChild(canvasStyleEl); + } else { + doc.documentElement.removeAttribute("data-dc-canvas"); + canvasStyleEl?.remove(); + canvasStyleEl = null; + } + } + window.addEventListener("message", (e) => { + const type = e.data && e.data.type; + if (type === "__dc_theme") { + const t = e.data.theme; + if (t === "light" || t === "dark") { + appTheme = t; + applyCanvasBg(); + } + return; + } + if (!designDocMode || type !== "__dc_probe") return; + postDesignMode(designDocMode); + }); + function compile(node) { + const raw = [...node.children]; + const helmetClosed = node.nextSibling != null || node.parentNode?.nextSibling != null; + if (node.hasAttribute("data-dc-atomics") && !mounted.has("__dc-atomics")) { + mounted.add("__dc-atomics"); + const el = doc.createElement("style"); + el.id = "__dc-atomics"; + el.textContent = ATOMIC_CSS; + doc.head.appendChild(el); + } + return (_vals, ctx) => { + const name = ctx && ctx.__name || ""; + const streaming = !!(name && isStreaming(name)); + for (let i = 0; i < raw.length; i++) { + const child = raw[i]; + const tag = child.tagName; + const mayBePartial = streaming && !helmetClosed && i === raw.length - 1; + if (tag === "SCRIPT") { + if (mayBePartial) continue; + const key = "SCRIPT|" + (child.getAttribute("src") || child.textContent || ""); + if (mounted.has(key)) continue; + mounted.add(key); + const el = doc.createElement("script"); + for (const { name: an, value } of [...child.attributes]) + el.setAttribute(an, value); + if (child.textContent) el.textContent = child.textContent; + doc.head.appendChild(el); + } else if (tag === "LINK" || tag === "META") { + if (mayBePartial) continue; + const key = tag + "|" + (child.getAttribute("href") || child.getAttribute("src") || child.outerHTML); + if (mounted.has(key)) continue; + mounted.add(key); + if (tag === "LINK") { + const rel = (child.getAttribute("rel") || "").toLowerCase().split(/\s+/); + const href = (child.getAttribute("href") || "").trim(); + const res = window.__resources; + const pre = res && rel.includes("stylesheet") && !rel.includes("alternate") ? res[href] : void 0; + const blob = typeof pre === "string" && pre ? bundledBlob(pre) : null; + if (blob) { + const el = doc.createElement("style"); + if (child.hasAttribute("disabled")) { + el.setAttribute("media", "not all"); + } else if (child.getAttribute("media")) { + el.setAttribute("media", child.getAttribute("media")); + } + if (child.getAttribute("title")) + el.setAttribute("title", child.getAttribute("title")); + void blob.text().then((css) => { + el.textContent = css; + }); + doc.head.appendChild(el); + continue; + } + } + doc.head.appendChild(child.cloneNode(true)); + } else { + const key = name + "|" + i; + let el = live.get(key); + if (!el || el.tagName !== tag) { + if (el) el.remove(); + el = doc.createElement(tag.toLowerCase()); + live.set(key, el); + doc.head.appendChild(el); + } + for (const { name: an, value } of [...child.attributes]) { + if (el.getAttribute(an) !== value) el.setAttribute(an, value); + } + if (el.textContent !== child.textContent) + el.textContent = child.textContent; + } + } + return null; + }; + } + return { compile, setDesignDocMode }; + } + + // src/pseudo.ts + function scanUnquotedUrl(css, i) { + if (css[i] !== "u" && css[i] !== "U" || css.slice(i, i + 4).toLowerCase() !== "url(" || /[a-z0-9_-]/i.test(css[i - 1] ?? "")) { + return -1; + } + let j = i + 4; + while (j < css.length && /\s/.test(css[j])) j++; + if (css[j] === '"' || css[j] === "'") return -1; + while (j < css.length && css[j] !== ")") { + if (css[j] === "\\") j++; + j++; + } + return j < css.length ? j + 1 : css.length; + } + function stripComments(css) { + let out = ""; + let quote = ""; + for (let i = 0; i < css.length; i++) { + const c = css[i]; + if (quote) { + if (c === "\\") { + out += c + (css[i + 1] ?? ""); + i++; + continue; + } + if (c === quote) quote = ""; + out += c; + } else if (c === "'" || c === '"') { + quote = c; + out += c; + } else if (c === "/" && css[i + 1] === "*") { + const end = css.indexOf("*/", i + 2); + i = end === -1 ? css.length : end + 1; + out += " "; + } else { + const end = scanUnquotedUrl(css, i); + if (end === -1) out += c; + else { + out += css.slice(i, end); + i = end - 1; + } + } + } + return out; + } + function importantify(css) { + css = stripComments(css); + const decls = []; + let start = 0; + let depth = 0; + let quote = ""; + for (let i = 0; i < css.length; i++) { + const c = css[i]; + if (quote) { + if (c === "\\") i++; + else if (c === quote) quote = ""; + } else if (c === "'" || c === '"') quote = c; + else if (c === "(") depth++; + else if (c === ")") depth = Math.max(0, depth - 1); + else if (c === ";" && depth === 0) { + decls.push(css.slice(start, i)); + start = i + 1; + } else { + const end = scanUnquotedUrl(css, i); + if (end !== -1) i = end - 1; + } + } + decls.push(css.slice(start)); + return decls.map((d) => d.trim()).filter(Boolean).map((d) => /!\s*important$/i.test(d) ? d : d + " !important").join(";"); + } + function createPseudoSheet(doc) { + let el = null; + const cache = /* @__PURE__ */ new Map(); + let n = 0; + return (pseudo, css) => { + const k = pseudo + "|" + css; + const hit = cache.get(k); + if (hit) return hit; + if (!el) { + el = doc.createElement("style"); + doc.head.appendChild(el); + } + const cls = "scp" + (n++).toString(36); + const isPseudoElement = pseudo === "before" || pseudo === "after"; + const sel = isPseudoElement ? "." + cls + "::" + pseudo : "." + cls + ":" + pseudo; + el.sheet.insertRule( + sel + "{" + (isPseudoElement ? css : importantify(css)) + "}", + el.sheet.cssRules.length + ); + cache.set(k, cls); + return cls; + }; + } + + // src/registry.ts + function createRegistry() { + const entries = /* @__PURE__ */ Object.create(null); + function get(name) { + return entries[name] || (entries[name] = { + html: "", + tpl: null, + Logic: null, + jsStreaming: false, + htmlStreaming: false, + ver: 0, + subs: /* @__PURE__ */ new Set(), + fetched: false + }); + } + function bump(name) { + const r = get(name); + r.ver++; + for (const fn of r.subs) fn(); + } + return { + entries, + get, + bump, + bumpAll() { + for (const n in entries) bump(n); + } + }; + } + + // src/runtime.ts + var COMPONENT_DIR = "."; + function createRuntime(doc = document) { + const registry = createRegistry(); + const pseudoClass = createPseudoSheet(doc); + const helmet = createHelmetManager( + doc, + (name) => registry.get(name).htmlStreaming + ); + const external = createExternalModules(() => registry.bumpAll()); + const factory = createComponentFactory(registry, ensureFetched); + const host = { + component: (name) => factory.getDC(name), + placeholder: (props) => h(Placeholder, props), + helmet: (node) => helmet.compile(node), + loadExternal: (kind, url, after) => external.load(kind, url, after), + resolveExternal: (url, name) => external.resolve(url, name), + resolveExternalGlobal: (url, name) => external.resolveGlobal(url, name), + resolveExternalError: (url, name) => external.getError(url, name), + pseudoClass + }; + function ensureFetched(name) { + const r = registry.get(name); + if (r.fetched) return; + r.fetched = true; + const url = COMPONENT_DIR + "/" + encodeURIComponent(name) + ".dc.html"; + const res = window.__resources; + const pre = res ? res[url] : void 0; + const target = typeof pre === "string" && pre ? pre : url; + const blob = bundledBlob(target); + (blob ? blob.text() : fetch(target).then((res2) => { + if (!res2.ok) { + console.error( + '[dc-runtime] sibling fetch for "' + name + '" failed:', + url, + "returned", + res2.status, + "\u2014 the reference renders as an empty placeholder." + ); + return ""; + } + return res2.text(); + })).then((t) => { + if (!t) return; + const parsed = parseDcText(t); + if (!parsed) { + console.error( + '[dc-runtime] sibling fetch for "' + name + '":', + url, + "has no block \u2014 not a Design Component." + ); + return; + } + if (parsed.props) r.propsMeta = parsed.props; + if (parsed.preview) r.preview = parsed.preview; + if (parsed.template && !r.html) updateHtml(name, parsed.template); + if (parsed.js && !r.Logic) updateJs(name, parsed.js); + }).catch( + (e) => console.error( + '[dc-runtime] sibling fetch for "' + name + '" threw:', + url, + e + ) + ); + } + let rootName = null; + function updateHtml(name, html) { + const r = registry.get(name); + r.html = html; + if (name === rootName) { + const mode = DESIGN_DOC_MODE_RE.exec(html)?.[1] ?? null; + if (mode || !r.htmlStreaming) helmet.setDesignDocMode(mode); + } + try { + r.tpl = compileTemplate(html, host); + } catch (e) { + console.error("[dc-runtime] template compile FAILED for", name, e); + } + registry.bump(name); + } + function updateJs(name, src) { + const r = registry.get(name); + const seq = r.jsSeq = (r.jsSeq || 0) + 1; + try { + const Cls = evalDcLogic(src); + if (r.jsSeq !== seq) return; + if (typeof Cls !== "function") { + r.logicError = name + ".dc.html: getScripts(); ?> diff --git a/Services/CurrencyRate.php b/Services/CurrencyRate.php new file mode 100644 index 0000000..2ac5ddc --- /dev/null +++ b/Services/CurrencyRate.php @@ -0,0 +1,47 @@ +rate('EUR'); // float|null + */ +class CurrencyRate +{ + /** @var array Кэш клиентов по имени подключения */ + protected static array $_instances = []; + + /** + * @param string|null $name Имя подключения из конфига; null — 'default' + * @return Client + * @throws MyException Если подключение не описано в конфиге + */ + public static function instance(?string $name = null): Client + { + $name ??= 'default'; + + if (!isset(self::$_instances[$name])) { + $config = Config::get('currency_rate', $name); + + if ($config === null) { + throw new MyException('Подключение currency_rate не описано в конфиге: :name', [':name' => $name]); + } + + self::$_instances[$name] = new Client($config); + } + + return self::$_instances[$name]; + } +} diff --git a/Services/CurrencyRate/Client.php b/Services/CurrencyRate/Client.php new file mode 100644 index 0000000..7a2a444 --- /dev/null +++ b/Services/CurrencyRate/Client.php @@ -0,0 +1,141 @@ +|null Разобранные курсы текущего запроса (в памяти процесса) */ + protected ?array $rates = null; + + /** + * @param array $config url (XML_daily.asp), timeout (сек, по умолчанию 10), + * cache_path (файл кэша), ttl (сек, по умолчанию 86400) + */ + public function __construct(protected array $config) + { + } + + /** + * Курс за 1 единицу валюты в рублях. + * + * @param string $code ISO 4217, например 'EUR' + * @return float|null null — RUB (курс 1 не нужен, но метод его не отдаёт отдельно, см. ниже), + * код не найден в ответе ЦБ, либо курс сейчас недоступен (сеть/парсинг) + */ + public function rate(string $code): ?float + { + $code = strtoupper($code); + + if ($code === 'RUB') { + return 1.0; + } + + return $this->rates()[$code] ?? null; + } + + /** + * @return array code => курс за 1 единицу в рублях; пустой массив при ошибке + */ + protected function rates(): array + { + if ($this->rates !== null) { + return $this->rates; + } + + $cache_path = $this->config['cache_path'] ?? (APPPATH . '/cache/currency_rate.json'); + $ttl = (int)($this->config['ttl'] ?? 86400); + + if (is_file($cache_path) && (time() - filemtime($cache_path)) < $ttl) { + $cached = json_decode((string)file_get_contents($cache_path), true); + + if (is_array($cached)) { + return $this->rates = $cached; + } + } + + $rates = $this->fetch(); + + if ($rates !== null) { + @file_put_contents($cache_path, json_encode($rates)); + + return $this->rates = $rates; + } + + // Сеть недоступна — отдаём протухший кэш, если он вообще есть, чем ничего. + if (is_file($cache_path)) { + $cached = json_decode((string)file_get_contents($cache_path), true); + + if (is_array($cached)) { + return $this->rates = $cached; + } + } + + return $this->rates = []; + } + + /** + * @return array|null null при любой ошибке сети/парсинга + */ + protected function fetch(): ?array + { + try { + $response = (new Curl())->execute( + HttpRequest::factory($this->config['url'] ?? 'https://www.cbr.ru/scripts/XML_daily.asp') + ->method('GET') + ->timeout((int)($this->config['timeout'] ?? 10)) + ); + } catch (RuntimeException) { + return null; + } + + if (!$response->isSuccess()) { + return null; + } + + // Ответ ЦБ — windows-1251, не UTF-8. + $xml_body = @iconv('windows-1251', 'UTF-8//IGNORE', $response->body()); + + if ($xml_body === false) { + return null; + } + + try { + $xml = new \SimpleXMLElement($xml_body); + } catch (Throwable) { + return null; + } + + $rates = []; + + foreach ($xml->Valute as $valute) { + $code = (string)$valute->CharCode; + $nominal = (float)str_replace(',', '.', (string)$valute->Nominal); + $value = (float)str_replace(',', '.', (string)$valute->Value); + + if ($code === '' || $nominal <= 0) { + continue; + } + + $rates[$code] = $value / $nominal; + } + + return $rates; + } +} diff --git a/composer.lock b/composer.lock index 6f5e1f0..bc079d3 100644 --- a/composer.lock +++ b/composer.lock @@ -1608,20 +1608,20 @@ "packages-dev": [ { "name": "myclabs/deep-copy", - "version": "1.13.4", + "version": "1.14.0", "source": { "type": "git", "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", - "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", "shasum": "" }, "require": { - "php": "^7.1 || ^8.0" + "php": "^8.0" }, "conflict": { "doctrine/collections": "<1.6.8", @@ -1656,15 +1656,15 @@ ], "support": { "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + "source": "https://github.com/myclabs/DeepCopy/tree/1.14.0" }, "funding": [ { - "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", - "type": "tidelift" + "url": "https://github.com/mnapoli", + "type": "github" } ], - "time": "2025-08-01T08:46:24+00:00" + "time": "2026-08-11T10:17:44+00:00" }, { "name": "nikic/php-parser", diff --git a/schema.sql b/schema.sql index 98f1efa..0582fdb 100644 --- a/schema.sql +++ b/schema.sql @@ -23,6 +23,8 @@ CREATE TABLE accounts ( summa DECIMAL(15, 2) NOT NULL DEFAULT 0, currency CHAR(3) NOT NULL DEFAULT 'RUB', -- ISO 4217; курс — Services\CurrencyRate (ЦБ РФ), не хранится тут description VARCHAR(255) NULL, -- свободное поле, например "счёт жены" — без отдельной структуры + color VARCHAR(20) NULL, -- пользовательский акцент счёта (oklch(...) строкой); NULL — цвет по умолчанию для type_acc + privacy ENUM ('shared', 'personal') NOT NULL DEFAULT 'shared', -- визуальный признак из дизайн-хендоффа; логики доступа за ним нет (мультипользовательский режим не в приоритете) include_in_total TINYINT(1) NOT NULL DEFAULT 1, -- чисто ручной переключатель пользователя, не системное правило по типу/валюте (дефолт 0 для savings — просто стартовое значение) is_delete TINYINT(1) NOT NULL DEFAULT 0, `order` INT NOT NULL DEFAULT 0, @@ -55,10 +57,28 @@ CREATE TABLE goals ( CONSTRAINT fk_goals_account FOREIGN KEY (account_id) REFERENCES accounts (id) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci; +-- Группа статей — самостоятельная сущность, не статья с детьми. Раньше группой считалась +-- обычная строка categories, на которую другие строки ссылались через parent_id — это позволяло +-- одной и той же записи «случайно» становиться то листом (со своим планом в budgets), то группой, +-- и при добавлении первого ребёнка старый план листа не чистился, оставаясь мусором в budgets. +-- Разделение на две таблицы делает такое структурно невозможным: budgets.categorie_id может +-- указывать только на categories (см. ниже), у group_id там просто нет своей строки в budgets. +CREATE TABLE category_groups ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id INT UNSIGNED NOT NULL, + title VARCHAR(128) NOT NULL, + type ENUM ('income', 'expense') NOT NULL, + is_delete TINYINT(1) NOT NULL DEFAULT 0, + `order` INT NOT NULL DEFAULT 0, + PRIMARY KEY (id), + KEY ix_category_groups_user_id (user_id), + CONSTRAINT fk_category_groups_user FOREIGN KEY (user_id) REFERENCES users (id) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci; + CREATE TABLE categories ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, user_id INT UNSIGNED NOT NULL, - parent_id INT UNSIGNED NULL, + group_id INT UNSIGNED NULL, -- NULL — статья без группы (верхний уровень), см. category_groups title VARCHAR(128) NOT NULL, type ENUM ('income', 'expense') NOT NULL, is_delete TINYINT(1) NOT NULL DEFAULT 0, -- архивация вместо удаления, тот же приём что у accounts — сохраняет историю транзакций @@ -69,10 +89,10 @@ CREATE TABLE categories ( formula_pct DECIMAL(5, 2) NULL, PRIMARY KEY (id), KEY ix_categories_user_id (user_id), - KEY ix_categories_parent_id (parent_id), + KEY ix_categories_group_id (group_id), CONSTRAINT fk_categories_user FOREIGN KEY (user_id) REFERENCES users (id), - -- удаление родителя не должно тянуть за собой подкатегории — они просто станут верхнего уровня - CONSTRAINT fk_categories_parent FOREIGN KEY (parent_id) REFERENCES categories (id) ON DELETE SET NULL, + -- удаление группы не должно тянуть за собой статьи — они просто станут без группы + CONSTRAINT fk_categories_group FOREIGN KEY (group_id) REFERENCES category_groups (id) ON DELETE SET NULL, CONSTRAINT fk_categories_linked_expense FOREIGN KEY (linked_expense_categorie_id) REFERENCES categories (id) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci; diff --git a/tests/Unit/CategoryRepositoryTest.php b/tests/Unit/CategoryRepositoryTest.php index 3d3fc28..cfc9292 100644 --- a/tests/Unit/CategoryRepositoryTest.php +++ b/tests/Unit/CategoryRepositoryTest.php @@ -17,6 +17,10 @@ use System\Classes\Repository; * подменяем таблицу на временную через прямой вызов Repository::__construct(), минуя * CategoryRepository::__construct() (тот же приём, что и RepositoryTestItemRepository * в RepositoryTest.php, только поверх реального класса, а не заглушки). + * + * Группа (category_groups) — самостоятельная сущность (см. схему), но на уровне + * CategoryRepository это просто INT-колонка group_id без FK — сюда её реальным существованием + * можно не заниматься, getTree()/findDuplicate() работают с сырым значением как есть. */ class CategoryRepositoryTestDouble extends CategoryRepository { @@ -47,7 +51,7 @@ class CategoryRepositoryTest extends TestCase 'CREATE TABLE category_repository_test_categories ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, user_id INT UNSIGNED NOT NULL, - parent_id INT UNSIGNED NULL, + group_id INT UNSIGNED NULL, title VARCHAR(128) NOT NULL, type ENUM(\'income\',\'expense\') NOT NULL, is_delete TINYINT(1) NOT NULL DEFAULT 0, @@ -77,10 +81,10 @@ class CategoryRepositoryTest extends TestCase $prop->setValue(null, []); } - public function testFindActiveDuplicateFindsSameTitleTypeParent(): void + public function testFindActiveDuplicateFindsSameTitleTypeGroup(): void { $this->repository->create([ - 'user_id' => 1, 'parent_id' => null, 'title' => 'Зарплата', 'type' => 'income', + 'user_id' => 1, 'group_id' => null, 'title' => 'Зарплата', 'type' => 'income', 'is_delete' => false, 'order' => 0, ]); @@ -93,7 +97,7 @@ class CategoryRepositoryTest extends TestCase public function testFindActiveDuplicateIgnoresDifferentType(): void { $this->repository->create([ - 'user_id' => 1, 'parent_id' => null, 'title' => 'Прочее', 'type' => 'income', + 'user_id' => 1, 'group_id' => null, 'title' => 'Прочее', 'type' => 'income', 'is_delete' => false, 'order' => 0, ]); @@ -103,7 +107,7 @@ class CategoryRepositoryTest extends TestCase public function testFindActiveDuplicateExcludesGivenId(): void { $id = $this->repository->create([ - 'user_id' => 1, 'parent_id' => null, 'title' => 'Транспорт', 'type' => 'expense', + 'user_id' => 1, 'group_id' => null, 'title' => 'Транспорт', 'type' => 'expense', 'is_delete' => false, 'order' => 0, ]); @@ -113,7 +117,7 @@ class CategoryRepositoryTest extends TestCase public function testFindArchivedDuplicateOnlyMatchesArchived(): void { $this->repository->create([ - 'user_id' => 1, 'parent_id' => null, 'title' => 'Такси', 'type' => 'expense', + 'user_id' => 1, 'group_id' => null, 'title' => 'Такси', 'type' => 'expense', 'is_delete' => true, 'order' => 0, ]); @@ -125,28 +129,32 @@ class CategoryRepositoryTest extends TestCase $this->assertSame('Такси', $archived->title); } - public function testGetTreeGroupsByParentId(): void + public function testGetTreeGroupsByGroupId(): void { - $group_id = $this->repository->create([ - 'user_id' => 1, 'parent_id' => null, 'title' => 'Транспорт', 'type' => 'expense', + // group_id тут условный (42) — CategoryRepository не проверяет, что за ним реально стоит + // строка category_groups, это забота CategoriesController::parseGroupId(). + $group_id = 42; + + $this->repository->create([ + 'user_id' => 1, 'group_id' => null, 'title' => 'Без группы', 'type' => 'expense', 'is_delete' => false, 'order' => 0, ]); $this->repository->create([ - 'user_id' => 1, 'parent_id' => $group_id, 'title' => 'Такси', 'type' => 'expense', + 'user_id' => 1, 'group_id' => $group_id, 'title' => 'Такси', 'type' => 'expense', 'is_delete' => false, 'order' => 0, ]); // архивная — не должна попасть в дерево $this->repository->create([ - 'user_id' => 1, 'parent_id' => $group_id, 'title' => 'Метро', 'type' => 'expense', + 'user_id' => 1, 'group_id' => $group_id, 'title' => 'Метро', 'type' => 'expense', 'is_delete' => true, 'order' => 0, ]); $tree = $this->repository->getTree(1); $this->assertCount(1, $tree[0]); - $this->assertSame('Транспорт', $tree[0][0]->title); + $this->assertSame('Без группы', $tree[0][0]->title); $this->assertCount(1, $tree[$group_id]); $this->assertSame('Такси', $tree[$group_id][0]->title); } @@ -154,11 +162,11 @@ class CategoryRepositoryTest extends TestCase public function testGetTreeScopedByUser(): void { $this->repository->create([ - 'user_id' => 1, 'parent_id' => null, 'title' => 'Моя статья', 'type' => 'expense', + 'user_id' => 1, 'group_id' => null, 'title' => 'Моя статья', 'type' => 'expense', 'is_delete' => false, 'order' => 0, ]); $this->repository->create([ - 'user_id' => 2, 'parent_id' => null, 'title' => 'Чужая статья', 'type' => 'expense', + 'user_id' => 2, 'group_id' => null, 'title' => 'Чужая статья', 'type' => 'expense', 'is_delete' => false, 'order' => 0, ]); diff --git a/tests/Unit/CurrencyRateClientTest.php b/tests/Unit/CurrencyRateClientTest.php new file mode 100644 index 0000000..e0af09d --- /dev/null +++ b/tests/Unit/CurrencyRateClientTest.php @@ -0,0 +1,62 @@ +cache_path = sys_get_temp_dir() . '/currency_rate_test_' . uniqid() . '.json'; + } + + protected function tearDown(): void + { + if (is_file($this->cache_path)) { + unlink($this->cache_path); + } + } + + public function testRubAlwaysReturnsOne(): void + { + $client = new Client(['url' => 'https://unreachable.invalid/', 'timeout' => 1, 'cache_path' => $this->cache_path]); + + $this->assertSame(1.0, $client->rate('rub')); + $this->assertSame(1.0, $client->rate('RUB')); + } + + public function testUnreachableSourceReturnsNullWithoutThrowing(): void + { + $client = new Client(['url' => 'https://unreachable.invalid/', 'timeout' => 1, 'cache_path' => $this->cache_path]); + + $this->assertNull($client->rate('EUR')); + } + + public function testReadsFromFreshCacheWithoutNetwork(): void + { + file_put_contents($this->cache_path, json_encode(['EUR' => 104.5])); + + // url заведомо невалиден — если бы клиент реально пошёл в сеть, тест бы не прошёл (null). + $client = new Client(['url' => 'not-a-url', 'timeout' => 1, 'cache_path' => $this->cache_path, 'ttl' => 86400]); + + $this->assertSame(104.5, $client->rate('EUR')); + } + + public function testUnknownCodeReturnsNull(): void + { + file_put_contents($this->cache_path, json_encode(['EUR' => 104.5])); + + $client = new Client(['url' => 'not-a-url', 'timeout' => 1, 'cache_path' => $this->cache_path, 'ttl' => 86400]); + + $this->assertNull($client->rate('XXX')); + } +} diff --git a/tests/Unit/CurrencyRateTest.php b/tests/Unit/CurrencyRateTest.php new file mode 100644 index 0000000..5f627c2 --- /dev/null +++ b/tests/Unit/CurrencyRateTest.php @@ -0,0 +1,42 @@ +getProperty('_instances'); + $prop->setAccessible(true); + $prop->setValue(null, []); + + $ref = new ReflectionClass(Config::class); + $prop = $ref->getProperty('config'); + $prop->setAccessible(true); + $prop->setValue(null, null); + } + + public function testDefaultConnectionReturnsClient(): void + { + $this->assertInstanceOf(Client::class, CurrencyRate::instance()); + } + + public function testInstanceIsCachedPerConnectionName(): void + { + $this->assertSame(CurrencyRate::instance('default'), CurrencyRate::instance('default')); + } + + public function testUnknownConnectionNameThrows(): void + { + $this->expectException(MyException::class); + CurrencyRate::instance('nonexistent'); + } +}