Merge remote-tracking branch 'forked/master'

This commit is contained in:
vinceh121 2024-07-30 22:33:37 +02:00
commit cd1c555973
No known key found for this signature in database
GPG Key ID: 780725DCACF96F16
23 changed files with 561 additions and 365 deletions

View File

@ -42,12 +42,6 @@ Clone the repository:
git clone https://github.com/maelgangloff/domain-watchdog.git
```
Navigate to the project directory:
```shell
cd domain-watchdog
```
#### Backend
1. Install dependencies:
@ -66,7 +60,6 @@ cd domain-watchdog
```shell
php bin/console doctrine:migrations:migrate
```
```
5. Start the Symfony server:
```shell
symfony server:start
@ -74,6 +67,46 @@ cd domain-watchdog
#### Frontend
1. Install dependencies:
```shell
yarn install
```
2. Generate language files:
```shell
yarn run ttag:po2json
```
3. Make the final build:
```shell
yarn build
```
## Update
**Any updates are your responsibility. Make a backup of the data if necessary.**
Fetch updates from the remote repository:
```shell
git pull origin master
```
### Backend
1. Install dependencies:
```shell
composer install
```
2. Run database migrations:
```shell
php bin/console doctrine:migrations:migrate
```
3. Clearing the Symfony cache:
```shell
php bin/console cache:clear
```
### Frontend
1. Install dependencies:
```shell
yarn install

View File

