Files
fluxer/packages/schema/src/domains/channel/GuildChannelOrdering.tsx
2026-02-20 23:35:21 +00:00

310 lines
9.6 KiB
TypeScript

/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
export interface ChannelOrderingChannel<Id extends string | bigint = string> {
id: Id;
parentId?: Id | null | undefined;
type: number;
position?: number | null | undefined;
}
export type GuildChannelReorderErrorCode =
| 'TARGET_CHANNEL_NOT_FOUND'
| 'PRECEDING_CHANNEL_NOT_FOUND'
| 'CANNOT_POSITION_RELATIVE_TO_SELF_BLOCK'
| 'PRECEDING_PARENT_MISMATCH'
| 'PARENT_NOT_FOUND'
| 'PARENT_NOT_CATEGORY'
| 'CATEGORIES_CANNOT_HAVE_PARENTS'
| 'PARENT_NOT_IN_GUILD_LIST'
| 'PRECEDING_NOT_IN_GUILD_LIST';
export interface GuildChannelReorderOperation<Id extends string | bigint> {
channelId: Id;
parentId: Id | null | undefined;
precedingSiblingId: Id | null | undefined;
}
export interface GuildChannelReorderPlan<Id extends string | bigint, Channel extends ChannelOrderingChannel<Id>> {
orderedChannels: Array<Channel>;
finalChannels: Array<Channel>;
desiredParentById: Map<Id, Id | null>;
orderUnchanged: boolean;
}
function idToString<Id extends string | bigint>(id: Id): string {
return String(id);
}
export function compareChannelOrdering<Id extends string | bigint>(
a: ChannelOrderingChannel<Id>,
b: ChannelOrderingChannel<Id>,
): number {
const aPos = a.position ?? 0;
const bPos = b.position ?? 0;
if (aPos !== bPos) return aPos - bPos;
return idToString(a.id).localeCompare(idToString(b.id));
}
export function sortChannelsForOrdering<Id extends string | bigint, Channel extends ChannelOrderingChannel<Id>>(
channels: ReadonlyArray<Channel>,
): Array<Channel> {
const channelById = new Map<Id, Channel>(channels.map((channel) => [channel.id, channel]));
const childrenByParent = new Map<Id, Array<Channel>>();
const rootChannels: Array<Channel> = [];
for (const channel of channels) {
const parentId = channel.parentId ?? null;
if (parentId === null || !channelById.has(parentId)) {
rootChannels.push(channel);
continue;
}
const existingChildren = childrenByParent.get(parentId);
if (existingChildren) {
existingChildren.push(channel);
} else {
childrenByParent.set(parentId, [channel]);
}
}
const orderedChannels: Array<Channel> = [];
const seen = new Set<Id>();
const sortedRoots = [...rootChannels].sort(compareChannelOrdering);
for (const root of sortedRoots) {
orderedChannels.push(root);
seen.add(root.id);
if (root.type !== ChannelTypes.GUILD_CATEGORY) {
continue;
}
const children = childrenByParent.get(root.id);
if (!children) {
continue;
}
for (const child of [...children].sort(compareChannelOrdering)) {
orderedChannels.push(child);
seen.add(child.id);
}
}
const remaining = channels.filter((channel) => !seen.has(channel.id)).sort(compareChannelOrdering);
orderedChannels.push(...remaining);
return orderedChannels;
}
export function computeChannelMoveBlockIds<Id extends string | bigint, Channel extends ChannelOrderingChannel<Id>>({
channels,
targetId,
}: {
channels: ReadonlyArray<Channel>;
targetId: Id;
}): Set<Id> {
const channelById = new Map<Id, Channel>(channels.map((ch) => [ch.id, ch]));
const target = channelById.get(targetId);
const blockIds = new Set<Id>();
blockIds.add(targetId);
if (target?.type === ChannelTypes.GUILD_CATEGORY) {
for (const channel of channels) {
if (channel.parentId === targetId) {
blockIds.add(channel.id);
}
}
}
return blockIds;
}
export function findCategorySpanInOrderedList<Id extends string | bigint, Channel extends ChannelOrderingChannel<Id>>(
orderedChannels: ReadonlyArray<Channel>,
categoryId: Id,
): {start: number; end: number} {
const start = orderedChannels.findIndex((ch) => ch.id === categoryId);
if (start === -1) return {start: -1, end: -1};
let end = start + 1;
while (end < orderedChannels.length && orderedChannels[end].parentId === categoryId) {
end++;
}
return {start, end};
}
export function computePrecedingSiblingIdFromPosition<
Id extends string | bigint,
Channel extends ChannelOrderingChannel<Id>,
>({
channels,
targetId,
desiredParentId,
position,
}: {
channels: ReadonlyArray<Channel>;
targetId: Id;
desiredParentId: Id | null;
position: number;
}): Id | null {
const siblings = sortChannelsForOrdering(channels).filter((ch) => (ch.parentId ?? null) === desiredParentId);
const blockIds = computeChannelMoveBlockIds({channels, targetId});
const siblingsWithoutBlock = siblings.filter((ch) => !blockIds.has(ch.id));
const clamped = Math.min(Math.max(position, 0), siblingsWithoutBlock.length);
return clamped === 0 ? null : siblingsWithoutBlock[clamped - 1]!.id;
}
export function computePositionFromPrecedingSiblingId<
Id extends string | bigint,
Channel extends ChannelOrderingChannel<Id>,
>({
channels,
targetId,
desiredParentId,
precedingSiblingId,
}: {
channels: ReadonlyArray<Channel>;
targetId: Id;
desiredParentId: Id | null;
precedingSiblingId: Id | null;
}): number | null {
const siblings = sortChannelsForOrdering(channels).filter((ch) => (ch.parentId ?? null) === desiredParentId);
const blockIds = computeChannelMoveBlockIds({channels, targetId});
const siblingsWithoutBlock = siblings.filter((ch) => !blockIds.has(ch.id));
if (!precedingSiblingId) return 0;
const index = siblingsWithoutBlock.findIndex((ch) => ch.id === precedingSiblingId);
if (index === -1) return null;
return index + 1;
}
export function computeGuildChannelReorderPlan<Id extends string | bigint, Channel extends ChannelOrderingChannel<Id>>({
channels,
operation,
}: {
channels: ReadonlyArray<Channel>;
operation: GuildChannelReorderOperation<Id>;
}): {ok: true; plan: GuildChannelReorderPlan<Id, Channel>} | {ok: false; code: GuildChannelReorderErrorCode} {
const orderedChannels = sortChannelsForOrdering(channels);
const channelById = new Map<Id, Channel>(orderedChannels.map((ch) => [ch.id, ch]));
const targetChannel = channelById.get(operation.channelId);
if (!targetChannel) {
return {ok: false, code: 'TARGET_CHANNEL_NOT_FOUND'};
}
const requestedParentId = operation.parentId;
const desiredParentId =
targetChannel.type === ChannelTypes.GUILD_CATEGORY
? null
: requestedParentId !== undefined
? requestedParentId
: (targetChannel.parentId ?? null);
if (targetChannel.type === ChannelTypes.GUILD_CATEGORY && operation.parentId) {
return {ok: false, code: 'CATEGORIES_CANNOT_HAVE_PARENTS'};
}
if (desiredParentId) {
const parentChannel = channelById.get(desiredParentId);
if (!parentChannel) {
return {ok: false, code: 'PARENT_NOT_FOUND'};
}
if (parentChannel.type !== ChannelTypes.GUILD_CATEGORY) {
return {ok: false, code: 'PARENT_NOT_CATEGORY'};
}
}
const precedingId = operation.precedingSiblingId ?? null;
if (precedingId && !channelById.has(precedingId)) {
return {ok: false, code: 'PRECEDING_CHANNEL_NOT_FOUND'};
}
const blockIds = computeChannelMoveBlockIds({channels: orderedChannels, targetId: targetChannel.id});
if (precedingId && blockIds.has(precedingId)) {
return {ok: false, code: 'CANNOT_POSITION_RELATIVE_TO_SELF_BLOCK'};
}
const remainingChannels = orderedChannels.filter((ch) => !blockIds.has(ch.id));
const blockChannels = orderedChannels.filter((ch) => blockIds.has(ch.id));
const expectedParent = desiredParentId ?? null;
if (precedingId) {
const precedingChannel = channelById.get(precedingId)!;
const precedingParent = precedingChannel.parentId ?? null;
if (precedingParent !== expectedParent) {
return {ok: false, code: 'PRECEDING_PARENT_MISMATCH'};
}
}
let insertIndex = 0;
if (precedingId) {
const precedingIndex = remainingChannels.findIndex((ch) => ch.id === precedingId);
if (precedingIndex === -1) {
return {ok: false, code: 'PRECEDING_NOT_IN_GUILD_LIST'};
}
const precedingChannel = channelById.get(precedingId)!;
if (precedingChannel.type === ChannelTypes.GUILD_CATEGORY) {
const span = findCategorySpanInOrderedList(remainingChannels, precedingChannel.id);
insertIndex = span.end;
} else {
insertIndex = precedingIndex + 1;
}
} else if (desiredParentId) {
const parentIndex = remainingChannels.findIndex((ch) => ch.id === desiredParentId);
if (parentIndex === -1) {
return {ok: false, code: 'PARENT_NOT_IN_GUILD_LIST'};
}
insertIndex = parentIndex + 1;
} else {
insertIndex = 0;
}
const finalChannels = [...remainingChannels];
finalChannels.splice(insertIndex, 0, ...blockChannels);
const desiredParentById = new Map<Id, Id | null>();
for (const channel of finalChannels) {
if (channel.id === targetChannel.id) {
desiredParentById.set(channel.id, desiredParentId ?? null);
} else {
desiredParentById.set(channel.id, channel.parentId ?? null);
}
}
const orderUnchanged =
finalChannels.length === orderedChannels.length &&
finalChannels.every((channel, index) => channel.id === orderedChannels[index]!.id) &&
(targetChannel.parentId ?? null) === (desiredParentById.get(targetChannel.id) ?? null);
return {
ok: true,
plan: {
orderedChannels,
finalChannels,
desiredParentById,
orderUnchanged,
},
};
}