spec(role/ScheduledNote): ロールで予約投稿の個数・予約の最大日数を制御できるように (MisskeyIO#906)

This commit is contained in:
あわわわとーにゅ 2025-01-17 17:08:13 +09:00 committed by GitHub
parent f0f86f1121
commit 8821e3e81b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 148 additions and 23 deletions

View file

@ -1794,6 +1794,8 @@ _role:
ltlAvailable: "Can view the local timeline" ltlAvailable: "Can view the local timeline"
canPublicNote: "Can send public notes" canPublicNote: "Can send public notes"
canScheduleNote: "Can schedule notes" canScheduleNote: "Can schedule notes"
scheduleNoteLimit: "Maximum number of scheduled notes"
scheduleNoteMaxDays: "Maximum number of days that note can be scheduled"
canInitiateConversation: "Can mention, reply or quote" canInitiateConversation: "Can mention, reply or quote"
canCreateContent: "Can create contents" canCreateContent: "Can create contents"
canUpdateContent: "Can edit contents" canUpdateContent: "Can edit contents"
@ -2324,6 +2326,7 @@ _postForm:
d: "What do you want to say?" d: "What do you want to say?"
e: "Start writing..." e: "Start writing..."
f: "Waiting for you to write..." f: "Waiting for you to write..."
policyScheduleNoteMaxDaysExceeded: "The maximum number of days you can schedule notes for with your current support plan is {max}.\nYou can upgrade your plan [here](https://go.misskey.io/donate)."
tosAndGuidelinesInfo: "Before posting, please read the [Terms of Service]({tosUrl}) and [NSFW Guidelines](https://go.misskey.io/media-guideline)." tosAndGuidelinesInfo: "Before posting, please read the [Terms of Service]({tosUrl}) and [NSFW Guidelines](https://go.misskey.io/media-guideline)."
_profile: _profile:
name: "Name" name: "Name"

13
locales/index.d.ts vendored
View file

@ -7019,6 +7019,14 @@ export interface Locale extends ILocale {
* 稿 * 稿
*/ */
"canScheduleNote": string; "canScheduleNote": string;
/**
* 稿
*/
"scheduleNoteLimit": string;
/**
* 稿
*/
"scheduleNoteMaxDays": string;
/** /**
* *
*/ */
@ -9067,6 +9075,11 @@ export interface Locale extends ILocale {
*/ */
"f": string; "f": string;
}; };
/**
* {max}
* [](https://go.misskey.io/donate)からプランをアップグレードできます。
*/
"policyScheduleNoteMaxDaysExceeded": ParameterizedString<"max">;
/** /**
* 稿[]({tosUrl})[NSFWガイドライン](https://go.misskey.io/media-guideline)を必ずお読みください。 * 稿[]({tosUrl})[NSFWガイドライン](https://go.misskey.io/media-guideline)を必ずお読みください。
*/ */

View file

@ -1808,6 +1808,8 @@ _role:
ltlAvailable: "ローカルタイムラインの閲覧" ltlAvailable: "ローカルタイムラインの閲覧"
canPublicNote: "パブリック投稿の許可" canPublicNote: "パブリック投稿の許可"
canScheduleNote: "予約投稿の許可" canScheduleNote: "予約投稿の許可"
scheduleNoteLimit: "予約投稿の最大数"
scheduleNoteMaxDays: "予約投稿の最大日数"
canInitiateConversation: "メンション、リプライ、引用の許可" canInitiateConversation: "メンション、リプライ、引用の許可"
canCreateContent: "コンテンツの作成" canCreateContent: "コンテンツの作成"
canUpdateContent: "コンテンツの編集" canUpdateContent: "コンテンツの編集"
@ -2377,6 +2379,7 @@ _postForm:
d: "言いたいことは?" d: "言いたいことは?"
e: "ここに書いてください" e: "ここに書いてください"
f: "あなたが書くのを待っています..." f: "あなたが書くのを待っています..."
policyScheduleNoteMaxDaysExceeded: "現在の支援プランで予約できる日数の上限は{max}日です。\n[ここ](https://go.misskey.io/donate)からプランをアップグレードできます。"
tosAndGuidelinesInfo: "投稿する前に、[利用規約]({tosUrl})と[NSFWガイドライン](https://go.misskey.io/media-guideline)を必ずお読みください。" tosAndGuidelinesInfo: "投稿する前に、[利用規約]({tosUrl})と[NSFWガイドライン](https://go.misskey.io/media-guideline)を必ずお読みください。"
_profile: _profile:

View file

@ -1791,6 +1791,8 @@ _role:
ltlAvailable: "로컬 타임라인 보이기" ltlAvailable: "로컬 타임라인 보이기"
canPublicNote: "공개 노트 허용" canPublicNote: "공개 노트 허용"
canScheduleNote: "노트 예약 허용" canScheduleNote: "노트 예약 허용"
scheduleNoteLimit: "노트 예약 한도"
scheduleNoteMaxDays: "노트 예약 최대 일수"
mentionMax: "노트에 넣을 수 있는 멘션 수" mentionMax: "노트에 넣을 수 있는 멘션 수"
canCreateContent: "컨텐츠 생성 허용" canCreateContent: "컨텐츠 생성 허용"
canUpdateContent: "컨텐츠 수정 허용" canUpdateContent: "컨텐츠 수정 허용"
@ -2310,6 +2312,7 @@ _postForm:
d: "말하고 싶은 게 있나요?" d: "말하고 싶은 게 있나요?"
e: "여기에 적어 주세요" e: "여기에 적어 주세요"
f: "글 쓰기를 기다려요…" f: "글 쓰기를 기다려요…"
policyScheduleNoteMaxDaysExceeded: "현재 지원 플랜의 예약 가능한 최대 일수는 {max}일입니다.\n[여기](https://go.misskey.io/donate)에서 플랜을 업그레이드할 수 있습니다."
tosAndGuidelinesInfo: "노트를 게시하기 전에 [이용약관]({tosUrl})과 [NSFW 가이드라인](https://go.misskey.io/media-guideline)을 반드시 읽어 주세요." tosAndGuidelinesInfo: "노트를 게시하기 전에 [이용약관]({tosUrl})과 [NSFW 가이드라인](https://go.misskey.io/media-guideline)을 반드시 읽어 주세요."
_profile: _profile:
name: "이름" name: "이름"

View file

@ -425,6 +425,15 @@ export class NoteCreateService implements OnApplicationShutdown {
throw new IdentifiableError('7cc42034-f7ab-4f7c-87b4-e00854479080', 'User has no permission to schedule notes.'); throw new IdentifiableError('7cc42034-f7ab-4f7c-87b4-e00854479080', 'User has no permission to schedule notes.');
} }
if ((data.scheduledAt.getTime() - Date.now()) / 86_400_000 > policies.scheduleNoteMaxDays) {
throw new IdentifiableError('506006cf-3092-4ae1-8145-b025001c591f', `User can schedule notes up to ${policies.scheduleNoteMaxDays} days in the future.`);
}
const scheduledCount = await this.scheduledNotesRepository.countBy({ userId: user.id });
if (scheduledCount >= policies.scheduleNoteLimit) {
throw new IdentifiableError('7fc78d25-d947-45c1-9547-02257b98cab3', `User can schedule up to ${policies.scheduleNoteLimit} notes.`);
}
const draft = await this.insertScheduledNote(user, data); const draft = await this.insertScheduledNote(user, data);
await this.queueService.createScheduledNoteJob(draft.id, draft.scheduledAt!); await this.queueService.createScheduledNoteJob(draft.id, draft.scheduledAt!);