@ -142,7 +142,7 @@ export default function App() {
{
key: 'connectors',
icon: <ApiOutlined/>,
label: t`My connectors`,
label: t`My Connectors`,
disabled: !isAuthenticated,
onClick: () => navigate('/tracking/connectors')
}

View File

@ -1,9 +1,15 @@
import {Button, Form, FormInstance, Input, Select, Space, Typography} from "antd";
import {Button, Checkbox, Form, FormInstance, Input, Select, Space, Typography} from "antd";
import React, {useState} from "react";
import {Connector, ConnectorProvider} from "../../utils/api/connectors";
import {t} from "ttag";
import {BankOutlined} from "@ant-design/icons";
import {regionNames} from "../../i18n";
import {
ovhEndpointList as ovhEndpointListFunction,
ovhFields as ovhFieldsFunction,
ovhPricingMode as ovhPricingModeFunction,
ovhSubsidiaryList as ovhSubsidiaryListFunction
} from "../../utils/providers/ovh";
import {helpGetTokenLink, tosHyperlink} from "../../utils/providers";
const formItemLayoutWithOutLabel = {
wrapperCol: {
@ -14,31 +20,10 @@ const formItemLayoutWithOutLabel = {
export function ConnectorForm({form, onCreate}: { form: FormInstance, onCreate: (values: Connector) => void }) {
const [provider, setProvider] = useState<string>()
const ovhFields = {
appKey: t`Application key`,
appSecret: t`Application secret`,
consumerKey: t`Consumer key`
}
const ovhEndpointList = [
{
label: t`European Region`,
value: 'ovh-eu'
}
]
const ovhSubsidiaryList = [{value: 'EU', label: t`Europa`}, ...[
'CZ', 'DE', 'ES', 'FI', 'FR', 'GB', 'IE', 'IT', 'LT', 'MA', 'NL', 'PL', 'PT', 'SN', 'TN'
].map(c => ({value: c, label: regionNames.of(c) ?? c}))]
const ovhPricingMode = [
{value: 'create-default', label: t`The domain is free and at the standard price`},
{
value: 'create-premium',
label: t`The domain is free but is a premium. Its price varies from one domain to another`
}
]
const ovhFields = ovhFieldsFunction()
const ovhEndpointList = ovhEndpointListFunction()
const ovhSubsidiaryList = ovhSubsidiaryListFunction()
const ovhPricingMode = ovhPricingModeFunction()
return <Form
{...formItemLayoutWithOutLabel}
@ -51,9 +36,11 @@ export function ConnectorForm({form, onCreate}: { form: FormInstance, onCreate:
<Form.Item
label={t`Provider`}
name="provider"
help={helpGetTokenLink(provider)}
rules={[{required: true, message: t`Required`}]}
>
<Select
allowClear
placeholder={t`Please select a Provider`}
suffixIcon={<BankOutlined/>}
options={Object.keys(ConnectorProvider).map((c) => ({
@ -66,22 +53,19 @@ export function ConnectorForm({form, onCreate}: { form: FormInstance, onCreate:
}))}
value={provider}
onChange={setProvider}
autoFocus
/>
</Form.Item>
{
provider === ConnectorProvider.OVH && <>
<Typography.Link target='_blank'
href="https://api.ovh.com/createToken/index.cgi?GET=/*&PUT=/*&POST=/*&DELETE=/*">
Retrieve a token set from the OVH API
</Typography.Link>
{
Object.keys(ovhFields).map(fieldName => <Form.Item
label={ovhFields[fieldName as keyof typeof ovhFields]}
name={['authData', fieldName]}
rules={[{required: true, message: t`Required`}]}
>
<Input/>
<Input autoComplete='off'/>
</Form.Item>)
}
<Form.Item
@ -106,6 +90,37 @@ export function ConnectorForm({form, onCreate}: { form: FormInstance, onCreate:
>
<Select options={ovhPricingMode} optionFilterProp="label"/>
</Form.Item>
<Form.Item
valuePropName="checked"
label={t`API Terms of Service`}
name={['authData', 'acceptConditions']}
rules={[{required: true, message: t`Required`}]}
>
<Checkbox
required={true}>
<Typography.Link target='_blank' href={tosHyperlink(provider)}>
{t`I certify that I have read and accepted the conditions of use of the Provider API, accessible from this hyperlink`}
</Typography.Link>
</Checkbox>
</Form.Item>
<Form.Item
valuePropName="checked"
label={t`Legal age`}
name={['authData', 'ownerLegalAge']}
rules={[{required: true, message: t`Required`}]}
>
<Checkbox
required={true}>{t`I certify on my honor that I am of the minimum age required to consent to these conditions`}</Checkbox>
</Form.Item>
<Form.Item
valuePropName="checked"
label={t`Withdrawal period`}
name={['authData', 'waiveRetractationPeriod']}
rules={[{required: true, message: t`Required`}]}
>
<Checkbox
required={true}>{t`I expressly waive my right of withdrawal regarding the purchase of domain names via the Provider's API`}</Checkbox>
</Form.Item>
</>
}

View File

@ -1,7 +1,7 @@
import {Button, Form, FormInstance, Input, Select, Space} from "antd";
import {t} from "ttag";
import {MinusCircleOutlined, PlusOutlined, ThunderboltFilled} from "@ant-design/icons";
import React, {useState} from "react";
import {ApiOutlined, MinusCircleOutlined, PlusOutlined, ThunderboltFilled} from "@ant-design/icons";
import React from "react";
import {EventAction} from "../../utils/api";
import {Connector} from "../../utils/api/connectors";
@ -73,13 +73,8 @@ export function WatchlistForm({form, connectors, onCreateWatchlist}: {
{
label: t`Send me an email`,
value: 'email'
},
{
label: t`Buy the domain if available`,
value: 'buy'
}
]
const [actionsSelect, setActionsSelect] = useState<{ [key: number]: string }>({})
return <Form
{...formItemLayoutWithOutLabel}
@ -186,27 +181,8 @@ export function WatchlistForm({form, connectors, onCreateWatchlist}: {
noStyle name={[field.name, 'action']}>
<Select style={{minWidth: 300}} options={triggerActionItems} showSearch
placeholder={t`Then do that`}
optionFilterProp="label" value={actionsSelect[field.key]}
onChange={(e) => setActionsSelect({...actionsSelect, [field.key]: e})}/>
optionFilterProp="label"/>
</Form.Item>
{actionsSelect[field.key] === 'buy' && <Form.Item {...field}
validateTrigger={['onChange', 'onBlur']}
rules={[{
required: true,
message: t`Required`
}]}
noStyle
name={[field.name, 'connector']}>
<Select style={{minWidth: 500}} showSearch
placeholder={t`Connector`}
optionFilterProp="label"
options={connectors.map(c => ({
label: `${c.provider} (${c.id})`,
value: c.id
}))}
/>
</Form.Item>
}
</Space>
{fields.length > 1 ? (
@ -231,6 +207,21 @@ export function WatchlistForm({form, connectors, onCreateWatchlist}: {
</>
)}
</Form.List>
<Form.Item label={t`Connector`}
name='connector'
>
<Select showSearch
allowClear
style={{width: '60%'}}
placeholder={t`Connector`}
suffixIcon={<ApiOutlined/>}
optionFilterProp="label"
options={connectors.map(c => ({
label: `${c.provider} (${c.id})`,
value: c.id
}))}
/>
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit">

View File

@ -52,7 +52,7 @@ export default function Page() {
name="username"
rules={[{required: true, message: t`Required`}]}
>
<Input/>
<Input autoFocus/>
</Form.Item>
<Form.Item<FieldType>

View File

@ -20,13 +20,14 @@ export default function WatchlistPage() {
const onCreateWatchlist = (values: {
domains: string[],
triggers: { event: string, action: string, connector?: string }[]
connector?: string
}) => {
const domainsURI = values.domains.map(d => '/api/domains/' + d)
postWatchlist(domainsURI, values.triggers.map(({action, event, connector}) => ({
action,
event,
connector: connector !== undefined ? '/api/connectors/' + connector : undefined
}))).then((w) => {
postWatchlist({
domains: domainsURI,
triggers: values.triggers,
connector: values.connector !== undefined ? '/api/connectors/' + values.connector : undefined
}).then((w) => {
form.resetFields()
refreshWatchlists()
messageApi.success(t`Watchlist created !`)

View File

@ -16,6 +16,8 @@ export type EventAction =
| 'enum validation expiration'
| string
export type TriggerAction = 'email' | string
export interface Event {
action: EventAction
date: string
@ -62,9 +64,10 @@ export interface User {
roles: string[]
}
export interface Watchlist {
domains: string[]
triggers: Event[]
export interface Watchlist {
domains: string[],
triggers: { event: EventAction, action: TriggerAction }[],
connector?: string
}
export async function request<T = any, R = AxiosResponse<T>, D = any>(config: AxiosRequestConfig): Promise<R> {

View File

@ -1,4 +1,4 @@
import {Event, EventAction, request, Watchlist} from "./index";
import {Event, request, Watchlist} from "./index";
export async function getWatchlists() {
const response = await request({
@ -8,24 +8,17 @@ export async function getWatchlists() {
}
export async function getWatchlist(token: string) {
const response = await request<Watchlist>({
const response = await request<Watchlist & { token: string }>({
url: 'watchlists/' + token
})
return response.data
}
export async function postWatchlist(domains: string[], triggers: {
action: string,
event: EventAction,
connector?: string
}[]) {
export async function postWatchlist(watchlist: Watchlist) {
const response = await request<{ token: string }>({
method: 'POST',
url: 'watchlists',
data: {
domains,
triggers
},
data: watchlist,
headers: {
"Content-Type": 'application/json'
}

View File

@ -0,0 +1,25 @@
import {ConnectorProvider} from "../api/connectors";
import {Typography} from "antd";
import {t} from "ttag";
import React from "react";
export const helpGetTokenLink = (provider?: string) => {
switch (provider) {
case ConnectorProvider.OVH:
return <Typography.Link target='_blank'
href="https://api.ovh.com/createToken/index.cgi?GET=/order/cart/*&POST=/order/cart&POST=/order/cart/*&DELETE=/order/cart/*">
{t`Retrieve a set of tokens from your customer account on the Provider's website`}
</Typography.Link>
default:
return <></>
}
}
export const tosHyperlink = (provider?: string) => {
switch (provider) {
case ConnectorProvider.OVH:
return 'https://www.ovhcloud.com/fr/terms-and-conditions/contracts/'
default:
return ''
}
}

View File

@ -0,0 +1,27 @@
import {t} from "ttag";
import {regionNames} from "../../i18n";
export const ovhFields = () => ({
appKey: t`Application key`,
appSecret: t`Application secret`,
consumerKey: t`Consumer key`
})
export const ovhEndpointList = () => [
{
label: t`European Region`,
value: 'ovh-eu'
}
]
export const ovhSubsidiaryList = () => [...[
'CZ', 'DE', 'ES', 'FI', 'FR', 'GB', 'IE', 'IT', 'LT', 'MA', 'NL', 'PL', 'PT', 'SN', 'TN'
].map(c => ({value: c, label: regionNames.of(c) ?? c})), {value: 'EU', label: t`Europe`}]
export const ovhPricingMode = () => [
{value: 'create-default', label: t`The domain is free and at the standard price`},
{
value: 'create-premium',
label: t`The domain is free but is a premium. Its price varies from one domain to another`
}
]

View File

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20240730193422 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE watch_list ADD connector_id UUID DEFAULT NULL');
$this->addSql('COMMENT ON COLUMN watch_list.connector_id IS \'(DC2Type:uuid)\'');
$this->addSql('ALTER TABLE watch_list ADD CONSTRAINT FK_152B584B4D085745 FOREIGN KEY (connector_id) REFERENCES connector (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('CREATE INDEX IDX_152B584B4D085745 ON watch_list (connector_id)');
$this->addSql('ALTER TABLE watch_list_trigger DROP CONSTRAINT fk_cf857a4c4d085745');
$this->addSql('DROP INDEX idx_cf857a4c4d085745');
$this->addSql('ALTER TABLE watch_list_trigger DROP connector_id');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SCHEMA public');
$this->addSql('ALTER TABLE watch_list_trigger ADD connector_id UUID DEFAULT NULL');
$this->addSql('COMMENT ON COLUMN watch_list_trigger.connector_id IS \'(DC2Type:uuid)\'');
$this->addSql('ALTER TABLE watch_list_trigger ADD CONSTRAINT fk_cf857a4c4d085745 FOREIGN KEY (connector_id) REFERENCES connector (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('CREATE INDEX idx_cf857a4c4d085745 ON watch_list_trigger (connector_id)');
$this->addSql('ALTER TABLE watch_list DROP CONSTRAINT FK_152B584B4D085745');
$this->addSql('DROP INDEX IDX_152B584B4D085745');
$this->addSql('ALTER TABLE watch_list DROP connector_id');
}
}

View File

@ -8,10 +8,5 @@ interface ConnectorInterface
{
public static function verifyAuthData(array $authData): array;
public function orderDomain(Domain $domain,
bool $acceptConditions,
bool $ownerLegalAge,
bool $waiveRetractationPeriod,
bool $dryRyn
): void;
public function orderDomain(Domain $domain, bool $dryRun): void;
}

View File

@ -3,6 +3,7 @@
namespace App\Config\Connector;
use App\Entity\Domain;
use DateTime;
use Exception;
use Ovh\Api;
@ -18,11 +19,7 @@ readonly class OvhConnector implements ConnectorInterface
* Order a domain name with the OVH API
* @throws Exception
*/
public function orderDomain(Domain $domain,
bool $acceptConditions,
bool $ownerLegalAge,
bool $waiveRetractationPeriod,
bool $dryRyn = false
public function orderDomain(Domain $domain, bool $dryRun = false
): void
{
if (!$domain->getDeleted()) throw new Exception('The domain name still appears in the WHOIS database');
@ -32,22 +29,19 @@ readonly class OvhConnector implements ConnectorInterface
$authData = self::verifyAuthData($this->authData);
$appKey = $authData['appKey'];
$appSecret = $authData['appSecret'];
$apiEndpoint = $authData['apiEndpoint'];
$consumerKey = $authData['consumerKey'];
$ovhSubsidiary = $authData['ovhSubsidiary'];
$pricingMode = $authData['pricingMode'];
$acceptConditions = $authData['acceptConditions'];
$ownerLegalAge = $authData['ownerLegalAge'];
$waiveRetractationPeriod = $authData['waiveRetractationPeriod'];
$conn = new Api(
$appKey,
$appSecret,
$apiEndpoint,
$consumerKey
$authData['appKey'],
$authData['appSecret'],
$authData['apiEndpoint'],
$authData['consumerKey']
);
$cart = $conn->post('/order/cart', [
"ovhSubsidiary" => $ovhSubsidiary,
"ovhSubsidiary" => $authData['ovhSubsidiary'],
"description" => "Domain Watchdog"
]);
$cartId = $cart['cartId'];
@ -57,7 +51,7 @@ readonly class OvhConnector implements ConnectorInterface
]);
$offer = array_filter($offers, fn($offer) => $offer['action'] === 'create' &&
$offer['orderable'] === true &&
$offer['pricingMode'] === $pricingMode
$offer['pricingMode'] === $authData['pricingMode']
);
if (empty($offer)) {
$conn->delete("/order/cart/{$cartId}");
@ -89,7 +83,7 @@ readonly class OvhConnector implements ConnectorInterface
}
$conn->get("/order/cart/{$cartId}/checkout");
if ($dryRyn) return;
if ($dryRun) return;
$conn->post("/order/cart/{$cartId}/checkout", [
"autoPayWithPreferredPaymentMethod" => true,
"waiveRetractationPeriod" => $waiveRetractationPeriod
@ -108,13 +102,36 @@ readonly class OvhConnector implements ConnectorInterface
$ovhSubsidiary = $authData['ovhSubsidiary'];
$pricingMode = $authData['pricingMode'];
$acceptConditions = $authData['acceptConditions'];
$ownerLegalAge = $authData['ownerLegalAge'];
$waiveRetractationPeriod = $authData['waiveRetractationPeriod'];
if (!is_string($appKey) || empty($appKey) ||
!is_string($appSecret) || empty($appSecret) ||
!is_string($consumerKey) || empty($consumerKey) ||
!is_string($apiEndpoint) || empty($apiEndpoint) ||
!is_string($ovhSubsidiary) || empty($ovhSubsidiary) ||
!is_string($pricingMode) || empty($pricingMode)
) throw new Exception("Bad data schema.");
!is_string($pricingMode) || empty($pricingMode) ||
true !== $acceptConditions ||
true !== $ownerLegalAge ||
true !== $waiveRetractationPeriod
) throw new Exception("Bad authData schema");
$conn = new Api(
$appKey,
$appSecret,
$apiEndpoint,
$consumerKey
);
$res = $conn->get('/auth/currentCredential');
if ($res['expiration'] !== null && new DateTime($res['expiration']) < new DateTime())
throw new Exception('These credentials have expired');
$status = $res['status'];
if ($status !== 'validated') throw new Exception("The status of these credentials is not valid ($status)");
return [
"appKey" => $appKey,
@ -122,7 +139,10 @@ readonly class OvhConnector implements ConnectorInterface
"apiEndpoint" => $apiEndpoint,
"consumerKey" => $consumerKey,
"ovhSubsidiary" => $ovhSubsidiary,
"pricingMode" => $pricingMode
"pricingMode" => $pricingMode,
"acceptConditions" => $acceptConditions,
"ownerLegalAge" => $ownerLegalAge,
"waiveRetractationPeriod" => $waiveRetractationPeriod
];
}
}

View File

@ -6,5 +6,4 @@ namespace App\Config;
enum TriggerAction: string
{
case SendEmail = 'email';
case BuyDomain = 'buy';
}

View File

@ -9,7 +9,6 @@ use App\Entity\User;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\EntityManagerInterface;
use Exception;
use Ovh\Api;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
@ -59,14 +58,6 @@ class ConnectorController extends AbstractController
if ($connector->getProvider() === ConnectorProvider::OVH) {
$authData = OvhConnector::verifyAuthData($connector->getAuthData());
$connector->setAuthData($authData);
$ovh = new Api(
$authData['appKey'],
$authData['appSecret'],
$authData['apiEndpoint'],
$authData['consumerKey']
);
} else throw new Exception('Unknown provider');
$this->em->persist($connector);

View File

@ -2,10 +2,8 @@
namespace App\Controller;
use App\Config\TriggerAction;
use App\Entity\User;
use App\Entity\WatchList;
use App\Entity\WatchListTrigger;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\EntityManagerInterface;
use Exception;
@ -55,13 +53,6 @@ class WatchListController extends AbstractController
{
$watchList = $this->serializer->deserialize($request->getContent(), WatchList::class, 'json', ['groups' => 'watchlist:create']);
$watchList->setUser($this->getUser());
/** @var WatchListTrigger $trigger */
foreach ($watchList->getWatchListTriggers()->toArray() as $trigger) {
if ($trigger->getAction() === TriggerAction::SendEmail && $trigger->getConnector() !== null)
throw new Exception('No connector needed to send email');
if ($trigger->getAction() === TriggerAction::BuyDomain && $trigger->getConnector() === null)
throw new Exception('Unable to order a domain name without a Connector');
}
$this->em->persist($watchList);
$this->em->flush();

View File

@ -56,16 +56,16 @@ class Connector
private array $authData = [];
/**
* @var Collection<int, WatchListTrigger>
* @var Collection<int, WatchList>
*/
#[ORM\OneToMany(targetEntity: WatchListTrigger::class, mappedBy: 'connector')]
private Collection $watchListTriggers;
#[ORM\OneToMany(targetEntity: WatchList::class, mappedBy: 'connector')]
private Collection $watchLists;
public function __construct()
{
$this->id = Uuid::v4();
$this->watchListTriggers = new ArrayCollection();
$this->watchLists = new ArrayCollection();
}
public function getId(): ?string
@ -97,36 +97,6 @@ class Connector
return $this;
}
/**
* @return Collection<int, WatchListTrigger>
*/
public function getWatchListTriggers(): Collection
{
return $this->watchListTriggers;
}
public function addWatchListTrigger(WatchListTrigger $watchListTrigger): static
{
if (!$this->watchListTriggers->contains($watchListTrigger)) {
$this->watchListTriggers->add($watchListTrigger);
$watchListTrigger->setConnector($this);
}
return $this;
}
public function removeWatchListTrigger(WatchListTrigger $watchListTrigger): static
{
if ($this->watchListTriggers->removeElement($watchListTrigger)) {
// set the owning side to null (unless already changed)
if ($watchListTrigger->getConnector() === $this) {
$watchListTrigger->setConnector(null);
}
}
return $this;
}
public function getProvider(): ?ConnectorProvider
{
return $this->provider;
@ -139,4 +109,34 @@ class Connector
return $this;
}
/**
* @return Collection<int, WatchList>
*/
public function getWatchLists(): Collection
{
return $this->watchLists;
}
public function addWatchList(WatchList $watchList): static
{
if (!$this->watchLists->contains($watchList)) {
$this->watchLists->add($watchList);
$watchList->setConnector($this);
}
return $this;
}
public function removeWatchList(WatchList $watchList): static
{
if ($this->watchLists->removeElement($watchList)) {
// set the owning side to null (unless already changed)
if ($watchList->getConnector() === $this) {
$watchList->setConnector(null);
}
}
return $this;
}
}

View File

@ -69,6 +69,11 @@ class WatchList
#[SerializedName("triggers")]
private Collection $watchListTriggers;
#[ORM\ManyToOne(inversedBy: 'watchLists')]
#[Groups(['watchlist:list', 'watchlist:item', 'watchlist:create', 'watchlist:update'])]
private ?Connector $connector = null;
public function __construct()
{
$this->token = Uuid::v4();
@ -146,4 +151,16 @@ class WatchList
return $this;
}
public function getConnector(): ?Connector
{
return $this->connector;
}
public function setConnector(?Connector $connector): static
{
$this->connector = $connector;
return $this;
}
}

View File

@ -25,10 +25,6 @@ class WatchListTrigger
#[Groups(['watchlist:list', 'watchlist:item', 'watchlist:create', 'watchlist:update'])]
private ?TriggerAction $action = null;
#[ORM\ManyToOne(inversedBy: 'watchListTriggers')]
#[Groups(['watchlist:list', 'watchlist:item', 'watchlist:create', 'watchlist:update'])]
private ?Connector $connector = null;
public function getEvent(): ?string
{
return $this->event;
@ -64,16 +60,4 @@ class WatchListTrigger
return $this;
}
public function getConnector(): ?Connector
{
return $this->connector;
}
public function setConnector(?Connector $connector): static
{
$this->connector = $connector;
return $this;
}
}

View File

@ -48,37 +48,28 @@ final readonly class ProcessDomainTriggerHandler
/** @var Domain $domain */
$domain = $this->domainRepository->findOneBy(["ldhName" => $message->ldhName]);
$watchListTriggers = $watchList->getWatchListTriggers();
$connector = $watchList->getConnector();
if (null !== $connector && $domain->getDeleted()) {
try {
if ($connector->getProvider() === ConnectorProvider::OVH) {
$ovh = new OVHConnector($connector->getAuthData());
$isDebug = $this->kernel->isDebug();
/** @var WatchListTrigger $watchListTrigger */
foreach ($watchListTriggers->getIterator() as $watchListTrigger) {
if ($watchListTrigger->getAction() === TriggerAction::BuyDomain) {
try {
if ($watchListTrigger->getConnector() === null) throw new Exception('Connector is missing');
$connector = $watchListTrigger->getConnector();
if ($connector->getProvider() === ConnectorProvider::OVH) {
$ovh = new OVHConnector($connector->getAuthData());
$isDebug = $this->kernel->isDebug();
$ovh->orderDomain(
$domain,
true, // TODO: Infer from the user
true, // TODO: Infer from the user
true, // TODO: Infer from the user
$isDebug
);
$this->sendEmailDomainOrdered($domain, $connector, $watchList->getUser());
} else throw new Exception("Unknown provider");
} catch (Throwable) {
$this->sendEmailDomainOrderError($domain, $watchList->getUser());
}
$ovh->orderDomain($domain, $isDebug);
$this->sendEmailDomainOrdered($domain, $connector, $watchList->getUser());
} else throw new Exception("Unknown provider");
} catch (Throwable) {
$this->sendEmailDomainOrderError($domain, $watchList->getUser());
}
}
/** @var DomainEvent $event */
foreach ($domain->getEvents()->filter(fn($event) => $message->updatedAt < $event->getDate()) as $event) {
/** @var DomainEvent $event */
foreach ($domain->getEvents()->filter(fn($event) => $message->updatedAt < $event->getDate()) as $event) {
$watchListTriggers = $watchList->getWatchListTriggers()
->filter(fn($trigger) => $trigger->getEvent() === $event->getAction());
/** @var WatchListTrigger $watchListTrigger */
foreach ($watchListTriggers->getIterator() as $watchListTrigger) {
if ($watchListTrigger->getAction() == TriggerAction::SendEmail) {
$this->sendEmailDomainUpdated($event, $watchList->getUser());
}

View File

@ -51,7 +51,7 @@
<p>Hello,</p>
<p>We are pleased to inform you that a new action has been detected on a domain name in your watchlist.</p>
<p><strong>Domain name:</strong> {{ event.domain.ldhName }}</p>
<p><strong>Action:</strong> {{ event.action.value }}</p>
<p><strong>Action:</strong> {{ event.action }}</p>
<p><strong>Effective Date:</strong> {{ event.date | date("c") }}</p>
<br/>
<p>Thank you for your understanding,</p>

View File

@ -1,6 +1,6 @@
msgid ""
msgstr ""
"PO-Revision-Date: 2024-07-30 00:46+0000\n"
"PO-Revision-Date: 2024-07-30 18:40+0000\n"
"Last-Translator: Maël Gangloff <contact@maelgangloff.fr>\n"
"Language-Team: French <https://weblate.vinceh121.me/projects/domain-watchdog/"
"domain-watchdog-dashboard/fr/>\n"
@ -56,21 +56,23 @@ msgid "ENUM validation expiration"
msgstr "Expiration de la validation ENUM"
#: assets/components/search/DomainSearchBar.tsx:23
#: assets/components/tracking/ConnectorForm.tsx:54
#: assets/components/tracking/ConnectorForm.tsx:82
#: assets/components/tracking/ConnectorForm.tsx:90
#: assets/components/tracking/ConnectorForm.tsx:40
#: assets/components/tracking/ConnectorForm.tsx:66
#: assets/components/tracking/ConnectorForm.tsx:74
#: assets/components/tracking/ConnectorForm.tsx:81
#: assets/components/tracking/ConnectorForm.tsx:89
#: assets/components/tracking/ConnectorForm.tsx:97
#: assets/components/tracking/ConnectorForm.tsx:105
#: assets/components/tracking/WatchlistForm.tsx:115
#: assets/components/tracking/WatchlistForm.tsx:174
#: assets/components/tracking/WatchlistForm.tsx:184
#: assets/components/tracking/WatchlistForm.tsx:196
#: assets/components/tracking/ConnectorForm.tsx:110
#: assets/components/tracking/ConnectorForm.tsx:119
#: assets/components/tracking/WatchlistForm.tsx:110
#: assets/components/tracking/WatchlistForm.tsx:169
#: assets/components/tracking/WatchlistForm.tsx:179
#: assets/pages/LoginPage.tsx:53 assets/pages/LoginPage.tsx:61
msgid "Required"
msgstr "Requis"
#: assets/components/search/DomainSearchBar.tsx:26
#: assets/components/tracking/WatchlistForm.tsx:118
#: assets/components/tracking/WatchlistForm.tsx:113
msgid "This domain name does not appear to be valid"
msgstr "Ce nom de domaine ne semble pas être valide"
@ -158,114 +160,115 @@ msgstr "Lorsqu'un domaine est enregistré"
msgid "Send me an email"
msgstr "Envoie-moi un email"
#: assets/components/tracking/WatchlistForm.tsx:78
msgid "Buy the domain if available"
msgstr "Acheter le domaine s'il est disponible"
#: assets/components/tracking/WatchlistForm.tsx:95
#: assets/components/tracking/WatchlistForm.tsx:90
msgid "At least one domain name"
msgstr "Au moins un nom de domaine"
#: assets/components/tracking/WatchlistForm.tsx:106
#: assets/components/tracking/WatchlistForm.tsx:101
msgid "Domain names"
msgstr "Noms de domaines"
#: assets/components/tracking/WatchlistForm.tsx:124
#: assets/components/tracking/WatchlistForm.tsx:119
#: assets/components/tracking/WatchlistsList.tsx:22
msgid "Domain name"
msgstr "Nom de domaine"
#: assets/components/tracking/WatchlistForm.tsx:141
#: assets/components/tracking/WatchlistForm.tsx:136
msgid "Add a Domain name"
msgstr "Ajouter un nom de domaine"
#: assets/components/tracking/WatchlistForm.tsx:154
#: assets/components/tracking/WatchlistForm.tsx:149
msgid "At least one domain trigger"
msgstr "Au moins une action"
#: assets/components/tracking/WatchlistForm.tsx:165
#: assets/components/tracking/WatchlistForm.tsx:160
msgid "Domain trigger"
msgstr "Action"
#: assets/components/tracking/WatchlistForm.tsx:178
#: assets/components/tracking/WatchlistForm.tsx:173
msgid "If this happens"
msgstr "Si ça arrive"
#: assets/components/tracking/WatchlistForm.tsx:188
#: assets/components/tracking/WatchlistForm.tsx:183
msgid "Then do that"
msgstr "Alors fais ça"
#: assets/components/tracking/WatchlistForm.tsx:201
msgid "Connector"
msgstr "Connecteur"
#: assets/components/tracking/WatchlistForm.tsx:227
#: assets/components/tracking/WatchlistForm.tsx:203
msgid "Add a Trigger"
msgstr "Ajouter une action"
#: assets/components/tracking/ConnectorForm.tsx:116
#: assets/components/tracking/WatchlistForm.tsx:237
#: assets/components/tracking/WatchlistForm.tsx:210
#: assets/components/tracking/WatchlistForm.tsx:216
msgid "Connector"
msgstr "Connecteur"
#: assets/components/tracking/ConnectorForm.tsx:131
#: assets/components/tracking/WatchlistForm.tsx:228
msgid "Create"
msgstr "Créer"
#: assets/components/tracking/ConnectorForm.tsx:119
#: assets/components/tracking/WatchlistForm.tsx:240
#: assets/components/tracking/ConnectorForm.tsx:134
#: assets/components/tracking/WatchlistForm.tsx:231
msgid "Reset"
msgstr "Réinitialiser"
#: assets/components/tracking/ConnectorForm.tsx:19
msgid "Application key"
msgstr "Clé d'application"
#: assets/components/tracking/ConnectorForm.tsx:20
msgid "Application secret"
msgstr "Clé secrète d'application"
#: assets/components/tracking/ConnectorForm.tsx:21
msgid "Consumer key"
msgstr "Clé d'utilisateur"
#: assets/components/tracking/ConnectorForm.tsx:26
msgid "European Region"
msgstr "Continent Européen"
#: assets/components/tracking/ConnectorForm.tsx:31
msgid "Europa"
msgstr "Europe"
#: assets/components/tracking/ConnectorForm.tsx:36
msgid "The domain is free and at the standard price"
msgstr "Le domaine est libre et au prix standard"
#: assets/components/tracking/ConnectorForm.tsx:39
msgid ""
"The domain is free but is a premium. Its price varies from one domain to "
"another"
msgstr ""
"Le domaine est libre mais est un premium. Son prix est variable d'un domaine "
"à l'autre"
#: assets/components/tracking/ConnectorForm.tsx:52
#: assets/components/tracking/ConnectorForm.tsx:37
#: assets/components/tracking/ConnectorsList.tsx:21
msgid "Provider"
msgstr "Fournisseur"
#: assets/components/tracking/ConnectorForm.tsx:57
#: assets/components/tracking/ConnectorForm.tsx:44
msgid "Please select a Provider"
msgstr "Veuillez sélectionner un fournisseur"
#: assets/components/tracking/ConnectorForm.tsx:88
#: assets/components/tracking/ConnectorForm.tsx:72
msgid "OVH Endpoint"
msgstr "Endpoint OVH"
#: assets/components/tracking/ConnectorForm.tsx:95
#: assets/components/tracking/ConnectorForm.tsx:79
msgid "OVH subsidiary"
msgstr "Filiale d'OVH"
msgstr "Filiale OVH"
#: assets/components/tracking/ConnectorForm.tsx:103
#: assets/components/tracking/ConnectorForm.tsx:87
msgid "OVH pricing mode"
msgstr "Mode de tarification OVH"
#: assets/components/tracking/ConnectorForm.tsx:95
msgid "API Terms of Service"
msgstr "Conditions d'utilisation de l'API"
#: assets/components/tracking/ConnectorForm.tsx:102
msgid ""
"I certify that I have read and accepted the conditions of use of the "
"Provider API, accessible from this hyperlink"
msgstr ""
"Je certifie avoir lu et accepté les conditions d'utilisation de l'API du "
"Fournisseur, accessibles à partir de ce lien hypertexte"
#: assets/components/tracking/ConnectorForm.tsx:108
msgid "Legal age"
msgstr "Âge minimum légal"
#: assets/components/tracking/ConnectorForm.tsx:113
msgid ""
"I certify on my honor that I am of the minimum age required to consent to "
"these conditions"
msgstr ""
"Je certifie sur l'honneur que j'ai l'âge minimum requis pour consentir à ces "
"conditions"
#: assets/components/tracking/ConnectorForm.tsx:117
msgid "Withdrawal period"
msgstr "Délai de rétractation"
#: assets/components/tracking/ConnectorForm.tsx:122
msgid ""
"I expressly waive my right of withdrawal regarding the purchase of domain "
"names via the Provider's API"
msgstr ""
"Je renonce expressément à mon droit de rétractation concernant l'achat de "
"noms de domaine via l'API du Fournisseur"
#: assets/components/tracking/WatchlistsList.tsx:14
#, javascript-format
msgid "Watchlist ${ watchlist.token }"
@ -313,8 +316,8 @@ msgstr "Trouvé !"
#: assets/pages/search/DomainSearchPage.tsx:23
#: assets/pages/tracking/ConnectorsPage.tsx:23
#: assets/pages/tracking/ConnectorsPage.tsx:31
#: assets/pages/tracking/WatchlistPage.tsx:35
#: assets/pages/tracking/WatchlistPage.tsx:43
#: assets/pages/tracking/WatchlistPage.tsx:36
#: assets/pages/tracking/WatchlistPage.tsx:44
msgid "An error occurred"
msgstr "Une erreur s'est produite"
@ -454,19 +457,19 @@ msgstr "Connecteur créé !"
msgid "Create a Connector"
msgstr "Créer un Connecteur"
#: assets/pages/tracking/ConnectorsPage.tsx:48
#: assets/App.tsx:143 assets/pages/tracking/ConnectorsPage.tsx:48
msgid "My Connectors"
msgstr "Mes Connecteurs"
#: assets/pages/tracking/WatchlistPage.tsx:32
#: assets/pages/tracking/WatchlistPage.tsx:33
msgid "Watchlist created !"
msgstr "Watchlist créée !"
#: assets/pages/tracking/WatchlistPage.tsx:53
#: assets/pages/tracking/WatchlistPage.tsx:54
msgid "Create a Watchlist"
msgstr "Créer une Watchlist"
#: assets/App.tsx:136 assets/pages/tracking/WatchlistPage.tsx:63
#: assets/App.tsx:136 assets/pages/tracking/WatchlistPage.tsx:64
msgid "My Watchlists"
msgstr "Mes Watchlists"
@ -494,6 +497,45 @@ msgstr "Se connecter"
msgid "Log in with SSO"
msgstr "Se connecter par SSO"
#: assets/utils/providers/index.tsx:11
msgid ""
"Retrieve a set of tokens from your customer account on the Provider's website"
msgstr ""
"Récupérer un ensemble de jetons à partir de votre compte client sur le site "
"web du Fournisseur"
#: assets/utils/providers/ovh.tsx:5
msgid "Application key"
msgstr "Clé d'application"
#: assets/utils/providers/ovh.tsx:6
msgid "Application secret"
msgstr "Clé secrète d'application"
#: assets/utils/providers/ovh.tsx:7
msgid "Consumer key"
msgstr "Clé d'utilisateur"
#: assets/utils/providers/ovh.tsx:12
msgid "European Region"
msgstr "Continent Européen"
#: assets/utils/providers/ovh.tsx:19
msgid "Europe"
msgstr "Europe"
#: assets/utils/providers/ovh.tsx:22
msgid "The domain is free and at the standard price"
msgstr "Le domaine est libre et au prix standard"
#: assets/utils/providers/ovh.tsx:25
msgid ""
"The domain is free but is a premium. Its price varies from one domain to "
"another"
msgstr ""
"Le domaine est libre mais est un premium. Son prix est variable d'un domaine "
"à l'autre"
#: assets/App.tsx:71
msgid "Home"
msgstr "Accueil"
@ -542,10 +584,6 @@ msgstr "Statistiques"
msgid "Tracking"
msgstr "Suivi"
#: assets/App.tsx:143
msgid "My connectors"
msgstr "Mes connecteurs"
#: assets/App.tsx:151
msgid "My Watchdog"
msgstr "Mon Watchdog"
@ -565,3 +603,9 @@ msgstr "FAQ"
#: assets/App.tsx:196
msgid "Log out"
msgstr "Se déconnecter"
#~ msgid "Buy the domain if available"
#~ msgstr "Acheter le domaine s'il est disponible"
#~ msgid "My connectors"
#~ msgstr "Mes connecteurs"

View File

@ -48,22 +48,24 @@ msgid "ENUM validation expiration"
msgstr ""
#: assets/components/search/DomainSearchBar.tsx:23
#: assets/components/tracking/ConnectorForm.tsx:54
#: assets/components/tracking/ConnectorForm.tsx:82
#: assets/components/tracking/ConnectorForm.tsx:90
#: assets/components/tracking/ConnectorForm.tsx:40
#: assets/components/tracking/ConnectorForm.tsx:66
#: assets/components/tracking/ConnectorForm.tsx:74
#: assets/components/tracking/ConnectorForm.tsx:81
#: assets/components/tracking/ConnectorForm.tsx:89
#: assets/components/tracking/ConnectorForm.tsx:97
#: assets/components/tracking/ConnectorForm.tsx:105
#: assets/components/tracking/WatchlistForm.tsx:115
#: assets/components/tracking/WatchlistForm.tsx:174
#: assets/components/tracking/WatchlistForm.tsx:184
#: assets/components/tracking/WatchlistForm.tsx:196
#: assets/components/tracking/ConnectorForm.tsx:110
#: assets/components/tracking/ConnectorForm.tsx:119
#: assets/components/tracking/WatchlistForm.tsx:110
#: assets/components/tracking/WatchlistForm.tsx:169
#: assets/components/tracking/WatchlistForm.tsx:179
#: assets/pages/LoginPage.tsx:53
#: assets/pages/LoginPage.tsx:61
msgid "Required"
msgstr ""
#: assets/components/search/DomainSearchBar.tsx:26
#: assets/components/tracking/WatchlistForm.tsx:118
#: assets/components/tracking/WatchlistForm.tsx:113
msgid "This domain name does not appear to be valid"
msgstr ""
@ -151,112 +153,109 @@ msgstr ""
msgid "Send me an email"
msgstr ""
#: assets/components/tracking/WatchlistForm.tsx:78
msgid "Buy the domain if available"
msgstr ""
#: assets/components/tracking/WatchlistForm.tsx:95
#: assets/components/tracking/WatchlistForm.tsx:90
msgid "At least one domain name"
msgstr ""
#: assets/components/tracking/WatchlistForm.tsx:106
#: assets/components/tracking/WatchlistForm.tsx:101
msgid "Domain names"
msgstr ""
#: assets/components/tracking/WatchlistForm.tsx:124
#: assets/components/tracking/WatchlistForm.tsx:119
#: assets/components/tracking/WatchlistsList.tsx:22
msgid "Domain name"
msgstr ""
#: assets/components/tracking/WatchlistForm.tsx:141
#: assets/components/tracking/WatchlistForm.tsx:136
msgid "Add a Domain name"
msgstr ""
#: assets/components/tracking/WatchlistForm.tsx:154
#: assets/components/tracking/WatchlistForm.tsx:149
msgid "At least one domain trigger"
msgstr ""
#: assets/components/tracking/WatchlistForm.tsx:165
#: assets/components/tracking/WatchlistForm.tsx:160
msgid "Domain trigger"
msgstr ""
#: assets/components/tracking/WatchlistForm.tsx:178
#: assets/components/tracking/WatchlistForm.tsx:173
msgid "If this happens"
msgstr ""
#: assets/components/tracking/WatchlistForm.tsx:188
#: assets/components/tracking/WatchlistForm.tsx:183
msgid "Then do that"
msgstr ""
#: assets/components/tracking/WatchlistForm.tsx:201
msgid "Connector"
msgstr ""
#: assets/components/tracking/WatchlistForm.tsx:227
#: assets/components/tracking/WatchlistForm.tsx:203
msgid "Add a Trigger"
msgstr ""
#: assets/components/tracking/ConnectorForm.tsx:116
#: assets/components/tracking/WatchlistForm.tsx:237
#: assets/components/tracking/WatchlistForm.tsx:210
#: assets/components/tracking/WatchlistForm.tsx:216
msgid "Connector"
msgstr ""
#: assets/components/tracking/ConnectorForm.tsx:131
#: assets/components/tracking/WatchlistForm.tsx:228
msgid "Create"
msgstr ""
#: assets/components/tracking/ConnectorForm.tsx:119
#: assets/components/tracking/WatchlistForm.tsx:240
#: assets/components/tracking/ConnectorForm.tsx:134
#: assets/components/tracking/WatchlistForm.tsx:231
msgid "Reset"
msgstr ""
#: assets/components/tracking/ConnectorForm.tsx:19
msgid "Application key"
msgstr ""
#: assets/components/tracking/ConnectorForm.tsx:20
msgid "Application secret"
msgstr ""
#: assets/components/tracking/ConnectorForm.tsx:21
msgid "Consumer key"
msgstr ""
#: assets/components/tracking/ConnectorForm.tsx:26
msgid "European Region"
msgstr ""
#: assets/components/tracking/ConnectorForm.tsx:31
msgid "Europa"
msgstr ""
#: assets/components/tracking/ConnectorForm.tsx:36
msgid "The domain is free and at the standard price"
msgstr ""
#: assets/components/tracking/ConnectorForm.tsx:39
msgid ""
"The domain is free but is a premium. Its price varies from one domain to "
"another"
msgstr ""
#: assets/components/tracking/ConnectorForm.tsx:52
#: assets/components/tracking/ConnectorForm.tsx:37
#: assets/components/tracking/ConnectorsList.tsx:21
msgid "Provider"
msgstr ""
#: assets/components/tracking/ConnectorForm.tsx:57
#: assets/components/tracking/ConnectorForm.tsx:44
msgid "Please select a Provider"
msgstr ""
#: assets/components/tracking/ConnectorForm.tsx:88
#: assets/components/tracking/ConnectorForm.tsx:72
msgid "OVH Endpoint"
msgstr ""
#: assets/components/tracking/ConnectorForm.tsx:95
#: assets/components/tracking/ConnectorForm.tsx:79
msgid "OVH subsidiary"
msgstr ""
#: assets/components/tracking/ConnectorForm.tsx:103
#: assets/components/tracking/ConnectorForm.tsx:87
msgid "OVH pricing mode"
msgstr ""
#: assets/components/tracking/ConnectorForm.tsx:95
msgid "API Terms of Service"
msgstr ""
#: assets/components/tracking/ConnectorForm.tsx:102
msgid ""
"I certify that I have read and accepted the conditions of use of the "
"Provider API, accessible from this hyperlink"
msgstr ""
#: assets/components/tracking/ConnectorForm.tsx:108
msgid "Legal age"
msgstr ""
#: assets/components/tracking/ConnectorForm.tsx:113
msgid ""
"I certify on my honor that I am of the minimum age required to consent to "
"these conditions"
msgstr ""
#: assets/components/tracking/ConnectorForm.tsx:117
msgid "Withdrawal period"
msgstr ""
#: assets/components/tracking/ConnectorForm.tsx:122
msgid ""
"I expressly waive my right of withdrawal regarding the purchase of domain "
"names via the Provider's API"
msgstr ""
#: assets/components/tracking/WatchlistsList.tsx:14
#, javascript-format
msgid "Watchlist ${ watchlist.token }"
@ -304,8 +303,8 @@ msgstr ""
#: assets/pages/search/DomainSearchPage.tsx:23
#: assets/pages/tracking/ConnectorsPage.tsx:23
#: assets/pages/tracking/ConnectorsPage.tsx:31
#: assets/pages/tracking/WatchlistPage.tsx:35
#: assets/pages/tracking/WatchlistPage.tsx:43
#: assets/pages/tracking/WatchlistPage.tsx:36
#: assets/pages/tracking/WatchlistPage.tsx:44
msgid "An error occurred"
msgstr ""
@ -427,20 +426,21 @@ msgstr ""
msgid "Create a Connector"
msgstr ""
#: assets/App.tsx:143
#: assets/pages/tracking/ConnectorsPage.tsx:48
msgid "My Connectors"
msgstr ""
#: assets/pages/tracking/WatchlistPage.tsx:32
#: assets/pages/tracking/WatchlistPage.tsx:33
msgid "Watchlist created !"
msgstr ""
#: assets/pages/tracking/WatchlistPage.tsx:53
#: assets/pages/tracking/WatchlistPage.tsx:54
msgid "Create a Watchlist"
msgstr ""
#: assets/App.tsx:136
#: assets/pages/tracking/WatchlistPage.tsx:63
#: assets/pages/tracking/WatchlistPage.tsx:64
msgid "My Watchlists"
msgstr ""
@ -469,6 +469,42 @@ msgstr ""
msgid "Log in with SSO"
msgstr ""
#: assets/utils/providers/index.tsx:11
msgid ""
"Retrieve a set of tokens from your customer account on the Provider's "
"website"
msgstr ""
#: assets/utils/providers/ovh.tsx:5
msgid "Application key"
msgstr ""
#: assets/utils/providers/ovh.tsx:6
msgid "Application secret"
msgstr ""
#: assets/utils/providers/ovh.tsx:7
msgid "Consumer key"
msgstr ""
#: assets/utils/providers/ovh.tsx:12
msgid "European Region"
msgstr ""
#: assets/utils/providers/ovh.tsx:19
msgid "Europe"
msgstr ""
#: assets/utils/providers/ovh.tsx:22
msgid "The domain is free and at the standard price"
msgstr ""
#: assets/utils/providers/ovh.tsx:25
msgid ""
"The domain is free but is a premium. Its price varies from one domain to "
"another"
msgstr ""
#: assets/App.tsx:71
msgid "Home"
msgstr ""
@ -517,10 +553,6 @@ msgstr ""
msgid "Tracking"
msgstr ""
#: assets/App.tsx:143
msgid "My connectors"
msgstr ""
#: assets/App.tsx:151
msgid "My Watchdog"
msgstr ""