@meith/import 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,297 @@
1
+ import type {
2
+ PhpbbAttachment,
3
+ PhpbbAvatarRow,
4
+ PhpbbBan,
5
+ PhpbbForum,
6
+ PhpbbPollOption,
7
+ PhpbbPollTopic,
8
+ PhpbbPollVote,
9
+ PhpbbPost,
10
+ PhpbbPrivateMessage,
11
+ PhpbbPrivateMessageCopy,
12
+ PhpbbTopic,
13
+ PhpbbUser,
14
+ PhpbbWarning,
15
+ PhpbbWatch,
16
+ PhpbbZebra,
17
+ } from './phpbb-rows'
18
+ import { decodeHtmlEntities, decodePhpbbText } from './phpbb-text'
19
+ import type {
20
+ ImportedAttachment,
21
+ ImportedAvatar,
22
+ ImportedBan,
23
+ ImportedForum,
24
+ ImportedPoll,
25
+ ImportedPollVote,
26
+ ImportedPost,
27
+ ImportedPrivateMessage,
28
+ ImportedReputation,
29
+ ImportedSubscription,
30
+ ImportedThread,
31
+ ImportedUser,
32
+ ImportedUserRelation,
33
+ ImportedWarning,
34
+ Visibility,
35
+ } from './types'
36
+ import { fromUnixSeconds } from './types'
37
+
38
+ const BOT_USER_TYPE = 2
39
+
40
+ export function mapPhpbbUser(row: PhpbbUser): ImportedUser | null {
41
+ if (row.user_type === BOT_USER_TYPE) return null
42
+
43
+ return {
44
+ legacyId: row.user_id,
45
+ username: decodeHtmlEntities(row.username),
46
+ email: row.user_email.trim().toLowerCase(),
47
+ legacyPasswordHash: `phpbb$${row.user_password}`,
48
+ registeredAt: fromUnixSeconds(row.user_regdate) ?? new Date(0),
49
+ lastVisitAt: fromUnixSeconds(row.user_lastvisit),
50
+ postCount: Math.max(0, row.user_posts),
51
+ legacyGroupId: row.group_id,
52
+ }
53
+ }
54
+
55
+ const FORUM_TYPES: Readonly<Record<number, ImportedForum['type']>> = {
56
+ 0: 'category',
57
+ 1: 'forum',
58
+ 2: 'link',
59
+ }
60
+
61
+ export function mapPhpbbForum(row: PhpbbForum): ImportedForum {
62
+ const description = decodeHtmlEntities(row.forum_desc).trim()
63
+
64
+ return {
65
+ legacyId: row.forum_id,
66
+ type: FORUM_TYPES[row.forum_type] ?? 'forum',
67
+ title: decodeHtmlEntities(row.forum_name),
68
+ description: description === '' ? null : description,
69
+ legacyParentId: row.parent_id === 0 ? null : row.parent_id,
70
+ displayOrder: row.left_id,
71
+ linkUrl: row.forum_type === 2 && row.forum_link.trim() !== '' ? row.forum_link : null,
72
+ }
73
+ }
74
+
75
+ export function phpbbVisibility(visibility: number): Visibility {
76
+ if (visibility === 1) return 'visible'
77
+ if (visibility === 2) return 'deleted'
78
+ return 'unapproved'
79
+ }
80
+
81
+ export function mapPhpbbTopic(row: PhpbbTopic): ImportedThread | null {
82
+ if (row.topic_moved_id !== 0) return null
83
+
84
+ const createdAt = fromUnixSeconds(row.topic_time) ?? new Date(0)
85
+
86
+ return {
87
+ legacyId: row.topic_id,
88
+ legacyForumId: row.forum_id,
89
+ title: decodeHtmlEntities(row.topic_title),
90
+ legacyAuthorId: row.topic_poster,
91
+ authorUsername: decodeHtmlEntities(row.topic_first_poster_name),
92
+ createdAt,
93
+ lastPostAt: fromUnixSeconds(row.topic_last_post_time) ?? createdAt,
94
+ replyCount: Math.max(0, row.topic_posts_approved - 1),
95
+ viewCount: Math.max(0, row.topic_views),
96
+ isSticky: row.topic_type > 0,
97
+ isLocked: row.topic_status === 1,
98
+ visibility: phpbbVisibility(row.topic_visibility),
99
+ }
100
+ }
101
+
102
+ export function mapPhpbbPost(row: PhpbbPost): ImportedPost {
103
+ return {
104
+ legacyId: row.post_id,
105
+ legacyThreadId: row.topic_id,
106
+ legacyForumId: row.forum_id,
107
+ legacyAuthorId: row.poster_id,
108
+ authorUsername: decodeHtmlEntities(row.post_username),
109
+ body: decodePhpbbText(row.post_text, row.bbcode_uid),
110
+ createdAt: fromUnixSeconds(row.post_time) ?? new Date(0),
111
+ editedAt: fromUnixSeconds(row.post_edit_time),
112
+ visibility: phpbbVisibility(row.post_visibility),
113
+ }
114
+ }
115
+
116
+ const AVATAR_SOURCES: Readonly<Record<string, ImportedAvatar['source']>> = {
117
+ 'avatar.driver.upload': 'upload',
118
+ 'avatar.driver.remote': 'remote',
119
+ 'avatar.driver.gallery': 'gallery',
120
+ '1': 'upload',
121
+ '2': 'remote',
122
+ '3': 'gallery',
123
+ }
124
+
125
+ export function mapPhpbbAvatar(row: PhpbbAvatarRow): ImportedAvatar | null {
126
+ const source = AVATAR_SOURCES[row.user_avatar_type.trim()]
127
+ if (source === undefined) return null
128
+
129
+ const value = row.user_avatar.split('?')[0]?.trim() ?? ''
130
+ if (value === '') return null
131
+
132
+ const path =
133
+ source === 'upload'
134
+ ? `images/avatars/upload/*_${row.user_id}${extensionOf(value)}`
135
+ : source === 'gallery'
136
+ ? `images/avatars/gallery/${value}`
137
+ : value
138
+
139
+ return {
140
+ legacyUserId: row.user_id,
141
+ source,
142
+ path,
143
+ width: row.user_avatar_width > 0 ? row.user_avatar_width : null,
144
+ height: row.user_avatar_height > 0 ? row.user_avatar_height : null,
145
+ }
146
+ }
147
+
148
+ function extensionOf(filename: string): string {
149
+ const dot = filename.lastIndexOf('.')
150
+ return dot === -1 ? '' : filename.slice(dot).toLowerCase()
151
+ }
152
+
153
+ export function mapPhpbbAttachment(row: PhpbbAttachment): ImportedAttachment {
154
+ const mimetype = row.mimetype.trim()
155
+
156
+ return {
157
+ legacyId: row.attach_id,
158
+ legacyPostId: row.post_msg_id,
159
+ legacyUserId: row.poster_id,
160
+ filename: decodeHtmlEntities(row.real_filename),
161
+ contentType: mimetype === '' ? null : mimetype,
162
+ sizeBytes: Math.max(0, row.filesize),
163
+ path: `files/${row.physical_filename}`,
164
+ thumbnailPath: row.thumbnail === 1 ? `files/thumb_${row.physical_filename}` : null,
165
+ downloadCount: Math.max(0, row.download_count),
166
+ createdAt: fromUnixSeconds(row.filetime) ?? new Date(0),
167
+ }
168
+ }
169
+
170
+ export function mapPhpbbPoll(
171
+ topic: PhpbbPollTopic,
172
+ options: readonly PhpbbPollOption[],
173
+ ): ImportedPoll | null {
174
+ const createdAt = fromUnixSeconds(topic.poll_start) ?? new Date(0)
175
+
176
+ const mapped = options
177
+ .filter((option) => option.topic_id === topic.topic_id)
178
+ .sort((a, b) => a.poll_option_id - b.poll_option_id)
179
+ .map((option) => ({
180
+ order: option.poll_option_id,
181
+ label: decodeHtmlEntities(option.poll_option_text).trim(),
182
+ voteCount: Math.max(0, option.poll_option_total),
183
+ }))
184
+ .filter((option) => option.label !== '')
185
+
186
+ if (mapped.length === 0) return null
187
+
188
+ return {
189
+ legacyId: topic.topic_id,
190
+ legacyThreadId: topic.topic_id,
191
+ question: decodeHtmlEntities(topic.poll_title),
192
+ options: mapped,
193
+ closesAt:
194
+ topic.poll_length > 0 ? new Date((topic.poll_start + topic.poll_length) * 1000) : null,
195
+ createdAt,
196
+ maxOptions: Math.min(Math.max(1, topic.poll_max_options), mapped.length),
197
+ allowRevote: topic.poll_vote_change > 0,
198
+ publicVotes: false,
199
+ }
200
+ }
201
+
202
+ export function mapPhpbbPollVote(row: PhpbbPollVote): ImportedPollVote {
203
+ return {
204
+ legacyPollId: row.topic_id,
205
+ legacyUserId: row.vote_user_id,
206
+ optionOrder: row.poll_option_id,
207
+ votedAt: null,
208
+ }
209
+ }
210
+
211
+ export function mapPhpbbPrivateMessage(
212
+ message: PhpbbPrivateMessage,
213
+ copies: readonly PhpbbPrivateMessageCopy[],
214
+ ): ImportedPrivateMessage | null {
215
+ const mapped = copies
216
+ .filter((copy) => copy.msg_id === message.msg_id)
217
+ .map((copy) => ({
218
+ legacyOwnerId: copy.user_id,
219
+ folder:
220
+ copy.pm_deleted === 1
221
+ ? ('trash' as const)
222
+ : copy.user_id === message.author_id && copy.folder_id < 0
223
+ ? ('sent' as const)
224
+ : ('inbox' as const),
225
+ readAt: copy.pm_unread === 1 ? null : (fromUnixSeconds(message.message_time) ?? null),
226
+ }))
227
+
228
+ if (mapped.length === 0) return null
229
+
230
+ return {
231
+ legacyId: message.msg_id,
232
+ legacyAuthorId: message.author_id,
233
+ subject: decodeHtmlEntities(message.message_subject),
234
+ body: decodePhpbbText(message.message_text, message.bbcode_uid),
235
+ sentAt: fromUnixSeconds(message.message_time) ?? new Date(0),
236
+ copies: mapped,
237
+ }
238
+ }
239
+
240
+ export function mapPhpbbWatch(row: PhpbbWatch): ImportedSubscription {
241
+ return {
242
+ legacyId: row.user_id,
243
+ legacyUserId: row.user_id,
244
+ legacyTargetId: row.target_id,
245
+ mode: 'instant',
246
+ }
247
+ }
248
+
249
+ export function mapPhpbbWarning(row: PhpbbWarning): ImportedWarning {
250
+ return {
251
+ legacyId: row.warning_id,
252
+ legacyUserId: row.user_id,
253
+ legacyIssuedByUserId: 0,
254
+ legacyPostId: row.post_id === 0 ? null : row.post_id,
255
+ title: 'Warning',
256
+ points: 1,
257
+ notes: '',
258
+ issuedAt: fromUnixSeconds(row.warning_time) ?? new Date(0),
259
+ expiresAt: null,
260
+ revokedAt: null,
261
+ revokeReason: null,
262
+ }
263
+ }
264
+
265
+ export function mapPhpbbBan(row: PhpbbBan): ImportedBan | null {
266
+ if (row.ban_userid === 0 || row.ban_exclude === 1) return null
267
+
268
+ const publicReason = decodeHtmlEntities(row.ban_give_reason).trim()
269
+
270
+ return {
271
+ legacyId: row.ban_id,
272
+ legacyUserId: row.ban_userid,
273
+ legacyBannedByUserId: 0,
274
+ reason: decodeHtmlEntities(row.ban_reason),
275
+ publicReason: publicReason === '' ? null : publicReason,
276
+ createdAt: fromUnixSeconds(row.ban_start) ?? new Date(0),
277
+ expiresAt: fromUnixSeconds(row.ban_end),
278
+ }
279
+ }
280
+
281
+ export function mapPhpbbZebra(row: PhpbbZebra): ImportedUserRelation | null {
282
+ if (row.user_id === row.zebra_id) return null
283
+ if (row.friend === 1) {
284
+ return { legacyUserId: row.user_id, legacyOtherUserId: row.zebra_id, kind: 'buddy' }
285
+ }
286
+ if (row.foe === 1) {
287
+ return { legacyUserId: row.user_id, legacyOtherUserId: row.zebra_id, kind: 'ignore' }
288
+ }
289
+ return null
290
+ }
291
+
292
+ export function noPhpbbReputation(): Promise<{
293
+ rows: readonly ImportedReputation[]
294
+ nextCursor: null
295
+ }> {
296
+ return Promise.resolve({ rows: [], nextCursor: null })
297
+ }
@@ -0,0 +1,355 @@
1
+ import {
2
+ assertSafePrefix,
3
+ connectMysql,
4
+ keysetPage,
5
+ type MysqlSourceOptions,
6
+ mapPage,
7
+ type Queryable,
8
+ selectRows,
9
+ } from './mysql-source'
10
+ import {
11
+ mapPhpbbAttachment,
12
+ mapPhpbbAvatar,
13
+ mapPhpbbBan,
14
+ mapPhpbbForum,
15
+ mapPhpbbPoll,
16
+ mapPhpbbPollVote,
17
+ mapPhpbbPost,
18
+ mapPhpbbPrivateMessage,
19
+ mapPhpbbTopic,
20
+ mapPhpbbUser,
21
+ mapPhpbbWarning,
22
+ mapPhpbbWatch,
23
+ mapPhpbbZebra,
24
+ noPhpbbReputation,
25
+ } from './phpbb-map'
26
+ import type {
27
+ PhpbbAttachment,
28
+ PhpbbAvatarRow,
29
+ PhpbbBan,
30
+ PhpbbForum,
31
+ PhpbbPollOption,
32
+ PhpbbPollTopic,
33
+ PhpbbPollVote,
34
+ PhpbbPost,
35
+ PhpbbPrivateMessage,
36
+ PhpbbPrivateMessageCopy,
37
+ PhpbbTopic,
38
+ PhpbbUser,
39
+ PhpbbWarning,
40
+ PhpbbWatch,
41
+ PhpbbZebra,
42
+ } from './phpbb-rows'
43
+ import { type ImportSource, type Page, pageByGroup } from './source'
44
+ import type {
45
+ ImportedAttachment,
46
+ ImportedAvatar,
47
+ ImportedBan,
48
+ ImportedForum,
49
+ ImportedPoll,
50
+ ImportedPollVote,
51
+ ImportedPost,
52
+ ImportedPrivateMessage,
53
+ ImportedReputation,
54
+ ImportedSubscription,
55
+ ImportedThread,
56
+ ImportedUser,
57
+ ImportedUserRelation,
58
+ ImportedWarning,
59
+ } from './types'
60
+
61
+ export class MysqlPhpbbSource implements ImportSource {
62
+ readonly name = 'phpbb'
63
+
64
+ private constructor(
65
+ private readonly connection: Queryable,
66
+ private readonly prefix: string,
67
+ ) {}
68
+
69
+ static async connect(options: MysqlSourceOptions): Promise<MysqlPhpbbSource> {
70
+ const prefix = options.tablePrefix ?? 'phpbb_'
71
+ assertSafePrefix(prefix)
72
+ return new MysqlPhpbbSource(await connectMysql(options), prefix)
73
+ }
74
+
75
+ async close(): Promise<void> {
76
+ await this.connection.end()
77
+ }
78
+
79
+ async users(afterId: number, limit: number): Promise<Page<ImportedUser>> {
80
+ return mapPage(
81
+ await keysetPage<PhpbbUser>(
82
+ this.connection,
83
+ `select user_id, user_type, username, user_email, user_password,
84
+ user_regdate, user_lastvisit, user_posts, group_id
85
+ from \`${this.prefix}users\``,
86
+ 'user_id',
87
+ (row) => row.user_id,
88
+ afterId,
89
+ limit,
90
+ ),
91
+ mapPhpbbUser,
92
+ )
93
+ }
94
+
95
+ async forums(afterId: number, limit: number): Promise<Page<ImportedForum>> {
96
+ return mapPage(
97
+ await keysetPage<PhpbbForum>(
98
+ this.connection,
99
+ `select forum_id, parent_id, forum_name, forum_desc, forum_type, forum_link, left_id
100
+ from \`${this.prefix}forums\``,
101
+ 'forum_id',
102
+ (row) => row.forum_id,
103
+ afterId,
104
+ limit,
105
+ ),
106
+ mapPhpbbForum,
107
+ )
108
+ }
109
+
110
+ async threads(afterId: number, limit: number): Promise<Page<ImportedThread>> {
111
+ return mapPage(
112
+ await keysetPage<PhpbbTopic>(
113
+ this.connection,
114
+ `select topic_id, forum_id, topic_title, topic_poster, topic_first_poster_name,
115
+ topic_time, topic_last_post_time, topic_views, topic_posts_approved,
116
+ topic_status, topic_type, topic_visibility, topic_moved_id
117
+ from \`${this.prefix}topics\``,
118
+ 'topic_id',
119
+ (row) => row.topic_id,
120
+ afterId,
121
+ limit,
122
+ ),
123
+ mapPhpbbTopic,
124
+ )
125
+ }
126
+
127
+ async posts(afterId: number, limit: number): Promise<Page<ImportedPost>> {
128
+ return mapPage(
129
+ await keysetPage<PhpbbPost>(
130
+ this.connection,
131
+ `select post_id, topic_id, forum_id, poster_id, post_username, post_subject,
132
+ post_text, bbcode_uid, post_time, post_edit_time, post_visibility
133
+ from \`${this.prefix}posts\``,
134
+ 'post_id',
135
+ (row) => row.post_id,
136
+ afterId,
137
+ limit,
138
+ ),
139
+ mapPhpbbPost,
140
+ )
141
+ }
142
+
143
+ async avatars(afterId: number, limit: number): Promise<Page<ImportedAvatar>> {
144
+ const rows = await selectRows<PhpbbAvatarRow>(
145
+ this.connection,
146
+ `select user_id, user_avatar, user_avatar_type, user_avatar_width, user_avatar_height
147
+ from \`${this.prefix}users\`
148
+ where user_id > ? and user_avatar <> ''
149
+ order by user_id asc limit ?`,
150
+ [afterId, limit],
151
+ )
152
+ const last = rows.at(-1)
153
+ return mapPage(
154
+ { rows, nextCursor: rows.length < limit || last === undefined ? null : last.user_id },
155
+ mapPhpbbAvatar,
156
+ )
157
+ }
158
+
159
+ async attachments(afterId: number, limit: number): Promise<Page<ImportedAttachment>> {
160
+ const rows = await selectRows<PhpbbAttachment>(
161
+ this.connection,
162
+ `select attach_id, post_msg_id, poster_id, real_filename, physical_filename,
163
+ mimetype, filesize, download_count, filetime, thumbnail
164
+ from \`${this.prefix}attachments\`
165
+ where attach_id > ? and in_message = 0
166
+ order by attach_id asc limit ?`,
167
+ [afterId, limit],
168
+ )
169
+ const last = rows.at(-1)
170
+ return mapPage(
171
+ { rows, nextCursor: rows.length < limit || last === undefined ? null : last.attach_id },
172
+ mapPhpbbAttachment,
173
+ )
174
+ }
175
+
176
+ async polls(afterId: number, limit: number): Promise<Page<ImportedPoll>> {
177
+ const topics = await selectRows<PhpbbPollTopic>(
178
+ this.connection,
179
+ `select topic_id, poll_title, poll_start, poll_length, poll_max_options, poll_vote_change
180
+ from \`${this.prefix}topics\`
181
+ where topic_id > ? and poll_title <> '' and topic_moved_id = 0
182
+ order by topic_id asc limit ?`,
183
+ [afterId, limit],
184
+ )
185
+
186
+ const last = topics.at(-1)
187
+ const options =
188
+ topics.length === 0
189
+ ? []
190
+ : await selectRows<PhpbbPollOption>(
191
+ this.connection,
192
+ `select topic_id, poll_option_id, poll_option_text, poll_option_total
193
+ from \`${this.prefix}poll_options\`
194
+ where topic_id in (${topics.map(() => '?').join(', ')})
195
+ order by topic_id asc, poll_option_id asc`,
196
+ topics.map((topic) => topic.topic_id),
197
+ )
198
+
199
+ return {
200
+ rows: topics
201
+ .map((topic) => mapPhpbbPoll(topic, options))
202
+ .filter((poll): poll is NonNullable<typeof poll> => poll !== null),
203
+ nextCursor: topics.length < limit || last === undefined ? null : last.topic_id,
204
+ }
205
+ }
206
+
207
+ async pollVotes(afterId: number, limit: number): Promise<Page<ImportedPollVote>> {
208
+ const table = `\`${this.prefix}poll_votes\``
209
+ const page = await pageByGroup<PhpbbPollVote>({
210
+ afterGroup: afterId,
211
+ limit,
212
+ groupOf: (row) => row.topic_id,
213
+ fetch: (afterGroup, take) =>
214
+ selectRows(
215
+ this.connection,
216
+ `select topic_id, poll_option_id, vote_user_id from ${table}
217
+ where topic_id > ? order by topic_id asc, vote_user_id asc limit ?`,
218
+ [afterGroup, take],
219
+ ),
220
+ fetchGroup: (group) =>
221
+ selectRows(
222
+ this.connection,
223
+ `select topic_id, poll_option_id, vote_user_id from ${table}
224
+ where topic_id = ? order by vote_user_id asc`,
225
+ [group],
226
+ ),
227
+ })
228
+ return mapPage(page, mapPhpbbPollVote)
229
+ }
230
+
231
+ async privateMessages(afterId: number, limit: number): Promise<Page<ImportedPrivateMessage>> {
232
+ const messages = await selectRows<PhpbbPrivateMessage>(
233
+ this.connection,
234
+ `select msg_id, author_id, message_subject, message_text, bbcode_uid, message_time
235
+ from \`${this.prefix}privmsgs\`
236
+ where msg_id > ? order by msg_id asc limit ?`,
237
+ [afterId, limit],
238
+ )
239
+
240
+ const last = messages.at(-1)
241
+ const copies =
242
+ messages.length === 0
243
+ ? []
244
+ : await selectRows<PhpbbPrivateMessageCopy>(
245
+ this.connection,
246
+ `select msg_id, user_id, author_id, folder_id, pm_deleted, pm_unread
247
+ from \`${this.prefix}privmsgs_to\`
248
+ where msg_id in (${messages.map(() => '?').join(', ')})
249
+ order by msg_id asc, user_id asc`,
250
+ messages.map((message) => message.msg_id),
251
+ )
252
+
253
+ return {
254
+ rows: messages
255
+ .map((message) => mapPhpbbPrivateMessage(message, copies))
256
+ .filter((message): message is NonNullable<typeof message> => message !== null),
257
+ nextCursor: messages.length < limit || last === undefined ? null : last.msg_id,
258
+ }
259
+ }
260
+
261
+ threadSubscriptions(afterId: number, limit: number): Promise<Page<ImportedSubscription>> {
262
+ return this.#watch(`\`${this.prefix}topics_watch\``, 'topic_id', afterId, limit)
263
+ }
264
+
265
+ forumSubscriptions(afterId: number, limit: number): Promise<Page<ImportedSubscription>> {
266
+ return this.#watch(`\`${this.prefix}forums_watch\``, 'forum_id', afterId, limit)
267
+ }
268
+
269
+ reputation(): Promise<Page<ImportedReputation>> {
270
+ return noPhpbbReputation()
271
+ }
272
+
273
+ async warnings(afterId: number, limit: number): Promise<Page<ImportedWarning>> {
274
+ return mapPage(
275
+ await keysetPage<PhpbbWarning>(
276
+ this.connection,
277
+ `select warning_id, user_id, post_id, warning_time
278
+ from \`${this.prefix}warnings\``,
279
+ 'warning_id',
280
+ (row) => row.warning_id,
281
+ afterId,
282
+ limit,
283
+ ),
284
+ mapPhpbbWarning,
285
+ )
286
+ }
287
+
288
+ async bans(afterId: number, limit: number): Promise<Page<ImportedBan>> {
289
+ return mapPage(
290
+ await keysetPage<PhpbbBan>(
291
+ this.connection,
292
+ `select ban_id, ban_userid, ban_start, ban_end, ban_exclude,
293
+ ban_reason, ban_give_reason
294
+ from \`${this.prefix}banlist\``,
295
+ 'ban_id',
296
+ (row) => row.ban_id,
297
+ afterId,
298
+ limit,
299
+ ),
300
+ mapPhpbbBan,
301
+ )
302
+ }
303
+
304
+ async userRelations(afterId: number, limit: number): Promise<Page<ImportedUserRelation>> {
305
+ const table = `\`${this.prefix}zebra\``
306
+ const page = await pageByGroup<PhpbbZebra>({
307
+ afterGroup: afterId,
308
+ limit,
309
+ groupOf: (row) => row.user_id,
310
+ fetch: (afterGroup, take) =>
311
+ selectRows(
312
+ this.connection,
313
+ `select user_id, zebra_id, friend, foe from ${table}
314
+ where user_id > ? order by user_id asc, zebra_id asc limit ?`,
315
+ [afterGroup, take],
316
+ ),
317
+ fetchGroup: (group) =>
318
+ selectRows(
319
+ this.connection,
320
+ `select user_id, zebra_id, friend, foe from ${table}
321
+ where user_id = ? order by zebra_id asc`,
322
+ [group],
323
+ ),
324
+ })
325
+ return mapPage(page, mapPhpbbZebra)
326
+ }
327
+
328
+ async #watch(
329
+ table: string,
330
+ targetColumn: 'topic_id' | 'forum_id',
331
+ afterId: number,
332
+ limit: number,
333
+ ): Promise<Page<ImportedSubscription>> {
334
+ const page = await pageByGroup<PhpbbWatch>({
335
+ afterGroup: afterId,
336
+ limit,
337
+ groupOf: (row) => row.user_id,
338
+ fetch: (afterGroup, take) =>
339
+ selectRows(
340
+ this.connection,
341
+ `select user_id, ${targetColumn} as target_id from ${table}
342
+ where user_id > ? order by user_id asc, ${targetColumn} asc limit ?`,
343
+ [afterGroup, take],
344
+ ),
345
+ fetchGroup: (group) =>
346
+ selectRows(
347
+ this.connection,
348
+ `select user_id, ${targetColumn} as target_id from ${table}
349
+ where user_id = ? order by ${targetColumn} asc`,
350
+ [group],
351
+ ),
352
+ })
353
+ return mapPage(page, mapPhpbbWatch)
354
+ }
355
+ }