View file

@ -37,6 +37,8 @@ export type RolePolicies = {
ltlAvailable: boolean; ltlAvailable: boolean;
canPublicNote: boolean; canPublicNote: boolean;
canScheduleNote: boolean; canScheduleNote: boolean;
scheduleNoteLimit: number;
scheduleNoteMaxDays: number;
canInitiateConversation: boolean; canInitiateConversation: boolean;
canCreateContent: boolean; canCreateContent: boolean;
canUpdateContent: boolean; canUpdateContent: boolean;
@ -79,6 +81,8 @@ export const DEFAULT_POLICIES: RolePolicies = {
ltlAvailable: true, ltlAvailable: true,
canPublicNote: true, canPublicNote: true,
canScheduleNote: true, canScheduleNote: true,
scheduleNoteLimit: 10,
scheduleNoteMaxDays: 365,
canInitiateConversation: true, canInitiateConversation: true,
canCreateContent: true, canCreateContent: true,
canUpdateContent: true, canUpdateContent: true,
@ -392,6 +396,8 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
ltlAvailable: calc('ltlAvailable', vs => vs.some(v => v === true)), ltlAvailable: calc('ltlAvailable', vs => vs.some(v => v === true)),
canPublicNote: calc('canPublicNote', vs => vs.some(v => v === true)), canPublicNote: calc('canPublicNote', vs => vs.some(v => v === true)),
canScheduleNote: calc('canScheduleNote', vs => vs.some(v => v === true)), canScheduleNote: calc('canScheduleNote', vs => vs.some(v => v === true)),
scheduleNoteLimit: calc('scheduleNoteLimit', vs => Math.max(...vs)),
scheduleNoteMaxDays: calc('scheduleNoteMaxDays', vs => Math.max(...vs)),
canInitiateConversation: calc('canInitiateConversation', vs => vs.some(v => v === true)), canInitiateConversation: calc('canInitiateConversation', vs => vs.some(v => v === true)),
canCreateContent: calc('canCreateContent', vs => vs.some(v => v === true)), canCreateContent: calc('canCreateContent', vs => vs.some(v => v === true)),
canUpdateContent: calc('canUpdateContent', vs => vs.some(v => v === true)), canUpdateContent: calc('canUpdateContent', vs => vs.some(v => v === true)),

View file

@ -184,6 +184,14 @@ export const packedRolePoliciesSchema = {
type: 'boolean', type: 'boolean',
optional: false, nullable: false, optional: false, nullable: false,
}, },
scheduleNoteLimit: {
type: 'integer',
optional: false, nullable: false,
},
scheduleNoteMaxDays: {
type: 'integer',
optional: false, nullable: false,
},
canInitiateConversation: { canInitiateConversation: {
type: 'boolean', type: 'boolean',
optional: false, nullable: false, optional: false, nullable: false,

View file

@ -155,18 +155,26 @@ export const meta = {
id: 'e577d185-8179-4a17-b47f-6093985558e6', id: 'e577d185-8179-4a17-b47f-6093985558e6',
}, },
cannotScheduleToFarFuture: {
message: 'Cannot schedule to the far future.',
code: 'CANNOT_SCHEDULE_TO_FAR_FUTURE',
id: 'ea102856-e8da-4ae9-a98a-0326821bd177',
},
cannotScheduleSameTime: { cannotScheduleSameTime: {
message: 'Cannot schedule multiple notes at the same time.', message: 'Cannot schedule multiple notes at the same time.',
code: 'CANNOT_SCHEDULE_SAME_TIME', code: 'CANNOT_SCHEDULE_SAME_TIME',
id: '187a8fab-fd83-4ae6-a46c-0f6f07784634', id: '187a8fab-fd83-4ae6-a46c-0f6f07784634',
}, },
tooManyScheduledNotes: {
message: 'You cannot schedule notes any more.',
code: 'TOO_MANY_SCHEDULED_NOTES',
kind: 'permission',
id: '9e33041f-f6fb-414d-98c1-591466e55287'
},
cannotScheduleToFarFuture: {
message: 'Cannot schedule to the far future.',
code: 'CANNOT_SCHEDULE_TO_FAR_FUTURE',
kind: 'permission',
id: 'ea102856-e8da-4ae9-a98a-0326821bd177',
},
rolePermissionDenied: { rolePermissionDenied: {
message: 'You are not assigned to a required role.', message: 'You are not assigned to a required role.',
code: 'ROLE_PERMISSION_DENIED', code: 'ROLE_PERMISSION_DENIED',
@ -462,11 +470,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
logger.error('Cannot schedule to the past.', { scheduledAt }); logger.error('Cannot schedule to the past.', { scheduledAt });
throw new ApiError(meta.errors.cannotScheduleToPast); throw new ApiError(meta.errors.cannotScheduleToPast);
} }
if (scheduledAt.getTime() - now.getTime() > ms('1year')) {
logger.error('Cannot schedule to the far future.', { scheduledAt });
throw new ApiError(meta.errors.cannotScheduleToFarFuture);
}
} }
// 投稿を作成 // 投稿を作成
@ -517,10 +520,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
logger.error('Failed to create a note.', { error: err }); logger.error('Failed to create a note.', { error: err });
if (err instanceof IdentifiableError) { if (err instanceof IdentifiableError) {
if (err.id === '689ee33f-f97c-479a-ac49-1b9f8140af99') throw new ApiError(meta.errors.containsProhibitedWords); if (err.id === '689ee33f-f97c-479a-ac49-1b9f8140af99') throw new ApiError(meta.errors.containsProhibitedWords, { message: err.message });
if (err.id === '9f466dab-c856-48cd-9e65-ff90ff750580') throw new ApiError(meta.errors.containsTooManyMentions); if (err.id === '9f466dab-c856-48cd-9e65-ff90ff750580') throw new ApiError(meta.errors.containsTooManyMentions, { message: err.message });
if (err.id === '7cc42034-f7ab-4f7c-87b4-e00854479080') throw new ApiError(meta.errors.rolePermissionDenied); if (err.id === '5ea8e4f5-9d64-4e6c-92b8-9e2b5a4756bc') throw new ApiError(meta.errors.cannotScheduleSameTime, { message: err.message });
if (err.id === '5ea8e4f5-9d64-4e6c-92b8-9e2b5a4756bc') throw new ApiError(meta.errors.cannotScheduleSameTime); if (err.id === '7fc78d25-d947-45c1-9547-02257b98cab3') throw new ApiError(meta.errors.tooManyScheduledNotes, { message: err.message });
if (err.id === '506006cf-3092-4ae1-8145-b025001c591f') throw new ApiError(meta.errors.cannotScheduleToFarFuture, { message: err.message });
if (err.id === '7cc42034-f7ab-4f7c-87b4-e00854479080') throw new ApiError(meta.errors.rolePermissionDenied, { message: err.message });
} }
throw err; throw err;

