@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.
- package/LICENSE.md +165 -0
- package/package.json +24 -0
- package/src/index.ts +130 -0
- package/src/legacy-urls.ts +131 -0
- package/src/map.ts +297 -0
- package/src/memory-sink.fixture.ts +64 -0
- package/src/mybb-fixture.ts +185 -0
- package/src/mybb-rows.ts +172 -0
- package/src/mysql-source.ts +391 -0
- package/src/phpbb-fixture.ts +191 -0
- package/src/phpbb-map.ts +297 -0
- package/src/phpbb-mysql-source.ts +355 -0
- package/src/phpbb-rows.ts +160 -0
- package/src/phpbb-text.ts +44 -0
- package/src/run.ts +261 -0
- package/src/source.ts +98 -0
- package/src/types.ts +176 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
export interface PhpbbUser {
|
|
2
|
+
readonly user_id: number
|
|
3
|
+
readonly user_type: number
|
|
4
|
+
readonly username: string
|
|
5
|
+
readonly user_email: string
|
|
6
|
+
readonly user_password: string
|
|
7
|
+
readonly user_regdate: number
|
|
8
|
+
readonly user_lastvisit: number
|
|
9
|
+
readonly user_posts: number
|
|
10
|
+
readonly group_id: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface PhpbbForum {
|
|
14
|
+
readonly forum_id: number
|
|
15
|
+
readonly parent_id: number
|
|
16
|
+
readonly forum_name: string
|
|
17
|
+
readonly forum_desc: string
|
|
18
|
+
readonly forum_type: number
|
|
19
|
+
readonly forum_link: string
|
|
20
|
+
readonly left_id: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface PhpbbTopic {
|
|
24
|
+
readonly topic_id: number
|
|
25
|
+
readonly forum_id: number
|
|
26
|
+
readonly topic_title: string
|
|
27
|
+
readonly topic_poster: number
|
|
28
|
+
readonly topic_first_poster_name: string
|
|
29
|
+
readonly topic_time: number
|
|
30
|
+
readonly topic_last_post_time: number
|
|
31
|
+
readonly topic_views: number
|
|
32
|
+
readonly topic_posts_approved: number
|
|
33
|
+
readonly topic_status: number
|
|
34
|
+
readonly topic_type: number
|
|
35
|
+
readonly topic_visibility: number
|
|
36
|
+
readonly topic_moved_id: number
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface PhpbbPost {
|
|
40
|
+
readonly post_id: number
|
|
41
|
+
readonly topic_id: number
|
|
42
|
+
readonly forum_id: number
|
|
43
|
+
readonly poster_id: number
|
|
44
|
+
readonly post_username: string
|
|
45
|
+
readonly post_subject: string
|
|
46
|
+
readonly post_text: string
|
|
47
|
+
readonly bbcode_uid: string
|
|
48
|
+
readonly post_time: number
|
|
49
|
+
readonly post_edit_time: number
|
|
50
|
+
readonly post_visibility: number
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface PhpbbAvatarRow {
|
|
54
|
+
readonly user_id: number
|
|
55
|
+
readonly user_avatar: string
|
|
56
|
+
readonly user_avatar_type: string
|
|
57
|
+
readonly user_avatar_width: number
|
|
58
|
+
readonly user_avatar_height: number
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface PhpbbAttachment {
|
|
62
|
+
readonly attach_id: number
|
|
63
|
+
readonly post_msg_id: number
|
|
64
|
+
readonly poster_id: number
|
|
65
|
+
readonly real_filename: string
|
|
66
|
+
readonly physical_filename: string
|
|
67
|
+
readonly mimetype: string
|
|
68
|
+
readonly filesize: number
|
|
69
|
+
readonly download_count: number
|
|
70
|
+
readonly filetime: number
|
|
71
|
+
readonly thumbnail: number
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface PhpbbPollTopic {
|
|
75
|
+
readonly topic_id: number
|
|
76
|
+
readonly poll_title: string
|
|
77
|
+
readonly poll_start: number
|
|
78
|
+
readonly poll_length: number
|
|
79
|
+
readonly poll_max_options: number
|
|
80
|
+
readonly poll_vote_change: number
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface PhpbbPollOption {
|
|
84
|
+
readonly topic_id: number
|
|
85
|
+
readonly poll_option_id: number
|
|
86
|
+
readonly poll_option_text: string
|
|
87
|
+
readonly poll_option_total: number
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface PhpbbPollVote {
|
|
91
|
+
readonly topic_id: number
|
|
92
|
+
readonly poll_option_id: number
|
|
93
|
+
readonly vote_user_id: number
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface PhpbbPrivateMessage {
|
|
97
|
+
readonly msg_id: number
|
|
98
|
+
readonly author_id: number
|
|
99
|
+
readonly message_subject: string
|
|
100
|
+
readonly message_text: string
|
|
101
|
+
readonly bbcode_uid: string
|
|
102
|
+
readonly message_time: number
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface PhpbbPrivateMessageCopy {
|
|
106
|
+
readonly msg_id: number
|
|
107
|
+
readonly user_id: number
|
|
108
|
+
readonly author_id: number
|
|
109
|
+
readonly folder_id: number
|
|
110
|
+
readonly pm_deleted: number
|
|
111
|
+
readonly pm_unread: number
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface PhpbbWatch {
|
|
115
|
+
readonly user_id: number
|
|
116
|
+
readonly target_id: number
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface PhpbbWarning {
|
|
120
|
+
readonly warning_id: number
|
|
121
|
+
readonly user_id: number
|
|
122
|
+
readonly post_id: number
|
|
123
|
+
readonly warning_time: number
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface PhpbbBan {
|
|
127
|
+
readonly ban_id: number
|
|
128
|
+
readonly ban_userid: number
|
|
129
|
+
readonly ban_start: number
|
|
130
|
+
readonly ban_end: number
|
|
131
|
+
readonly ban_exclude: number
|
|
132
|
+
readonly ban_reason: string
|
|
133
|
+
readonly ban_give_reason: string
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface PhpbbZebra {
|
|
137
|
+
readonly user_id: number
|
|
138
|
+
readonly zebra_id: number
|
|
139
|
+
readonly friend: number
|
|
140
|
+
readonly foe: number
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export interface PhpbbTables {
|
|
144
|
+
readonly users?: readonly PhpbbUser[]
|
|
145
|
+
readonly forums?: readonly PhpbbForum[]
|
|
146
|
+
readonly topics?: readonly PhpbbTopic[]
|
|
147
|
+
readonly posts?: readonly PhpbbPost[]
|
|
148
|
+
readonly avatars?: readonly PhpbbAvatarRow[]
|
|
149
|
+
readonly attachments?: readonly PhpbbAttachment[]
|
|
150
|
+
readonly pollTopics?: readonly PhpbbPollTopic[]
|
|
151
|
+
readonly pollOptions?: readonly PhpbbPollOption[]
|
|
152
|
+
readonly pollVotes?: readonly PhpbbPollVote[]
|
|
153
|
+
readonly privateMessages?: readonly PhpbbPrivateMessage[]
|
|
154
|
+
readonly privateMessageCopies?: readonly PhpbbPrivateMessageCopy[]
|
|
155
|
+
readonly topicWatch?: readonly PhpbbWatch[]
|
|
156
|
+
readonly forumWatch?: readonly PhpbbWatch[]
|
|
157
|
+
readonly warnings?: readonly PhpbbWarning[]
|
|
158
|
+
readonly bans?: readonly PhpbbBan[]
|
|
159
|
+
readonly zebra?: readonly PhpbbZebra[]
|
|
160
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
const ENTITIES: Readonly<Record<string, string>> = {
|
|
2
|
+
'&': '&',
|
|
3
|
+
'<': '<',
|
|
4
|
+
'>': '>',
|
|
5
|
+
'"': '"',
|
|
6
|
+
''': "'",
|
|
7
|
+
''': "'",
|
|
8
|
+
' ': ' ',
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function decodeHtmlEntities(text: string): string {
|
|
12
|
+
return text
|
|
13
|
+
.replace(/&#(\d+);/g, (whole, code: string) => {
|
|
14
|
+
const point = Number(code)
|
|
15
|
+
return Number.isSafeInteger(point) && point > 0 && point <= 0x10ffff
|
|
16
|
+
? String.fromCodePoint(point)
|
|
17
|
+
: whole
|
|
18
|
+
})
|
|
19
|
+
.replace(/&(amp|lt|gt|quot|nbsp);/g, (whole) => ENTITIES[whole] ?? whole)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function escapeRegExp(value: string): string {
|
|
23
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function decodePhpbbText(text: string, bbcodeUid: string): string {
|
|
27
|
+
let out = text
|
|
28
|
+
|
|
29
|
+
if (bbcodeUid.trim() !== '') {
|
|
30
|
+
out = out.replace(new RegExp(`:${escapeRegExp(bbcodeUid.trim())}(?=[\\]:])`, 'g'), '')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
out = out.replace(/<!-- s(\S+) -->.*?<!-- s\1 -->/gs, '$1')
|
|
34
|
+
|
|
35
|
+
out = out.replace(
|
|
36
|
+
/<!-- ([mwe]) --><a[^>]*href="([^"]*)"[^>]*>(.*?)<\/a><!-- \1 -->/gs,
|
|
37
|
+
(_whole, kind: string, href: string, label: string) => {
|
|
38
|
+
const url = kind === 'e' ? href.replace(/^mailto:/, '') : href
|
|
39
|
+
return label === href || label === url || kind === 'e' ? url : `[url=${url}]${label}[/url]`
|
|
40
|
+
},
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
return decodeHtmlEntities(out)
|
|
44
|
+
}
|
package/src/run.ts
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import type { ImportSource, Page } from './source'
|
|
2
|
+
import type {
|
|
3
|
+
ImportedAttachment,
|
|
4
|
+
ImportedAvatar,
|
|
5
|
+
ImportedBan,
|
|
6
|
+
ImportedForum,
|
|
7
|
+
ImportedPoll,
|
|
8
|
+
ImportedPollVote,
|
|
9
|
+
ImportedPost,
|
|
10
|
+
ImportedPrivateMessage,
|
|
11
|
+
ImportedReputation,
|
|
12
|
+
ImportedSubscription,
|
|
13
|
+
ImportedThread,
|
|
14
|
+
ImportedUser,
|
|
15
|
+
ImportedUserRelation,
|
|
16
|
+
ImportedWarning,
|
|
17
|
+
} from './types'
|
|
18
|
+
|
|
19
|
+
export const KINDS = [
|
|
20
|
+
'users',
|
|
21
|
+
'forums',
|
|
22
|
+
'threads',
|
|
23
|
+
'posts',
|
|
24
|
+
'avatars',
|
|
25
|
+
'attachments',
|
|
26
|
+
'polls',
|
|
27
|
+
'pollVotes',
|
|
28
|
+
'privateMessages',
|
|
29
|
+
'threadSubscriptions',
|
|
30
|
+
'forumSubscriptions',
|
|
31
|
+
'reputation',
|
|
32
|
+
'warnings',
|
|
33
|
+
'bans',
|
|
34
|
+
'userRelations',
|
|
35
|
+
] as const
|
|
36
|
+
export type Kind = (typeof KINDS)[number]
|
|
37
|
+
|
|
38
|
+
export type Cursors = Readonly<Record<Kind, number>>
|
|
39
|
+
|
|
40
|
+
export const NO_PROGRESS: Cursors = Object.fromEntries(KINDS.map((kind) => [kind, 0])) as Record<
|
|
41
|
+
Kind,
|
|
42
|
+
number
|
|
43
|
+
>
|
|
44
|
+
|
|
45
|
+
export interface ImportSink {
|
|
46
|
+
putUsers(rows: readonly ImportedUser[]): Promise<WriteResult>
|
|
47
|
+
putForums(rows: readonly ImportedForum[]): Promise<WriteResult>
|
|
48
|
+
putThreads(rows: readonly ImportedThread[]): Promise<WriteResult>
|
|
49
|
+
putPosts(rows: readonly ImportedPost[]): Promise<WriteResult>
|
|
50
|
+
putAvatars(rows: readonly ImportedAvatar[]): Promise<WriteResult>
|
|
51
|
+
putAttachments(rows: readonly ImportedAttachment[]): Promise<WriteResult>
|
|
52
|
+
putPolls(rows: readonly ImportedPoll[]): Promise<WriteResult>
|
|
53
|
+
putPollVotes(rows: readonly ImportedPollVote[]): Promise<WriteResult>
|
|
54
|
+
putPrivateMessages(rows: readonly ImportedPrivateMessage[]): Promise<WriteResult>
|
|
55
|
+
putThreadSubscriptions(rows: readonly ImportedSubscription[]): Promise<WriteResult>
|
|
56
|
+
putForumSubscriptions(rows: readonly ImportedSubscription[]): Promise<WriteResult>
|
|
57
|
+
putReputation(rows: readonly ImportedReputation[]): Promise<WriteResult>
|
|
58
|
+
putWarnings(rows: readonly ImportedWarning[]): Promise<WriteResult>
|
|
59
|
+
putBans(rows: readonly ImportedBan[]): Promise<WriteResult>
|
|
60
|
+
putUserRelations(rows: readonly ImportedUserRelation[]): Promise<WriteResult>
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface WriteResult {
|
|
64
|
+
readonly inserted: number
|
|
65
|
+
readonly updated: number
|
|
66
|
+
readonly skipped: readonly { readonly legacyId: number; readonly reason: string }[]
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface KindReport {
|
|
70
|
+
readonly read: number
|
|
71
|
+
readonly inserted: number
|
|
72
|
+
readonly updated: number
|
|
73
|
+
readonly skipped: readonly { readonly legacyId: number; readonly reason: string }[]
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface ImportReport {
|
|
77
|
+
readonly kinds: Readonly<Record<Kind, KindReport>>
|
|
78
|
+
readonly cursors: Cursors
|
|
79
|
+
readonly finished: boolean
|
|
80
|
+
readonly readThisRun: number
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface RunOptions {
|
|
84
|
+
readonly source: ImportSource
|
|
85
|
+
readonly sink: ImportSink
|
|
86
|
+
readonly pageSize?: number | undefined
|
|
87
|
+
readonly budget?: number | undefined
|
|
88
|
+
readonly from?: Partial<Cursors> | undefined
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const emptyKind = (): KindReport => ({ read: 0, inserted: 0, updated: 0, skipped: [] })
|
|
92
|
+
|
|
93
|
+
export async function runImport(options: RunOptions): Promise<ImportReport> {
|
|
94
|
+
const pageSize = options.pageSize ?? 200
|
|
95
|
+
const budget = options.budget ?? 2000
|
|
96
|
+
const cursors: Record<Kind, number> = { ...NO_PROGRESS, ...options.from }
|
|
97
|
+
|
|
98
|
+
const kinds = Object.fromEntries(KINDS.map((kind) => [kind, emptyKind()])) as Record<
|
|
99
|
+
Kind,
|
|
100
|
+
KindReport
|
|
101
|
+
>
|
|
102
|
+
|
|
103
|
+
let readThisRun = 0
|
|
104
|
+
const exhausted = new Set<Kind>()
|
|
105
|
+
|
|
106
|
+
for (const kind of KINDS) {
|
|
107
|
+
while (readThisRun < budget) {
|
|
108
|
+
const limit = Math.min(pageSize, budget - readThisRun)
|
|
109
|
+
const outcome = await importPage(kind, options, cursors[kind], limit)
|
|
110
|
+
|
|
111
|
+
kinds[kind] = merge(kinds[kind], outcome.report)
|
|
112
|
+
readThisRun += outcome.report.read
|
|
113
|
+
|
|
114
|
+
if (outcome.nextCursor === null) {
|
|
115
|
+
exhausted.add(kind)
|
|
116
|
+
break
|
|
117
|
+
}
|
|
118
|
+
cursors[kind] = outcome.nextCursor
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
kinds,
|
|
124
|
+
cursors,
|
|
125
|
+
finished: KINDS.every((kind) => exhausted.has(kind)),
|
|
126
|
+
readThisRun,
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function merge(a: KindReport, b: KindReport): KindReport {
|
|
131
|
+
return {
|
|
132
|
+
read: a.read + b.read,
|
|
133
|
+
inserted: a.inserted + b.inserted,
|
|
134
|
+
updated: a.updated + b.updated,
|
|
135
|
+
skipped: [...a.skipped, ...b.skipped],
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function importPage(
|
|
140
|
+
kind: Kind,
|
|
141
|
+
options: RunOptions,
|
|
142
|
+
after: number,
|
|
143
|
+
limit: number,
|
|
144
|
+
): Promise<{ report: KindReport; nextCursor: number | null }> {
|
|
145
|
+
const { source, sink } = options
|
|
146
|
+
|
|
147
|
+
const write = async <T>(
|
|
148
|
+
page: Page<T>,
|
|
149
|
+
put: (rows: readonly T[]) => Promise<WriteResult>,
|
|
150
|
+
): Promise<{ report: KindReport; nextCursor: number | null }> => {
|
|
151
|
+
if (page.rows.length === 0) return { report: emptyKind(), nextCursor: page.nextCursor }
|
|
152
|
+
const written = await put(page.rows)
|
|
153
|
+
return { report: report(page.rows.length, written), nextCursor: page.nextCursor }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
switch (kind) {
|
|
157
|
+
case 'users':
|
|
158
|
+
return write(await source.users(after, limit), sink.putUsers.bind(sink))
|
|
159
|
+
case 'forums':
|
|
160
|
+
return write(await source.forums(after, limit), sink.putForums.bind(sink))
|
|
161
|
+
case 'threads':
|
|
162
|
+
return write(await source.threads(after, limit), sink.putThreads.bind(sink))
|
|
163
|
+
case 'posts':
|
|
164
|
+
return write(await source.posts(after, limit), sink.putPosts.bind(sink))
|
|
165
|
+
case 'avatars':
|
|
166
|
+
return write(await source.avatars(after, limit), sink.putAvatars.bind(sink))
|
|
167
|
+
case 'attachments':
|
|
168
|
+
return write(await source.attachments(after, limit), sink.putAttachments.bind(sink))
|
|
169
|
+
case 'polls':
|
|
170
|
+
return write(await source.polls(after, limit), sink.putPolls.bind(sink))
|
|
171
|
+
case 'pollVotes':
|
|
172
|
+
return write(await source.pollVotes(after, limit), sink.putPollVotes.bind(sink))
|
|
173
|
+
case 'privateMessages':
|
|
174
|
+
return write(await source.privateMessages(after, limit), sink.putPrivateMessages.bind(sink))
|
|
175
|
+
case 'threadSubscriptions':
|
|
176
|
+
return write(
|
|
177
|
+
await source.threadSubscriptions(after, limit),
|
|
178
|
+
sink.putThreadSubscriptions.bind(sink),
|
|
179
|
+
)
|
|
180
|
+
case 'forumSubscriptions':
|
|
181
|
+
return write(
|
|
182
|
+
await source.forumSubscriptions(after, limit),
|
|
183
|
+
sink.putForumSubscriptions.bind(sink),
|
|
184
|
+
)
|
|
185
|
+
case 'reputation':
|
|
186
|
+
return write(await source.reputation(after, limit), sink.putReputation.bind(sink))
|
|
187
|
+
case 'warnings':
|
|
188
|
+
return write(await source.warnings(after, limit), sink.putWarnings.bind(sink))
|
|
189
|
+
case 'bans':
|
|
190
|
+
return write(await source.bans(after, limit), sink.putBans.bind(sink))
|
|
191
|
+
case 'userRelations':
|
|
192
|
+
return write(await source.userRelations(after, limit), sink.putUserRelations.bind(sink))
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function report(read: number, written: WriteResult): KindReport {
|
|
197
|
+
return { read, inserted: written.inserted, updated: written.updated, skipped: written.skipped }
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export interface CounterComparison {
|
|
201
|
+
readonly legacyId: number
|
|
202
|
+
readonly field: string
|
|
203
|
+
readonly claimed: number
|
|
204
|
+
readonly actual: number
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function compareCounters(input: {
|
|
208
|
+
readonly forums: readonly ImportedForum[]
|
|
209
|
+
readonly threads: readonly ImportedThread[]
|
|
210
|
+
readonly posts: readonly ImportedPost[]
|
|
211
|
+
readonly claimedForumTotals: Readonly<Record<number, { threads: number; posts: number }>>
|
|
212
|
+
}): readonly CounterComparison[] {
|
|
213
|
+
const differences: CounterComparison[] = []
|
|
214
|
+
|
|
215
|
+
const visibleThreads = input.threads.filter((thread) => thread.visibility === 'visible')
|
|
216
|
+
const visiblePosts = input.posts.filter((post) => post.visibility === 'visible')
|
|
217
|
+
|
|
218
|
+
for (const forum of input.forums) {
|
|
219
|
+
const claimed = input.claimedForumTotals[forum.legacyId]
|
|
220
|
+
if (claimed === undefined) continue
|
|
221
|
+
|
|
222
|
+
const actualThreads = visibleThreads.filter(
|
|
223
|
+
(thread) => thread.legacyForumId === forum.legacyId,
|
|
224
|
+
).length
|
|
225
|
+
const actualPosts = visiblePosts.filter((post) => post.legacyForumId === forum.legacyId).length
|
|
226
|
+
|
|
227
|
+
if (claimed.threads !== actualThreads) {
|
|
228
|
+
differences.push({
|
|
229
|
+
legacyId: forum.legacyId,
|
|
230
|
+
field: 'threads',
|
|
231
|
+
claimed: claimed.threads,
|
|
232
|
+
actual: actualThreads,
|
|
233
|
+
})
|
|
234
|
+
}
|
|
235
|
+
if (claimed.posts !== actualPosts) {
|
|
236
|
+
differences.push({
|
|
237
|
+
legacyId: forum.legacyId,
|
|
238
|
+
field: 'posts',
|
|
239
|
+
claimed: claimed.posts,
|
|
240
|
+
actual: actualPosts,
|
|
241
|
+
})
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
for (const thread of visibleThreads) {
|
|
246
|
+
const actual = Math.max(
|
|
247
|
+
0,
|
|
248
|
+
visiblePosts.filter((post) => post.legacyThreadId === thread.legacyId).length - 1,
|
|
249
|
+
)
|
|
250
|
+
if (thread.replyCount !== actual) {
|
|
251
|
+
differences.push({
|
|
252
|
+
legacyId: thread.legacyId,
|
|
253
|
+
field: 'replies',
|
|
254
|
+
claimed: thread.replyCount,
|
|
255
|
+
actual,
|
|
256
|
+
})
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return differences
|
|
261
|
+
}
|
package/src/source.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ImportedAttachment,
|
|
3
|
+
ImportedAvatar,
|
|
4
|
+
ImportedBan,
|
|
5
|
+
ImportedForum,
|
|
6
|
+
ImportedPoll,
|
|
7
|
+
ImportedPollVote,
|
|
8
|
+
ImportedPost,
|
|
9
|
+
ImportedPrivateMessage,
|
|
10
|
+
ImportedReputation,
|
|
11
|
+
ImportedSubscription,
|
|
12
|
+
ImportedThread,
|
|
13
|
+
ImportedUser,
|
|
14
|
+
ImportedUserRelation,
|
|
15
|
+
ImportedWarning,
|
|
16
|
+
} from './types'
|
|
17
|
+
|
|
18
|
+
export interface Page<T> {
|
|
19
|
+
readonly rows: readonly T[]
|
|
20
|
+
readonly nextCursor: number | null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type ImportSourceName = 'mybb' | 'phpbb'
|
|
24
|
+
|
|
25
|
+
export interface ImportSource {
|
|
26
|
+
readonly name: ImportSourceName
|
|
27
|
+
users(afterId: number, limit: number): Promise<Page<ImportedUser>>
|
|
28
|
+
forums(afterId: number, limit: number): Promise<Page<ImportedForum>>
|
|
29
|
+
threads(afterId: number, limit: number): Promise<Page<ImportedThread>>
|
|
30
|
+
posts(afterId: number, limit: number): Promise<Page<ImportedPost>>
|
|
31
|
+
avatars(afterId: number, limit: number): Promise<Page<ImportedAvatar>>
|
|
32
|
+
attachments(afterId: number, limit: number): Promise<Page<ImportedAttachment>>
|
|
33
|
+
polls(afterId: number, limit: number): Promise<Page<ImportedPoll>>
|
|
34
|
+
pollVotes(afterId: number, limit: number): Promise<Page<ImportedPollVote>>
|
|
35
|
+
privateMessages(afterId: number, limit: number): Promise<Page<ImportedPrivateMessage>>
|
|
36
|
+
threadSubscriptions(afterId: number, limit: number): Promise<Page<ImportedSubscription>>
|
|
37
|
+
forumSubscriptions(afterId: number, limit: number): Promise<Page<ImportedSubscription>>
|
|
38
|
+
reputation(afterId: number, limit: number): Promise<Page<ImportedReputation>>
|
|
39
|
+
warnings(afterId: number, limit: number): Promise<Page<ImportedWarning>>
|
|
40
|
+
bans(afterId: number, limit: number): Promise<Page<ImportedBan>>
|
|
41
|
+
userRelations(afterId: number, limit: number): Promise<Page<ImportedUserRelation>>
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function pageById<T>(
|
|
45
|
+
all: readonly T[],
|
|
46
|
+
idOf: (row: T) => number,
|
|
47
|
+
afterId: number,
|
|
48
|
+
limit: number,
|
|
49
|
+
): Page<T> {
|
|
50
|
+
const rows = [...all]
|
|
51
|
+
.sort((a, b) => idOf(a) - idOf(b))
|
|
52
|
+
.filter((row) => idOf(row) > afterId)
|
|
53
|
+
.slice(0, limit)
|
|
54
|
+
|
|
55
|
+
const last = rows.at(-1)
|
|
56
|
+
return { rows, nextCursor: rows.length < limit || last === undefined ? null : idOf(last) }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function pageByGroup<T>(input: {
|
|
60
|
+
readonly afterGroup: number
|
|
61
|
+
readonly limit: number
|
|
62
|
+
readonly groupOf: (row: T) => number
|
|
63
|
+
readonly fetch: (afterGroup: number, limit: number) => Promise<readonly T[]>
|
|
64
|
+
readonly fetchGroup: (group: number) => Promise<readonly T[]>
|
|
65
|
+
}): Promise<Page<T>> {
|
|
66
|
+
const rows = await input.fetch(input.afterGroup, input.limit)
|
|
67
|
+
const last = rows.at(-1)
|
|
68
|
+
if (rows.length < input.limit || last === undefined) return { rows, nextCursor: null }
|
|
69
|
+
|
|
70
|
+
const lastGroup = input.groupOf(last)
|
|
71
|
+
const kept = rows.filter((row) => input.groupOf(row) !== lastGroup)
|
|
72
|
+
|
|
73
|
+
if (kept.length === 0) {
|
|
74
|
+
const whole = await input.fetchGroup(lastGroup)
|
|
75
|
+
return { rows: whole, nextCursor: lastGroup }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const lastKept = kept.at(-1)
|
|
79
|
+
return {
|
|
80
|
+
rows: kept,
|
|
81
|
+
nextCursor: lastKept === undefined ? null : input.groupOf(lastKept),
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function groupRows<T>(
|
|
86
|
+
all: readonly T[],
|
|
87
|
+
groupOf: (row: T) => number,
|
|
88
|
+
): {
|
|
89
|
+
fetch: (afterGroup: number, limit: number) => Promise<readonly T[]>
|
|
90
|
+
fetchGroup: (group: number) => Promise<readonly T[]>
|
|
91
|
+
} {
|
|
92
|
+
const sorted = [...all].sort((a, b) => groupOf(a) - groupOf(b))
|
|
93
|
+
return {
|
|
94
|
+
fetch: (afterGroup, limit) =>
|
|
95
|
+
Promise.resolve(sorted.filter((row) => groupOf(row) > afterGroup).slice(0, limit)),
|
|
96
|
+
fetchGroup: (group) => Promise.resolve(sorted.filter((row) => groupOf(row) === group)),
|
|
97
|
+
}
|
|
98
|
+
}
|