atagen

malice

public
Star0
Watch0
e72926bcca9a49b5dce29970ae99748c32cac9f5
2
ssh://brewery@pls.lobotomise.me:33/atagen/malice.git
web/src/lib/packs.tsTypescript185 lines6.5 KB
1
import type { CommandRequest, PackTarget } from './transport';
2
import type { CloneSource } from './pack-clone';
3
import type { ImagePack, ImagePacks, PackImage, PackUsage, RoomSummary } from './types';
4
5
export interface PackChoice {
6
  label: string;
7
  target: PackTarget;
8
  pack: ImagePack | null;
9
}
10
11
export function packTarget(pack: ImagePack): PackTarget {
12
  if (pack.source === 'User') return { kind: 'user' };
13
  return { kind: 'room', room_id: pack.source.Room.room_id, state_key: pack.source.Room.state_key };
14
}
15
16
export function packLabel(pack: ImagePack, roomName: (roomId: string) => string): string {
17
  if (pack.display_name) return pack.display_name;
18
  if (pack.source === 'User') return 'Your pack';
19
  return roomName(pack.source.Room.room_id);
20
}
21
22
export function allPacks(packs: ImagePacks): ImagePack[] {
23
  const seen = new Set<string>();
24
  const out: ImagePack[] = [];
25
  for (const pack of [...packs.global, ...Object.values(packs.rooms).flat()]) {
26
    if (seen.has(pack.id)) continue;
27
    seen.add(pack.id);
28
    out.push(pack);
29
  }
30
  return out;
31
}
32
33
export function packsForRoom(
34
  packs: ImagePacks,
35
  roomId: string | null,
36
  rooms: Pick<RoomSummary, 'room_id' | 'parent_spaces' | 'children' | 'space'>[] = []
37
): ImagePack[] {
38
  const seen = new Set<string>();
39
  const out: ImagePack[] = [];
40
  const add = (pack: ImagePack) => {
41
    if (seen.has(pack.id) || pack.images.length === 0) return;
42
    seen.add(pack.id);
43
    out.push(pack);
44
  };
45
  for (const pack of packs.global) if (pack.source === 'User') add(pack);
46
  if (roomId) {
47
    const visited = new Set<string>();
48
    const queue = [roomId];
49
    for (const id of queue) {
50
      if (visited.has(id)) continue;
51
      visited.add(id);
52
      for (const pack of packs.rooms[id] ?? []) add(pack);
53
      const room = rooms.find((entry) => entry.room_id === id);
54
      queue.push(...(room?.parent_spaces ?? []));
55
      for (const space of rooms) {
56
        if (space.space && space.children.includes(id)) queue.push(space.room_id);
57
      }
58
    }
59
  }
60
  for (const pack of packs.global) add(pack);
61
  return out;
62
}
63
64
/** Packs you can write to, with your own pack first even before it exists. */
65
export function editableChoices(packs: ImagePacks, roomName: (roomId: string) => string): PackChoice[] {
66
  const all = allPacks(packs);
67
  const user = all.find((p) => p.source === 'User') ?? null;
68
  const choices: PackChoice[] = [{ label: user?.display_name ?? 'Your pack', target: { kind: 'user' }, pack: user }];
69
  for (const pack of all) {
70
    if (pack.source === 'User' || !pack.editable) continue;
71
    choices.push({ label: `${packLabel(pack, roomName)} · ${roomName(pack.source.Room.room_id)}`, target: packTarget(pack), pack });
72
  }
73
  return choices;
74
}
75
76
export function hasUsage(image: PackImage, usage: PackUsage): boolean {
77
  return image.usage.length === 0 || image.usage.includes(usage);
78
}
79
80
export function stickersOf(pack: ImagePack): PackImage[] {
81
  return pack.images.filter((image) => hasUsage(image, 'Sticker'));
82
}
83
84
export function reactionShortcode(key: string, shortcode: string | undefined, packs: ImagePack[]): string | undefined {
85
  if (!key.startsWith('mxc://')) return undefined;
86
  const provided = shortcode?.trim();
87
  if (provided) return provided;
88
  for (const pack of packs) {
89
    const image = pack.images.find((item) => item.reaction_key === key);
90
    if (image) return image.shortcode;
91
  }
92
  return undefined;
93
}
94
95
export function findSticker(packs: ImagePack[], shortcode: string): { pack: ImagePack; sticker: PackImage } {
96
  const name = shortcode.toLowerCase();
97
  const matches = packs.flatMap((pack) => stickersOf(pack)
98
    .filter((sticker) => sticker.shortcode.toLowerCase() === name)
99
    .map((sticker) => ({ pack, sticker })));
100
  if (matches.length === 0) throw new Error(`No sticker named ${shortcode} is available in this room`);
101
  if (matches.length > 1) throw new Error(`More than one sticker is named ${shortcode}, choose one from the sticker menu`);
102
  return matches[0];
103
}
104
105
/** A shortcode from a file name, unique within `taken`. */
106
export function shortcodeFor(filename: string, taken: Set<string>): string {
107
  const stem = filename.replace(/\.[^.]+$/, '');
108
  const base =
109
    stem
110
      .toLowerCase()
111
      .replace(/[^a-z0-9_-]+/g, '_')
112
      .replace(/^_+|_+$/g, '')
113
      .slice(0, 64) || 'image';
114
  let candidate = base;
115
  for (let n = 2; taken.has(candidate); n += 1) candidate = `${base}_${n}`;
116
  taken.add(candidate);
117
  return candidate;
118
}
119
120
export function validShortcode(code: string): boolean {
121
  return /^[^\s:]{1,100}$/.test(code);
122
}
123
124
export function packSlug(name: string): string {
125
  return (
126
    name
127
      .toLowerCase()
128
      .replace(/[^a-z0-9]+/g, '-')
129
      .replace(/^-+|-+$/g, '')
130
      .slice(0, 48) || 'pack'
131
  );
132
}
133
134
export function freePackStateKey(packs: ImagePacks, roomId: string, name: string): string {
135
  const taken = new Set(allPacks(packs).flatMap((pack) =>
136
    pack.source !== 'User' && pack.source.Room.room_id === roomId ? [pack.source.Room.state_key] : []));
137
  const base = packSlug(name);
138
  let key = base;
139
  for (let n = 2; taken.has(key); n += 1) key = `${base}-${n}`;
140
  return key;
141
}
142
143
export function cloneRequest(
144
  source: CloneSource,
145
  to: PackTarget,
146
  usage: PackUsage[],
147
  displayName: string | null,
148
  destination: ImagePack | null
149
): CommandRequest {
150
  if (usage.length === 0) throw new Error('Choose at least one use for the copied images');
151
  if (source.kind === 'pack') {
152
    return { op: 'clone_pack', from_pack: source.pack.id, to, display_name: displayName, usage };
153
  }
154
  if (source.kind === 'image') {
155
    return { op: 'clone_pack_image', from_pack: source.pack.id, shortcode: source.image.shortcode, to, usage };
156
  }
157
  const taken = new Set(destination?.images.map((image) => image.shortcode) ?? []);
158
  return {
159
    op: 'clone_sticker', room_id: source.room_id, event_id: source.event_id, to,
160
    shortcode: shortcodeFor(source.body || 'sticker', taken), usage
161
  };
162
}
163
164
export interface CloneDestination {
165
  label: string;
166
  target: PackTarget;
167
  display_name: string | null;
168
}
169
170
/** Where a whole pack can be cloned to: merged into your pack, or as a new pack in a room you can edit. */
171
export function clonePackDestinations(
172
  pack: ImagePack,
173
  rooms: { room_id: string; name: string }[],
174
  roomName: (roomId: string) => string
175
): CloneDestination[] {
176
  const name = packLabel(pack, roomName);
177
  return [
178
    { label: 'Merge into your pack', target: { kind: 'user' }, display_name: null },
179
    ...rooms.map((room) => ({
180
      label: `New pack in ${room.name}`,
181
      target: { kind: 'room', room_id: room.room_id, state_key: packSlug(name) } as PackTarget,
182
      display_name: name
183
    }))
184
  ];
185
}