View file

@ -82,8 +82,18 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
<input v-show="withHashtags" ref="hashtagsInputEl" v-model="hashtags" class="mk-input-text" :class="$style.hashtags" :placeholder="i18n.ts.hashtags" list="hashtags"> <input v-show="withHashtags" ref="hashtagsInputEl" v-model="hashtags" class="mk-input-text" :class="$style.hashtags" :placeholder="i18n.ts.hashtags" list="hashtags">
<div v-if="scheduledTime" :class="$style.scheduledTime"> <div v-if="scheduledTime" :class="$style.scheduledTime">
<div><i class="ti ti-calendar-clock"></i></div> <div>
<span>{{ i18n.tsx.willBePostedAt({ x: dateTimeFormat.format(scheduledTime) }) }}</span> <div style="display: flex; gap: 4px" :style="scheduledTimeExceededPolicy ? 'color: var(--error)' : undefined">
<span style="margin-right: 4px"><i class="ti ti-calendar-clock"></i></span>
<component :is="scheduledTimeExceededPolicy ? 'del' : 'span'" :style="scheduledTimeExceededPolicy ? 'opacity: 0.6' : undefined">
{{ i18n.tsx.willBePostedAt({ x: dateTimeFormat.format(scheduledTime) }) }}
</component>
</div>
<div v-if="scheduledTimeExceededPolicy" style="display: flex; gap: 4px; margin-top: 4px; color: var(--infoWarnFg)">
<span style="margin-right: 4px"><i class="ti ti-exclamation-circle"></i></span>
<Mfm :text="i18n.tsx._postForm.policyScheduleNoteMaxDaysExceeded({ max: $i.policies.scheduleNoteMaxDays })"/>
</div>
</div>
<button class="_button" style="margin-left: auto" @click="scheduledTime = null"><i class="ti ti-x"></i></button> <button class="_button" style="margin-left: auto" @click="scheduledTime = null"><i class="ti ti-x"></i></button>
</div> </div>
<MkInfo v-if="files.length > 0 && instance.tosUrl" warn style="margin-top: 8px;" :rounded="false"> <MkInfo v-if="files.length > 0 && instance.tosUrl" warn style="margin-top: 8px;" :rounded="false">
@ -219,6 +229,9 @@ if (props.initialVisibleUsers) {
} }
const reactionAcceptance = ref(defaultStore.state.reactionAcceptance); const reactionAcceptance = ref(defaultStore.state.reactionAcceptance);
const scheduledTime = ref<Date | null>(null); const scheduledTime = ref<Date | null>(null);
const scheduledTimeExceededPolicy = computed(() =>
scheduledTime.value ? (scheduledTime.value.getTime() - Date.now()) / 86_400_000 > $i!.policies.scheduleNoteMaxDays : false
);
const autocompleteTextareaInput = ref<Autocomplete | null>(null); const autocompleteTextareaInput = ref<Autocomplete | null>(null);
const autocompleteCwInput = ref<Autocomplete | null>(null); const autocompleteCwInput = ref<Autocomplete | null>(null);
const autocompleteHashtagsInput = ref<Autocomplete | null>(null); const autocompleteHashtagsInput = ref<Autocomplete | null>(null);
@ -285,16 +298,20 @@ const maxTextLength = computed((): number => {
}); });
const canPost = computed((): boolean => { const canPost = computed((): boolean => {
return !props.mock && !posting.value && !posted.value && return !props.mock
( && !posting.value
&& !posted.value
&& (
1 <= textLength.value || 1 <= textLength.value ||
1 <= files.value.length || 1 <= files.value.length ||
poll.value != null || poll.value != null ||
renote.value != null || renote.value != null ||
(reply.value != null && quoteId.value != null) (reply.value != null && quoteId.value != null)
) && )
(textLength.value <= maxTextLength.value) && && (textLength.value <= maxTextLength.value)
(!poll.value || poll.value.choices.length >= 2); && (!poll.value || poll.value.choices.length >= 2)
&& !scheduledTimeExceededPolicy.value
;
}); });
const withHashtags = computed(defaultStore.makeGetterSetter('postFormWithHashtags')); const withHashtags = computed(defaultStore.makeGetterSetter('postFormWithHashtags'));
@ -1393,8 +1410,10 @@ defineExpose({
.scheduledTime { .scheduledTime {
display: flex; display: flex;
padding: 8px 24px; padding: 8px 12px;
gap: 4px; gap: 4px;
align-items: center;
font-size: 90%;
background: var(--infoBg); background: var(--infoBg);
} }

View file

@ -79,6 +79,8 @@ export const ROLE_POLICIES = [
'ltlAvailable', 'ltlAvailable',
'canPublicNote', 'canPublicNote',
'canScheduleNote', 'canScheduleNote',
'scheduleNoteLimit',
'scheduleNoteMaxDays',
'canInitiateConversation', 'canInitiateConversation',
'canCreateContent', 'canCreateContent',
'canUpdateContent', 'canUpdateContent',

View file

@ -185,6 +185,44 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
</MkFolder> </MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.scheduleNoteLimit, 'scheduleNoteLimit'])">
<template #label>{{ i18n.ts._role._options.scheduleNoteLimit }}</template>
<template #suffix>
<span v-if="role.policies.scheduleNoteLimit.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
<span v-else>{{ role.policies.scheduleNoteLimit.value }}</span>
<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.scheduleNoteLimit)"></i></span>
</template>
<div class="_gaps">
<MkSwitch v-model="role.policies.scheduleNoteLimit.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
</MkSwitch>
<MkInput v-model="role.policies.scheduleNoteLimit.value" :disabled="role.policies.scheduleNoteLimit.useDefault" type="number" :readonly="readonly">
</MkInput>
<MkRange v-model="role.policies.scheduleNoteLimit.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
<template #label>{{ i18n.ts._role.priority }}</template>
</MkRange>
</div>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.scheduleNoteMaxDays, 'scheduleNoteMaxDays'])">
<template #label>{{ i18n.ts._role._options.scheduleNoteMaxDays }}</template>
<template #suffix>
<span v-if="role.policies.scheduleNoteMaxDays.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
<span v-else>{{ role.policies.scheduleNoteMaxDays.value }}</span>
<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.scheduleNoteMaxDays)"></i></span>
</template>
<div class="_gaps">
<MkSwitch v-model="role.policies.scheduleNoteMaxDays.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
</MkSwitch>
<MkInput v-model="role.policies.scheduleNoteMaxDays.value" :disabled="role.policies.scheduleNoteMaxDays.useDefault" type="number" :readonly="readonly">
</MkInput>
<MkRange v-model="role.policies.scheduleNoteMaxDays.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
<template #label>{{ i18n.ts._role.priority }}</template>
</MkRange>
</div>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.canInitiateConversation, 'canInitiateConversation'])"> <MkFolder v-if="matchQuery([i18n.ts._role._options.canInitiateConversation, 'canInitiateConversation'])">
<template #label>{{ i18n.ts._role._options.canInitiateConversation }}</template> <template #label>{{ i18n.ts._role._options.canInitiateConversation }}</template>
<template #suffix> <template #suffix>

