mirror of
https://git.vladimir.cc/vladimir/ewsdr.git
synced 2026-08-25 17:27:32 +00:00
Сеть и резолв (DXClusterClient): - Резолв ушёл в отдельный поток: системный резолвер блокирующий и не прерывается, а сокета в этот момент ещё нет — Stop из UI-потока висел на DNS-таймауте. Сессия ждёт квантами по 200 мс, просыпаясь на FStopEvent; Stop теперь отрабатывает за 100-200 мс в любой фазе. - Реестр запросов: по одному резолверу на имя, не больше DX_MAX_RESOLVERS. Не дождавшись, сессия оставляет запрос в реестре и на следующей попытке цепляется к нему же (иначе зависший резолвер плодил бы вечные потоки). Слотов несколько, чтобы смена адреса работала поверх зависшего прежнего; литеральный IP разбирается до реестра — ввод адреса руками обязан работать всегда. Запись живёт по счётчику ссылок, исключение в резолвере не оставляет слот занятым. - getaddrinfo вместо netdb.ResolveHostByName: тот ходит в DNS сам и /etc/hosts не читает вовсе (getent находит localhost, ResolveHostByName — нет), т.е. локальный алиас кластера не работал. Заодно реентерабельно. - WSAStartup перенесён в initialization, WSACleanup убран: Stop не ждёт резолвер, а тот может сидеть в gethostbyname. Логин (DXClusterClient): - Команды пользователя больше не уходят в незавершённый логин: очередь разбирается только после post-login, SendCommand говорит в лог, что команда ждёт. - ONLINE не по таймеру, а по существу: LoginSettled требует ответа сервера после учётки (с потолком молчания), пароль ждёт своего приглашения. Подтверждение ставится ПОСЛЕ разбора куска, а не на приход байтов — иначе исход логина зависел от границ TCP-пакетов. - Приглашения и отказы: строгий детектор (текст, заканчивающийся двоеточием) и для отправки пароля, и для вердикта — по вхождению слова пароль улетал командой от строки приветствия. Повтор приглашения пароля или позывного = отказ авторизации (dxsError, без реконнекта); опоздавшее приглашение после слепой отправки позывного отказом не считается. Эхо уже отвеченного приглашения гасится окном в одну строку. - Ошибка отправки post-login рвёт сессию, а не только цикл команд; неотправленная очередь возвращается на следующее соединение. UI и данные: - QSY по споту крутит активный VFO, а не всегда A (MainForm). - TTL спотов чистится тиком независимо от видимости оверлея (DXSpotStore .Purge + ServiceDXCluster). - Выделение в окне списка держится по позывному И частоте: один позывной живёт на разных диапазонах (DXClusterForm). - Кнопка DX правит и отложенную копию настроек, иначе debounce SETUP возвращал прежнее состояние подписей (MainForm). Проверено на фейковом кластере: приглашения с CRLF и без, границы TCP-пакетов, отказ по паролю и по позывному, опоздавшее приглашение, ловушки ложного срабатывания. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
485 lines
15 KiB
ObjectPascal
485 lines
15 KiB
ObjectPascal
unit DXClusterForm;
|
|
|
|
{
|
|
TDXClusterForm — окно DX-кластера: список принятых спотов, лог соединения и
|
|
строка команды в кластер (sh/dx, set/filter — диалект у каждого кластера свой,
|
|
поэтому команды не угадываем, а даём отправить руками).
|
|
|
|
Данные тянутся ПОЛЛИНГОМ: сетевой поток кладёт споты в TDXSpotStore и строки в
|
|
лог клиента, а форма раз в секунду сравнивает Version/LogVersion и перечитывает
|
|
только при изменении. Никакого Synchronize из потока в UI — та же схема, что у
|
|
оверлея спектра.
|
|
|
|
Двойной клик по споту (или Enter) — QSY: наружу через OnTuneSpot.
|
|
}
|
|
|
|
{$mode objfpc}{$H+}
|
|
|
|
interface
|
|
|
|
uses
|
|
Classes, SysUtils, Math,
|
|
Forms, Controls, Graphics, StdCtrls, ExtCtrls,
|
|
FlatButton, FlatEdit, FlatListBox, FlatMemo,
|
|
AppTheme, DpiUtils, DXSpotStore, DXClusterClient;
|
|
|
|
type
|
|
// QSY по споту: частота в display-Гц + мода (dxmUnknown — не трогать моду).
|
|
TDXTuneEvent = procedure(FreqHz: Double; Mode: TDXMode) of object;
|
|
|
|
TDXClusterForm = class(TForm)
|
|
private
|
|
FStore: TDXSpotStore; // не владеет
|
|
FClient: TDXClusterClient; // не владеет
|
|
FTheme: TAppTheme;
|
|
|
|
FTitle: TLabel;
|
|
FStatus: TLabel;
|
|
FBtnConn: TFlatButton;
|
|
FBtnClear: TFlatButton;
|
|
FBtnSort: TFlatButton;
|
|
FBtnClose: TFlatButton;
|
|
FList: TFlatListBox;
|
|
FLog: TFlatMemo;
|
|
FEdCmd: TFlatEdit;
|
|
FBtnSend: TFlatButton;
|
|
FTimer: TTimer;
|
|
|
|
FSpots: TDXSpotArray; // снимок, параллельный строкам FList
|
|
FSortFreq: Boolean; // False = по времени (свежие сверху)
|
|
FLastVer: Int64;
|
|
FLastLogVer: Int64;
|
|
FOnTune: TDXTuneEvent;
|
|
|
|
procedure BuildUI;
|
|
procedure StyleBtn(B: TFlatButton);
|
|
function FormatSpotLine(const S: TDXSpot): string;
|
|
procedure RefreshSpots;
|
|
procedure RefreshLog;
|
|
procedure RefreshStatus;
|
|
procedure TuneSelected;
|
|
|
|
procedure OnTimerTick(Sender: TObject);
|
|
procedure OnConnClick(Sender: TObject);
|
|
procedure OnClearClick(Sender: TObject);
|
|
procedure OnSortClick(Sender: TObject);
|
|
procedure OnCloseClick(Sender: TObject);
|
|
procedure OnSendClick(Sender: TObject);
|
|
procedure OnListDblClick(Sender: TObject);
|
|
procedure OnListKeyDown(Sender: TObject; var Key: Word; Shift: TShiftState);
|
|
procedure OnCmdKeyDown(Sender: TObject; var Key: Word; Shift: TShiftState);
|
|
procedure OnFormClose(Sender: TObject; var CloseAction: TCloseAction);
|
|
protected
|
|
procedure DoShow; override;
|
|
procedure DoHide; override;
|
|
public
|
|
constructor CreateWith(AOwner: TComponent; AStore: TDXSpotStore;
|
|
AClient: TDXClusterClient); reintroduce;
|
|
procedure ApplyTheme(const T: TAppTheme);
|
|
property OnTuneSpot: TDXTuneEvent read FOnTune write FOnTune;
|
|
end;
|
|
|
|
implementation
|
|
|
|
const
|
|
FORM_W = 860;
|
|
FORM_H = 560;
|
|
MARGIN = 12;
|
|
BTN_H = 26;
|
|
LOG_H = 130;
|
|
|
|
// Моноширинный — ТОЛЬКО там, где колонки выровнены пробелами (список спотов и
|
|
// лог соединения); всё остальное в окне идёт системным шрифтом, как везде по
|
|
// проекту. Имя платформенное, как в CWTerminalForm: 'Courier New' есть не
|
|
// всюду (на Linux fontconfig всё равно подставляет свой моноширинный).
|
|
{$IFDEF WINDOWS}
|
|
MONO_FONT = 'Consolas';
|
|
{$ELSE}
|
|
MONO_FONT = 'Monospace';
|
|
{$ENDIF}
|
|
|
|
constructor TDXClusterForm.CreateWith(AOwner: TComponent; AStore: TDXSpotStore;
|
|
AClient: TDXClusterClient);
|
|
var
|
|
WorkArea: TRect;
|
|
begin
|
|
inherited CreateNew(AOwner);
|
|
FStore := AStore;
|
|
FClient := AClient;
|
|
FTheme := DarkTheme;
|
|
FSortFreq := False;
|
|
FLastVer := -1;
|
|
FLastLogVer := -1;
|
|
|
|
Scaled := False;
|
|
Caption := 'DX Cluster';
|
|
BorderStyle := bsSizeable;
|
|
if AOwner is TCustomForm then
|
|
begin
|
|
WorkArea := Screen.MonitorFromRect(TCustomForm(AOwner).BoundsRect).WorkareaRect;
|
|
Position := poOwnerFormCenter;
|
|
end
|
|
else
|
|
begin
|
|
WorkArea := Screen.PrimaryMonitor.WorkareaRect;
|
|
Position := poScreenCenter;
|
|
end;
|
|
Width := Min(DpiScale(FORM_W), WorkArea.Right - WorkArea.Left - DpiScale(48));
|
|
Height := Min(DpiScale(FORM_H), WorkArea.Bottom - WorkArea.Top - DpiScale(48));
|
|
Constraints.MinWidth := Min(DpiScale(620), Width);
|
|
Constraints.MinHeight := Min(DpiScale(380), Height);
|
|
OnClose := @OnFormClose;
|
|
|
|
BuildUI;
|
|
ApplyTheme(DarkTheme);
|
|
end;
|
|
|
|
procedure TDXClusterForm.BuildUI;
|
|
var
|
|
BtnW, Y: Integer;
|
|
begin
|
|
BtnW := DpiScale(96);
|
|
|
|
FTitle := TLabel.Create(Self);
|
|
FTitle.Parent := Self;
|
|
FTitle.SetBounds(DpiScale(MARGIN), DpiScale(10), DpiScale(300), DpiScale(22));
|
|
FTitle.Caption := 'DX CLUSTER';
|
|
FTitle.Font.Size := 9;
|
|
FTitle.Font.Style := [fsBold];
|
|
|
|
FStatus := TLabel.Create(Self);
|
|
FStatus.Parent := Self;
|
|
FStatus.SetBounds(DpiScale(MARGIN), DpiScale(31), DpiScale(520), DpiScale(18));
|
|
FStatus.Font.Size := 8;
|
|
FStatus.Anchors := [akLeft, akTop, akRight];
|
|
|
|
Y := DpiScale(10);
|
|
FBtnClose := TFlatButton.Create(Self);
|
|
FBtnClose.Parent := Self;
|
|
FBtnClose.SetBounds(ClientWidth - DpiScale(MARGIN) - BtnW, Y, BtnW, DpiScale(BTN_H));
|
|
FBtnClose.Caption := 'Close';
|
|
FBtnClose.Anchors := [akTop, akRight];
|
|
FBtnClose.OnClick := @OnCloseClick;
|
|
|
|
FBtnSort := TFlatButton.Create(Self);
|
|
FBtnSort.Parent := Self;
|
|
FBtnSort.SetBounds(FBtnClose.Left - BtnW - DpiScale(6), Y, BtnW, DpiScale(BTN_H));
|
|
FBtnSort.Caption := 'BY TIME';
|
|
FBtnSort.Anchors := [akTop, akRight];
|
|
FBtnSort.OnClick := @OnSortClick;
|
|
|
|
FBtnClear := TFlatButton.Create(Self);
|
|
FBtnClear.Parent := Self;
|
|
FBtnClear.SetBounds(FBtnSort.Left - BtnW - DpiScale(6), Y, BtnW, DpiScale(BTN_H));
|
|
FBtnClear.Caption := 'CLEAR';
|
|
FBtnClear.Anchors := [akTop, akRight];
|
|
FBtnClear.OnClick := @OnClearClick;
|
|
|
|
FBtnConn := TFlatButton.Create(Self);
|
|
FBtnConn.Parent := Self;
|
|
FBtnConn.SetBounds(FBtnClear.Left - BtnW - DpiScale(6), Y, BtnW, DpiScale(BTN_H));
|
|
FBtnConn.Caption := 'CONNECT';
|
|
FBtnConn.Anchors := [akTop, akRight];
|
|
FBtnConn.OnClick := @OnConnClick;
|
|
|
|
// Строка команды — внизу, над логом.
|
|
FBtnSend := TFlatButton.Create(Self);
|
|
FBtnSend.Parent := Self;
|
|
FBtnSend.SetBounds(ClientWidth - DpiScale(MARGIN) - BtnW,
|
|
ClientHeight - DpiScale(MARGIN + BTN_H), BtnW, DpiScale(BTN_H));
|
|
FBtnSend.Caption := 'SEND';
|
|
FBtnSend.Anchors := [akRight, akBottom];
|
|
FBtnSend.OnClick := @OnSendClick;
|
|
|
|
FEdCmd := TFlatEdit.Create(Self);
|
|
FEdCmd.Parent := Self;
|
|
FEdCmd.SetBounds(DpiScale(MARGIN), ClientHeight - DpiScale(MARGIN + BTN_H),
|
|
FBtnSend.Left - DpiScale(MARGIN + 6), DpiScale(BTN_H));
|
|
FEdCmd.TextHint := 'command to cluster (sh/dx, set/filter …)';
|
|
FEdCmd.Anchors := [akLeft, akRight, akBottom];
|
|
FEdCmd.OnKeyDown := @OnCmdKeyDown;
|
|
|
|
FLog := TFlatMemo.Create(Self);
|
|
FLog.Parent := Self;
|
|
FLog.SetBounds(DpiScale(MARGIN),
|
|
FEdCmd.Top - DpiScale(LOG_H + 6),
|
|
ClientWidth - DpiScale(MARGIN * 2), DpiScale(LOG_H));
|
|
FLog.Anchors := [akLeft, akRight, akBottom];
|
|
FLog.ReadOnly := True;
|
|
FLog.WordWrap := False;
|
|
FLog.Font.Name := MONO_FONT;
|
|
FLog.Font.Size := 8;
|
|
|
|
FList := TFlatListBox.Create(Self);
|
|
FList.Parent := Self;
|
|
FList.SetBounds(DpiScale(MARGIN), DpiScale(56),
|
|
ClientWidth - DpiScale(MARGIN * 2),
|
|
FLog.Top - DpiScale(56 + 6));
|
|
FList.Anchors := [akLeft, akTop, akRight, akBottom];
|
|
FList.Font.Name := MONO_FONT;
|
|
FList.Font.Size := 9;
|
|
FList.TabStop := True;
|
|
FList.OnDblClick := @OnListDblClick;
|
|
FList.OnKeyDown := @OnListKeyDown;
|
|
|
|
FTimer := TTimer.Create(Self);
|
|
FTimer.Interval := 1000;
|
|
FTimer.Enabled := False;
|
|
FTimer.OnTimer := @OnTimerTick;
|
|
end;
|
|
|
|
procedure TDXClusterForm.StyleBtn(B: TFlatButton);
|
|
begin
|
|
// TFlatButton темы не знает — цвета выставляются вручную, как в ChannelsForm.
|
|
if B = nil then Exit;
|
|
B.Font.Assign(Font);
|
|
B.Font.Size := 8;
|
|
B.Font.Style := [];
|
|
B.ClrNorm := FTheme.BtnNorm;
|
|
B.ClrActive := FTheme.BtnActive;
|
|
B.ClrHot := FTheme.BtnHot;
|
|
B.ClrBorder := FTheme.BtnBorderNorm;
|
|
B.ClrText := FTheme.BtnText;
|
|
B.ClrTextAct := FTheme.BtnTextActive;
|
|
B.Invalidate;
|
|
end;
|
|
|
|
procedure TDXClusterForm.ApplyTheme(const T: TAppTheme);
|
|
begin
|
|
FTheme := T;
|
|
Color := T.BG;
|
|
FTitle.Font.Color := T.Text;
|
|
FStatus.Font.Color := T.TextDim;
|
|
FList.SetAppTheme(T);
|
|
FLog.SetAppTheme(T);
|
|
FEdCmd.SetAppTheme(T);
|
|
StyleBtn(FBtnConn);
|
|
StyleBtn(FBtnClear);
|
|
StyleBtn(FBtnSort);
|
|
StyleBtn(FBtnClose);
|
|
StyleBtn(FBtnSend);
|
|
Invalidate;
|
|
end;
|
|
|
|
{ ── Наполнение ───────────────────────────────────────────────────────────── }
|
|
|
|
function TDXClusterForm.FormatSpotLine(const S: TDXSpot): string;
|
|
// Моноширинные колонки: частота | позывной | мода | UTC | спотер | комментарий.
|
|
var
|
|
FreqStr, Md: string;
|
|
begin
|
|
FreqStr := FormatFloat('0.0', S.FreqHz / 1000.0); // кГц, как в кластере
|
|
while Length(FreqStr) < 10 do FreqStr := ' ' + FreqStr;
|
|
// Мода, выведенная из бэндплана, помечена '?': спотер её не называл, и
|
|
// видеть разницу полезно — на границах участков таблица может ошибаться.
|
|
Md := DXModeName(S.Mode);
|
|
if (Md <> '') and S.ModeGuessed then Md := Md + '?';
|
|
while Length(Md) < 5 do Md := Md + ' ';
|
|
Result := Format('%s %-12s %s %-5s %-10s %s',
|
|
[FreqStr, S.Call, Md, S.TimeUTC, S.Spotter, S.Comment]);
|
|
end;
|
|
|
|
procedure TDXClusterForm.RefreshSpots;
|
|
var
|
|
i, j, Keep: Integer;
|
|
T: TDXSpot;
|
|
SelCall: string;
|
|
SelFreq: Double;
|
|
begin
|
|
if FStore = nil then Exit;
|
|
// Что выделено — запоминаем позывным И частотой: стор намеренно держит один
|
|
// позывной на разных диапазонах, и по одному позывному выделение после
|
|
// обновления перескочило бы на первый совпавший, а Enter/двойной клик увёл бы
|
|
// радио не на тот диапазон.
|
|
Keep := FList.ItemIndex;
|
|
SelCall := '';
|
|
SelFreq := 0;
|
|
if (Keep >= 0) and (Keep < Length(FSpots)) then
|
|
begin
|
|
SelCall := FSpots[Keep].Call;
|
|
SelFreq := FSpots[Keep].FreqHz;
|
|
end;
|
|
|
|
FStore.Snapshot(FSpots); // приходит отсортированным по частоте
|
|
if not FSortFreq then
|
|
// по времени, свежие сверху (вставками — список короткий, TTL его держит)
|
|
for i := 1 to High(FSpots) do
|
|
begin
|
|
T := FSpots[i];
|
|
j := i - 1;
|
|
while (j >= 0) and (FSpots[j].Stamp < T.Stamp) do
|
|
begin
|
|
FSpots[j + 1] := FSpots[j];
|
|
Dec(j);
|
|
end;
|
|
FSpots[j + 1] := T;
|
|
end;
|
|
|
|
FList.Items.BeginUpdate;
|
|
try
|
|
FList.Items.Clear;
|
|
for i := 0 to High(FSpots) do
|
|
FList.Items.Add(FormatSpotLine(FSpots[i]));
|
|
finally
|
|
FList.Items.EndUpdate;
|
|
end;
|
|
|
|
// Держим выделение на том же споте, если он ещё в списке. Допуск по частоте —
|
|
// тот же, что у дедупа стора: спот того же позывного мог чуть подвинуться.
|
|
if SelCall <> '' then
|
|
for i := 0 to High(FSpots) do
|
|
if SameText(FSpots[i].Call, SelCall) and
|
|
(Abs(FSpots[i].FreqHz - SelFreq) <= DX_DEDUP_HZ) then
|
|
begin
|
|
FList.ItemIndex := i;
|
|
Break;
|
|
end;
|
|
end;
|
|
|
|
procedure TDXClusterForm.RefreshLog;
|
|
var
|
|
L: TStringList;
|
|
begin
|
|
if FClient = nil then Exit;
|
|
L := TStringList.Create;
|
|
try
|
|
FClient.GetLog(L);
|
|
FLog.Lines.BeginUpdate;
|
|
try
|
|
FLog.Lines.Assign(L);
|
|
finally
|
|
FLog.Lines.EndUpdate;
|
|
end;
|
|
// прокрутка в конец — свежие строки внизу
|
|
FLog.SelStart := Length(FLog.Text);
|
|
finally
|
|
L.Free;
|
|
end;
|
|
end;
|
|
|
|
procedure TDXClusterForm.RefreshStatus;
|
|
var
|
|
St: TDXClusterState;
|
|
S: string;
|
|
begin
|
|
if FClient = nil then Exit;
|
|
St := FClient.State;
|
|
S := DXStateName(St);
|
|
if FClient.StatusMessage <> '' then S := S + ' — ' + FClient.StatusMessage;
|
|
S := S + Format(' | spots stored: %d received this session: %d',
|
|
[FStore.Count, FClient.SpotsReceived]);
|
|
FStatus.Caption := S;
|
|
if FClient.Running then FBtnConn.Caption := 'DISCONNECT'
|
|
else FBtnConn.Caption := 'CONNECT';
|
|
end;
|
|
|
|
{ ── События ──────────────────────────────────────────────────────────────── }
|
|
|
|
procedure TDXClusterForm.OnTimerTick(Sender: TObject);
|
|
begin
|
|
if (FStore <> nil) and (FStore.Version <> FLastVer) then
|
|
begin
|
|
FLastVer := FStore.Version;
|
|
RefreshSpots;
|
|
end;
|
|
if (FClient <> nil) and (FClient.LogVersion <> FLastLogVer) then
|
|
begin
|
|
FLastLogVer := FClient.LogVersion;
|
|
RefreshLog;
|
|
RefreshStatus;
|
|
end;
|
|
end;
|
|
|
|
procedure TDXClusterForm.OnConnClick(Sender: TObject);
|
|
begin
|
|
if FClient = nil then Exit;
|
|
if FClient.Running then FClient.Stop else FClient.Start;
|
|
RefreshStatus;
|
|
end;
|
|
|
|
procedure TDXClusterForm.OnClearClick(Sender: TObject);
|
|
begin
|
|
if FStore <> nil then FStore.Clear;
|
|
FLastVer := -1;
|
|
RefreshSpots;
|
|
end;
|
|
|
|
procedure TDXClusterForm.OnSortClick(Sender: TObject);
|
|
begin
|
|
FSortFreq := not FSortFreq;
|
|
if FSortFreq then FBtnSort.Caption := 'BY FREQ'
|
|
else FBtnSort.Caption := 'BY TIME';
|
|
RefreshSpots;
|
|
end;
|
|
|
|
procedure TDXClusterForm.OnCloseClick(Sender: TObject);
|
|
begin
|
|
Close;
|
|
end;
|
|
|
|
procedure TDXClusterForm.OnSendClick(Sender: TObject);
|
|
begin
|
|
if (FClient = nil) or (Trim(FEdCmd.Text) = '') then Exit;
|
|
FClient.SendCommand(Trim(FEdCmd.Text));
|
|
FEdCmd.Text := '';
|
|
end;
|
|
|
|
procedure TDXClusterForm.OnCmdKeyDown(Sender: TObject; var Key: Word;
|
|
Shift: TShiftState);
|
|
begin
|
|
if Key = 13 then
|
|
begin
|
|
Key := 0;
|
|
OnSendClick(nil);
|
|
end;
|
|
end;
|
|
|
|
procedure TDXClusterForm.TuneSelected;
|
|
var i: Integer;
|
|
begin
|
|
i := FList.ItemIndex;
|
|
if (i < 0) or (i > High(FSpots)) then Exit;
|
|
if Assigned(FOnTune) then FOnTune(FSpots[i].FreqHz, FSpots[i].Mode);
|
|
end;
|
|
|
|
procedure TDXClusterForm.OnListDblClick(Sender: TObject);
|
|
begin
|
|
TuneSelected;
|
|
end;
|
|
|
|
procedure TDXClusterForm.OnListKeyDown(Sender: TObject; var Key: Word;
|
|
Shift: TShiftState);
|
|
// Enter по выделенной строке = то же, что двойной клик (QSY на спот).
|
|
begin
|
|
if (Key = 13) or (Key = 10) then
|
|
begin
|
|
Key := 0;
|
|
TuneSelected;
|
|
end;
|
|
end;
|
|
|
|
procedure TDXClusterForm.OnFormClose(Sender: TObject; var CloseAction: TCloseAction);
|
|
begin
|
|
CloseAction := caHide;
|
|
end;
|
|
|
|
procedure TDXClusterForm.DoShow;
|
|
begin
|
|
inherited DoShow;
|
|
// Поллинг живёт только пока окно видимо (как анализатор лупы маяка).
|
|
FLastVer := -1;
|
|
FLastLogVer := -1;
|
|
RefreshSpots;
|
|
RefreshLog;
|
|
RefreshStatus;
|
|
FTimer.Enabled := True;
|
|
end;
|
|
|
|
procedure TDXClusterForm.DoHide;
|
|
begin
|
|
FTimer.Enabled := False;
|
|
inherited DoHide;
|
|
end;
|
|
|
|
end.
|