Ужесточение имен каналов и удаление legacy USER_PARAM для описания
This commit is contained in:
parent
acdd6c928b
commit
4956ba7352
@ -1,2 +1,2 @@
|
|||||||
client.version=1.2.42
|
client.version=1.2.43
|
||||||
server.version=1.2.36
|
server.version=1.2.37
|
||||||
|
|||||||
@ -44,11 +44,11 @@ export function render({ navigate }) {
|
|||||||
form.className = 'card stack';
|
form.className = 'card stack';
|
||||||
form.innerHTML = `
|
form.innerHTML = `
|
||||||
<strong class="channel-head-title">Создание канала</strong>
|
<strong class="channel-head-title">Создание канала</strong>
|
||||||
<p class="channel-head-meta">Можно использовать кириллицу, латиницу, цифры, пробел, _ и -.</p>
|
<p class="channel-head-meta">Можно использовать только латиницу, цифры, _ и -.</p>
|
||||||
<p class="channel-head-meta">Длина названия: от 3 до 32 символов. Название уникально во всей системе.</p>
|
<p class="channel-head-meta">Длина названия: от 3 до 32 символов. Название уникально во всей системе.</p>
|
||||||
|
|
||||||
<label for="channel-name">Название канала</label>
|
<label for="channel-name">Название канала</label>
|
||||||
<input id="channel-name" class="input" maxlength="64" placeholder="Например: Поток силы" required />
|
<input id="channel-name" class="input" maxlength="32" placeholder="Например: my_channel-1" required />
|
||||||
<div id="channel-name-error" class="meta-muted inline-error"></div>
|
<div id="channel-name-error" class="meta-muted inline-error"></div>
|
||||||
|
|
||||||
<label for="channel-description">Описание канала (необязательно)</label>
|
<label for="channel-description">Описание канала (необязательно)</label>
|
||||||
@ -124,18 +124,14 @@ export function render({ navigate }) {
|
|||||||
errorEl.textContent = '';
|
errorEl.textContent = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const created = await authService.addBlockCreateChannel({
|
await authService.addBlockCreateChannel({
|
||||||
login,
|
login,
|
||||||
storagePwd,
|
storagePwd,
|
||||||
channelName: normalizeChannelDisplayName(check.name),
|
channelName: normalizeChannelDisplayName(check.name),
|
||||||
channelDescription: normalizeChannelDescription(check.description),
|
channelDescription: normalizeChannelDescription(check.description),
|
||||||
});
|
});
|
||||||
|
|
||||||
const baseMessage = `Канал "${normalizeChannelDisplayName(check.name)}" создан.`;
|
persistCreateSuccessFlash(`Канал "${normalizeChannelDisplayName(check.name)}" создан.`);
|
||||||
const successMessage = created?.usedLegacyDescriptionFallback && created?.savedDescriptionViaUserParam
|
|
||||||
? `${baseMessage} Описание сохранено через блок параметра.`
|
|
||||||
: baseMessage;
|
|
||||||
persistCreateSuccessFlash(successMessage);
|
|
||||||
navigate('channels-list');
|
navigate('channels-list');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errorEl.textContent = toUserMessage(error, 'Не удалось создать канал.');
|
errorEl.textContent = toUserMessage(error, 'Не удалось создать канал.');
|
||||||
|
|||||||
@ -123,41 +123,6 @@ function buildAbsoluteRouteUrl(routePath = '') {
|
|||||||
return url.toString();
|
return url.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
function channelDescriptionParamKey(selector) {
|
|
||||||
const owner = String(selector?.ownerBlockchainName || '').trim();
|
|
||||||
const rootNo = Number(selector?.channelRootBlockNumber);
|
|
||||||
const rootHash = normalizeRouteHash(selector?.channelRootBlockHash);
|
|
||||||
if (!owner || !Number.isFinite(rootNo)) return '';
|
|
||||||
return `channel_desc:${owner}:${rootNo}:${rootHash}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseDescriptionOverride(payload) {
|
|
||||||
if (!payload || typeof payload !== 'object') {
|
|
||||||
return { hasOverride: false, description: '' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const rawValue = String(payload?.value ?? payload?.param_value ?? '').trim();
|
|
||||||
if (!rawValue && !Number(payload?.time_ms || payload?.timeMs || 0)) {
|
|
||||||
return { hasOverride: false, description: '' };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!rawValue) {
|
|
||||||
return { hasOverride: true, description: '' };
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(rawValue);
|
|
||||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
||||||
const value = typeof parsed.v === 'string' ? parsed.v : '';
|
|
||||||
return { hasOverride: true, description: value.trim() };
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// legacy raw string value
|
|
||||||
}
|
|
||||||
|
|
||||||
return { hasOverride: true, description: rawValue };
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSelectorFromRoute(route, channelId) {
|
function buildSelectorFromRoute(route, channelId) {
|
||||||
const params = route?.params || {};
|
const params = route?.params || {};
|
||||||
|
|
||||||
@ -410,88 +375,6 @@ function openAddMessageModal({ channelName, onSubmit }) {
|
|||||||
if (textEl) textEl.focus();
|
if (textEl) textEl.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEditDescriptionModal({ initialValue = '', onSubmit }) {
|
|
||||||
const root = document.getElementById('modal-root');
|
|
||||||
root.innerHTML = `
|
|
||||||
<div class="modal" id="channel-edit-description-modal">
|
|
||||||
<div class="modal-card stack">
|
|
||||||
<h3 class="modal-title">Описание канала</h3>
|
|
||||||
<textarea id="channel-description-text" class="input" rows="5" maxlength="400" placeholder="Коротко о канале, до 200 байт UTF-8"></textarea>
|
|
||||||
<div class="meta-muted" id="channel-description-counter">0 / 200 байт</div>
|
|
||||||
<div class="meta-muted inline-error" id="channel-description-error"></div>
|
|
||||||
<div class="form-actions-grid">
|
|
||||||
<button class="secondary-btn" id="channel-description-cancel" type="button">Отмена</button>
|
|
||||||
<button class="primary-btn" id="channel-description-submit" type="button">Сохранить</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const textEl = root.querySelector('#channel-description-text');
|
|
||||||
const counterEl = root.querySelector('#channel-description-counter');
|
|
||||||
const errorEl = root.querySelector('#channel-description-error');
|
|
||||||
const submitEl = root.querySelector('#channel-description-submit');
|
|
||||||
const cancelEl = root.querySelector('#channel-description-cancel');
|
|
||||||
|
|
||||||
let inFlight = false;
|
|
||||||
|
|
||||||
const compute = () => {
|
|
||||||
const value = String(textEl?.value || '').replace(/\s+/g, ' ').trim();
|
|
||||||
const bytes = new TextEncoder().encode(value).length;
|
|
||||||
const ok = bytes <= 200;
|
|
||||||
return {
|
|
||||||
value,
|
|
||||||
bytes,
|
|
||||||
ok,
|
|
||||||
error: ok ? '' : 'Описание слишком длинное: максимум 200 байт UTF-8.',
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const setBusy = (busy) => {
|
|
||||||
inFlight = !!busy;
|
|
||||||
submitEl.disabled = inFlight;
|
|
||||||
cancelEl.disabled = inFlight;
|
|
||||||
if (textEl) textEl.disabled = inFlight;
|
|
||||||
submitEl.textContent = inFlight ? 'Сохраняем...' : 'Сохранить';
|
|
||||||
};
|
|
||||||
|
|
||||||
const close = () => {
|
|
||||||
root.innerHTML = '';
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateValidation = () => {
|
|
||||||
const check = compute();
|
|
||||||
counterEl.textContent = `${check.bytes} / 200 байт`;
|
|
||||||
errorEl.textContent = check.error;
|
|
||||||
submitEl.disabled = inFlight || !check.ok;
|
|
||||||
return check;
|
|
||||||
};
|
|
||||||
|
|
||||||
cancelEl?.addEventListener('click', close);
|
|
||||||
textEl?.addEventListener('input', updateValidation);
|
|
||||||
submitEl?.addEventListener('click', async () => {
|
|
||||||
if (inFlight) return;
|
|
||||||
const check = updateValidation();
|
|
||||||
if (!check.ok) return;
|
|
||||||
|
|
||||||
setBusy(true);
|
|
||||||
errorEl.textContent = '';
|
|
||||||
try {
|
|
||||||
await onSubmit(check.value);
|
|
||||||
close();
|
|
||||||
} catch (error) {
|
|
||||||
setBusy(false);
|
|
||||||
errorEl.textContent = toUserMessage(error, 'Не удалось сохранить описание.');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (textEl) {
|
|
||||||
textEl.value = String(initialValue || '');
|
|
||||||
textEl.focus();
|
|
||||||
}
|
|
||||||
updateValidation();
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapApiMessageToPost(message, selector, localNumber) {
|
function mapApiMessageToPost(message, selector, localNumber) {
|
||||||
const blockNumber = toSafeInt(message?.messageRef?.blockNumber);
|
const blockNumber = toSafeInt(message?.messageRef?.blockNumber);
|
||||||
const blockHash = normalizeMessageHash(message?.messageRef?.blockHash);
|
const blockHash = normalizeMessageHash(message?.messageRef?.blockHash);
|
||||||
@ -552,27 +435,11 @@ async function loadFromApi(route, channelId) {
|
|||||||
const posts = messages.map((message, index) => mapApiMessageToPost(message, selector, index + 1));
|
const posts = messages.map((message, index) => mapApiMessageToPost(message, selector, index + 1));
|
||||||
const ownerLogin = String(payload.channel?.ownerLogin || '').trim();
|
const ownerLogin = String(payload.channel?.ownerLogin || '').trim();
|
||||||
|
|
||||||
const readDescription = async () => {
|
|
||||||
const sourceDescription = String(payload.channel?.channelDescription || '').trim();
|
|
||||||
const paramKey = channelDescriptionParamKey(selector);
|
|
||||||
if (!ownerLogin || !paramKey) return sourceDescription;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const paramPayload = await authService.getUserParam(ownerLogin, paramKey);
|
|
||||||
const override = parseDescriptionOverride(paramPayload);
|
|
||||||
return override.hasOverride ? override.description : sourceDescription;
|
|
||||||
} catch {
|
|
||||||
return sourceDescription;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const resolvedDescription = await readDescription();
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
channel: {
|
channel: {
|
||||||
name: payload.channel?.channelName || 'неизвестный канал',
|
name: payload.channel?.channelName || 'неизвестный канал',
|
||||||
displayName: `${ownerLogin || 'неизвестно'}/${payload.channel?.channelName || 'неизвестный канал'}`,
|
displayName: `${ownerLogin || 'неизвестно'}/${payload.channel?.channelName || 'неизвестный канал'}`,
|
||||||
description: resolvedDescription,
|
description: String(payload.channel?.channelDescription || '').trim(),
|
||||||
ownerName: ownerLogin || 'неизвестно',
|
ownerName: ownerLogin || 'неизвестно',
|
||||||
},
|
},
|
||||||
posts,
|
posts,
|
||||||
@ -818,22 +685,6 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
|||||||
});
|
});
|
||||||
headActions.append(aboutButton);
|
headActions.append(aboutButton);
|
||||||
|
|
||||||
if (channelData.isOwnChannel) {
|
|
||||||
const editButton = document.createElement('button');
|
|
||||||
editButton.type = 'button';
|
|
||||||
editButton.className = 'secondary-btn small-btn';
|
|
||||||
editButton.textContent = '✎';
|
|
||||||
editButton.title = 'Редактировать описание';
|
|
||||||
editButton.addEventListener('click', (event) => {
|
|
||||||
animatePress(event.currentTarget);
|
|
||||||
openEditDescriptionModal({
|
|
||||||
initialValue: channelData.channel.description || '',
|
|
||||||
onSubmit: async (nextValue) => handlers.onEditDescription(nextValue),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
headActions.append(editButton);
|
|
||||||
}
|
|
||||||
|
|
||||||
head.append(title);
|
head.append(title);
|
||||||
head.append(owner, headActions);
|
head.append(owner, headActions);
|
||||||
|
|
||||||
@ -1024,25 +875,6 @@ export function render({ navigate, route }) {
|
|||||||
rerender();
|
rerender();
|
||||||
};
|
};
|
||||||
|
|
||||||
const onEditDescription = async (descriptionText) => {
|
|
||||||
const { login, storagePwd } = requireSigningSession();
|
|
||||||
const selector = routeSelector;
|
|
||||||
const param = channelDescriptionParamKey(selector);
|
|
||||||
if (!param) throw new Error('Идентификатор канала не готов для обновления описания.');
|
|
||||||
|
|
||||||
const value = JSON.stringify({ v: String(descriptionText || '').trim() });
|
|
||||||
await authService.addBlockUserParam({
|
|
||||||
login,
|
|
||||||
storagePwd,
|
|
||||||
param,
|
|
||||||
value,
|
|
||||||
});
|
|
||||||
|
|
||||||
softHaptic(10);
|
|
||||||
showToast('Описание канала сохранено');
|
|
||||||
rerender();
|
|
||||||
};
|
|
||||||
|
|
||||||
screen.append(
|
screen.append(
|
||||||
renderHeader({
|
renderHeader({
|
||||||
title: '',
|
title: '',
|
||||||
@ -1085,14 +917,6 @@ export function render({ navigate, route }) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onShare: onShare,
|
onShare: onShare,
|
||||||
onEditDescription: async (descriptionText) => {
|
|
||||||
try {
|
|
||||||
await onEditDescription(descriptionText);
|
|
||||||
showStatus('');
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error(toUserMessage(error, 'Не удалось сохранить описание.'));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onSubscribeChannel: async (event) => {
|
onSubscribeChannel: async (event) => {
|
||||||
animatePress(event?.currentTarget);
|
animatePress(event?.currentTarget);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -92,27 +92,6 @@ function opError(op, response) {
|
|||||||
return error;
|
return error;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isLegacyCreateChannelFormatError(error) {
|
|
||||||
const code = String(error?.code || '').trim().toUpperCase();
|
|
||||||
const text = String(error?.message || '').toLowerCase();
|
|
||||||
if (code === 'BAD_BLOCK_FORMAT') return true;
|
|
||||||
return (
|
|
||||||
text.includes('unknown body type/version') ||
|
|
||||||
text.includes('unknown tech body type/version/subtype') ||
|
|
||||||
text.includes('bad_block_format')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function channelDescriptionParamKeyFromSelector(selector) {
|
|
||||||
const owner = String(selector?.ownerBlockchainName || '').trim();
|
|
||||||
const rootNo = Number(selector?.channelRootBlockNumber);
|
|
||||||
const rootHash = String(selector?.channelRootBlockHash || '').trim().toLowerCase();
|
|
||||||
if (!owner || !Number.isFinite(rootNo) || rootNo < 0 || !/^[0-9a-f]{64}$/.test(rootHash)) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
return `channel_desc:${owner}:${rootNo}:${rootHash}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeClientInfo() {
|
function makeClientInfo() {
|
||||||
const ua = navigator.userAgent || 'unknown';
|
const ua = navigator.userAgent || 'unknown';
|
||||||
return ua.slice(0, 50);
|
return ua.slice(0, 50);
|
||||||
@ -416,26 +395,6 @@ function makeConnectionBodyBytes({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeCreateChannelBodyBytes({ lineCode, prevLineNumber, prevLineHashHex, thisLineNumber, channelName }) {
|
|
||||||
const check = validateChannelDisplayName(channelName);
|
|
||||||
if (!check.ok) throw new Error(channelNameErrorText(check.code));
|
|
||||||
const cleanName = check.normalized;
|
|
||||||
|
|
||||||
const nameBytes = utf8Bytes(cleanName);
|
|
||||||
if (nameBytes.length < 1 || nameBytes.length > 255) {
|
|
||||||
throw new Error('Channel name must be 1..255 bytes');
|
|
||||||
}
|
|
||||||
|
|
||||||
return concatBytes(
|
|
||||||
int32Bytes(lineCode),
|
|
||||||
int32Bytes(prevLineNumber),
|
|
||||||
hexToBytes(normalizeHex32(prevLineHashHex)),
|
|
||||||
int32Bytes(thisLineNumber),
|
|
||||||
int8Byte(nameBytes.length),
|
|
||||||
nameBytes
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeChannelDescription(value) {
|
function normalizeChannelDescription(value) {
|
||||||
const text = String(value == null ? '' : value).trim().replace(/\s+/g, ' ');
|
const text = String(value == null ? '' : value).trim().replace(/\s+/g, ' ');
|
||||||
const bytes = utf8Bytes(text);
|
const bytes = utf8Bytes(text);
|
||||||
@ -1088,44 +1047,21 @@ export class AuthService {
|
|||||||
thisLineNumber = createdChannels.length + 1;
|
thisLineNumber = createdChannels.length + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
const submitCreate = async (useV2) => {
|
const payload = await this.addBlockSigned({
|
||||||
const bodyBytes = useV2
|
login: cleanLogin,
|
||||||
? makeCreateChannelBodyV2Bytes({
|
storagePwd,
|
||||||
lineCode: 0,
|
msgType: MSG_TYPE_TECH,
|
||||||
prevLineNumber,
|
msgSubType: MSG_SUBTYPE_TECH_CREATE_CHANNEL,
|
||||||
prevLineHashHex,
|
msgVersion: CREATE_CHANNEL_BODY_VERSION,
|
||||||
thisLineNumber,
|
bodyBytes: makeCreateChannelBodyV2Bytes({
|
||||||
channelName: cleanChannelName,
|
lineCode: 0,
|
||||||
channelDescription: cleanChannelDescription,
|
prevLineNumber,
|
||||||
})
|
prevLineHashHex,
|
||||||
: makeCreateChannelBodyBytes({
|
thisLineNumber,
|
||||||
lineCode: 0,
|
channelName: cleanChannelName,
|
||||||
prevLineNumber,
|
channelDescription: cleanChannelDescription,
|
||||||
prevLineHashHex,
|
}),
|
||||||
thisLineNumber,
|
});
|
||||||
channelName: cleanChannelName,
|
|
||||||
});
|
|
||||||
|
|
||||||
return this.addBlockSigned({
|
|
||||||
login: cleanLogin,
|
|
||||||
storagePwd,
|
|
||||||
msgType: MSG_TYPE_TECH,
|
|
||||||
msgSubType: MSG_SUBTYPE_TECH_CREATE_CHANNEL,
|
|
||||||
msgVersion: useV2 ? CREATE_CHANNEL_BODY_VERSION : 1,
|
|
||||||
bodyBytes,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
let payload;
|
|
||||||
let usedLegacyDescriptionFallback = false;
|
|
||||||
let savedDescriptionViaUserParam = false;
|
|
||||||
try {
|
|
||||||
payload = await submitCreate(true);
|
|
||||||
} catch (error) {
|
|
||||||
if (!isLegacyCreateChannelFormatError(error)) throw error;
|
|
||||||
payload = await submitCreate(false);
|
|
||||||
usedLegacyDescriptionFallback = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const selector = {
|
const selector = {
|
||||||
ownerBlockchainName: blockchainName,
|
ownerBlockchainName: blockchainName,
|
||||||
@ -1133,24 +1069,8 @@ export class AuthService {
|
|||||||
channelRootBlockHash: normalizeHex32(payload?.serverLastGlobalHash, ZERO64),
|
channelRootBlockHash: normalizeHex32(payload?.serverLastGlobalHash, ZERO64),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (usedLegacyDescriptionFallback && cleanChannelDescription) {
|
|
||||||
const param = channelDescriptionParamKeyFromSelector(selector);
|
|
||||||
if (!param) {
|
|
||||||
throw new Error('Не удалось сохранить описание канала: некорректный идентификатор канала.');
|
|
||||||
}
|
|
||||||
await this.addBlockUserParam({
|
|
||||||
login: cleanLogin,
|
|
||||||
storagePwd,
|
|
||||||
param,
|
|
||||||
value: JSON.stringify({ v: cleanChannelDescription }),
|
|
||||||
});
|
|
||||||
savedDescriptionViaUserParam = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...payload,
|
...payload,
|
||||||
usedLegacyDescriptionFallback,
|
|
||||||
savedDescriptionViaUserParam,
|
|
||||||
channel: {
|
channel: {
|
||||||
...selector,
|
...selector,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
const MIN_LEN = 3;
|
const MIN_LEN = 3;
|
||||||
const MAX_LEN = 32;
|
const MAX_LEN = 32;
|
||||||
const ALLOWED_CHARS_RE = /^[\p{Script=Latin}\p{Script=Cyrillic}0-9 _-]+$/u;
|
const ALLOWED_CHARS_RE = /^[A-Za-z0-9_-]+$/;
|
||||||
|
|
||||||
export function normalizeChannelDisplayName(value) {
|
export function normalizeChannelDisplayName(value) {
|
||||||
if (value == null) return '';
|
if (value == null) return '';
|
||||||
return String(value).trim().replace(/\s+/g, ' ');
|
return String(value).trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeChannelDescription(value) {
|
export function normalizeChannelDescription(value) {
|
||||||
@ -16,24 +16,9 @@ export function toCanonicalChannelSlug(value) {
|
|||||||
const normalized = normalizeChannelDisplayName(value);
|
const normalized = normalizeChannelDisplayName(value);
|
||||||
if (!normalized) return '';
|
if (!normalized) return '';
|
||||||
|
|
||||||
const lowered = normalized.toLowerCase().replace(/\u0451/g, '\u0435');
|
const lowered = normalized.toLowerCase();
|
||||||
let out = '';
|
if (!ALLOWED_CHARS_RE.test(lowered)) return '';
|
||||||
let pendingSeparator = false;
|
return lowered;
|
||||||
|
|
||||||
for (const ch of lowered) {
|
|
||||||
if (ch === ' ' || ch === '_' || ch === '-') {
|
|
||||||
pendingSeparator = out.length > 0;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!/[\p{Script=Latin}\p{Script=Cyrillic}0-9]/u.test(ch)) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
if (pendingSeparator && out.length > 0) out += '-';
|
|
||||||
out += ch;
|
|
||||||
pendingSeparator = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return out.replace(/-+$/g, '');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function validateChannelDisplayName(value) {
|
export function validateChannelDisplayName(value) {
|
||||||
@ -73,7 +58,7 @@ export function channelNameErrorText(code) {
|
|||||||
case 'too_long':
|
case 'too_long':
|
||||||
return 'Название слишком длинное: максимум 32 символа.';
|
return 'Название слишком длинное: максимум 32 символа.';
|
||||||
case 'bad_chars':
|
case 'bad_chars':
|
||||||
return 'Разрешены кириллица, латиница, цифры, пробел, _ и -.';
|
return 'Разрешены только латиница, цифры, _ и -.';
|
||||||
case 'reserved':
|
case 'reserved':
|
||||||
return 'Название "0" зарезервировано.';
|
return 'Название "0" зарезервировано.';
|
||||||
default:
|
default:
|
||||||
|
|||||||
@ -7,13 +7,13 @@ public final class ChannelNameRules {
|
|||||||
private static final int MIN_DISPLAY_NAME_LENGTH = 3;
|
private static final int MIN_DISPLAY_NAME_LENGTH = 3;
|
||||||
private static final int MAX_DISPLAY_NAME_LENGTH = 32;
|
private static final int MAX_DISPLAY_NAME_LENGTH = 32;
|
||||||
private static final Pattern DISPLAY_ALLOWED_PATTERN =
|
private static final Pattern DISPLAY_ALLOWED_PATTERN =
|
||||||
Pattern.compile("^[\\p{IsLatin}\\p{IsCyrillic}0-9 _-]+$");
|
Pattern.compile("^[A-Za-z0-9_-]+$");
|
||||||
|
|
||||||
private ChannelNameRules() {}
|
private ChannelNameRules() {}
|
||||||
|
|
||||||
public static String normalizeDisplayName(String value) {
|
public static String normalizeDisplayName(String value) {
|
||||||
if (value == null) return "";
|
if (value == null) return "";
|
||||||
return value.trim().replaceAll("\\s+", " ");
|
return value.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static String requireValidDisplayNameForCreate(String rawName) {
|
public static String requireValidDisplayNameForCreate(String rawName) {
|
||||||
@ -40,45 +40,10 @@ public final class ChannelNameRules {
|
|||||||
throw new IllegalArgumentException("channelName is blank");
|
throw new IllegalArgumentException("channelName is blank");
|
||||||
}
|
}
|
||||||
|
|
||||||
String lowered = normalized.toLowerCase(Locale.ROOT).replace('\u0451', '\u0435');
|
String lowered = normalized.toLowerCase(Locale.ROOT);
|
||||||
StringBuilder slug = new StringBuilder(lowered.length());
|
if (!DISPLAY_ALLOWED_PATTERN.matcher(lowered).matches()) {
|
||||||
boolean pendingSeparator = false;
|
throw new IllegalArgumentException("channelName contains unsupported characters");
|
||||||
|
|
||||||
for (int i = 0; i < lowered.length(); ) {
|
|
||||||
int cp = lowered.codePointAt(i);
|
|
||||||
i += Character.charCount(cp);
|
|
||||||
|
|
||||||
if (cp == ' ' || cp == '_' || cp == '-') {
|
|
||||||
pendingSeparator = slug.length() > 0;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isLatinOrCyrillicOrDigit(cp)) {
|
|
||||||
throw new IllegalArgumentException("channelName contains unsupported characters");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pendingSeparator && slug.length() > 0) {
|
|
||||||
slug.append('-');
|
|
||||||
}
|
|
||||||
pendingSeparator = false;
|
|
||||||
slug.appendCodePoint(cp);
|
|
||||||
}
|
}
|
||||||
|
return lowered;
|
||||||
int len = slug.length();
|
|
||||||
if (len > 0 && slug.charAt(len - 1) == '-') {
|
|
||||||
slug.deleteCharAt(len - 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (slug.length() == 0) {
|
|
||||||
throw new IllegalArgumentException("channelName canonical slug is empty");
|
|
||||||
}
|
|
||||||
|
|
||||||
return slug.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean isLatinOrCyrillicOrDigit(int cp) {
|
|
||||||
if (Character.isDigit(cp)) return true;
|
|
||||||
Character.UnicodeScript script = Character.UnicodeScript.of(cp);
|
|
||||||
return script == Character.UnicodeScript.LATIN || script == Character.UnicodeScript.CYRILLIC;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user