View file

@ -56,6 +56,20 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkSwitch> </MkSwitch>
</MkFolder> </MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.scheduleNoteLimit, 'scheduleNoteLimit'])">
<template #label>{{ i18n.ts._role._options.scheduleNoteLimit }}</template>
<template #suffix>{{ policies.scheduleNoteLimit }}</template>
<MkInput v-model="policies.scheduleNoteLimit" type="number">
</MkInput>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.scheduleNoteMaxDays, 'scheduleNoteMaxDays'])">
<template #label>{{ i18n.ts._role._options.scheduleNoteMaxDays }}</template>
<template #suffix>{{ policies.scheduleNoteMaxDays }}</template>
<MkInput v-model="policies.scheduleNoteMaxDays" type="number">
</MkInput>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.canInitiateConversation, 'canInitiateConversation'])"> <MkFolder v-if="matchQuery([i18n.ts._role._options.canInitiateConversation, 'canInitiateConversation'])">
<template #label>{{ i18n.ts._role._options.canInitiateConversation }}</template> <template #label>{{ i18n.ts._role._options.canInitiateConversation }}</template>
<template #suffix>{{ policies.canInitiateConversation ? i18n.ts.yes : i18n.ts.no }}</template> <template #suffix>{{ policies.canInitiateConversation ? i18n.ts.yes : i18n.ts.no }}</template>

View file

@ -5078,6 +5078,8 @@ export type components = {
ltlAvailable: boolean; ltlAvailable: boolean;
canPublicNote: boolean; canPublicNote: boolean;
canScheduleNote: boolean; canScheduleNote: boolean;
scheduleNoteLimit: number;
scheduleNoteMaxDays: number;
canInitiateConversation: boolean; canInitiateConversation: boolean;
canCreateContent: boolean; canCreateContent: boolean;
canUpdateContent: boolean; canUpdateContent: boolean;