@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,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
+ '&amp;': '&',
3
+ '&lt;': '<',
4
+ '&gt;': '>',
5
+ '&quot;': '"',
6
+ '&#039;': "'",
7
+ '&#39;': "'",
8
+ '&nbsp;': ' ',
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
+ }