(
mode: 'full' | 'incremental',
options: { targetAdds?: number } = {}
)
| 168 | } |
| 169 | |
| 170 | export async function syncTwitterBookmarks( |
| 171 | mode: 'full' | 'incremental', |
| 172 | options: { targetAdds?: number } = {} |
| 173 | ): Promise<BookmarkSyncResult> { |
| 174 | const token = await loadTwitterOAuthToken(); |
| 175 | if (!token?.access_token) { |
| 176 | throw new Error('Missing user-context OAuth token. Run: ft auth'); |
| 177 | } |
| 178 | |
| 179 | const me = await fetchCurrentUserId(token.access_token); |
| 180 | if (!me.ok || !me.id) { |
| 181 | throw new Error(`Could not resolve current user id: ${me.detail}`); |
| 182 | } |
| 183 | |
| 184 | ensureDataDir(); |
| 185 | const cachePath = twitterBookmarksCachePath(); |
| 186 | const metaPath = twitterBookmarksMetaPath(); |
| 187 | const now = new Date().toISOString(); |
| 188 | const existing = await readJsonLines<BookmarkRecord>(cachePath); |
| 189 | const existingById = new Map(existing.map((item) => [item.id, item])); |
| 190 | |
| 191 | const allFetched: BookmarkRecord[] = []; |
| 192 | let nextToken: string | undefined; |
| 193 | let pages = 0; |
| 194 | const maxPages = mode === 'full' ? 200 : 2; |
| 195 | |
| 196 | while (pages < maxPages) { |
| 197 | const pageResult = await fetchBookmarksPage(token.access_token, me.id, nextToken); |
| 198 | if (!pageResult.ok || !pageResult.page) { |
| 199 | throw new Error(`Bookmark fetch failed (${pageResult.status}): ${pageResult.detail}`); |
| 200 | } |
| 201 | |
| 202 | const normalized = normalizeBookmarkPage(pageResult.page, now); |
| 203 | allFetched.push(...normalized); |
| 204 | nextToken = pageResult.page.meta?.next_token; |
| 205 | pages += 1; |
| 206 | |
| 207 | if (!nextToken) break; |
| 208 | if (mode === 'incremental' && normalized.every((item) => existingById.has(item.id))) break; |
| 209 | if (typeof options.targetAdds === 'number') { |
| 210 | const uniqueAddsSoFar = allFetched.filter((item, index, arr) => arr.findIndex((x) => x.id === item.id) === index).filter((item) => !existingById.has(item.id)).length; |
| 211 | if (uniqueAddsSoFar >= options.targetAdds) break; |
| 212 | } |
| 213 | } |
| 214 | |
| 215 | const merged = [...existing]; |
| 216 | let added = 0; |
| 217 | for (const record of allFetched) { |
| 218 | if (!existingById.has(record.id)) { |
| 219 | merged.push(record); |
| 220 | existingById.set(record.id, record); |
| 221 | added += 1; |
| 222 | if (typeof options.targetAdds === 'number' && added >= options.targetAdds) break; |
| 223 | } |
| 224 | } |
| 225 | |
| 226 | merged.sort((a, b) => String(b.bookmarkedAt ?? b.syncedAt).localeCompare(String(a.bookmarkedAt ?? a.syncedAt))); |
| 227 | await writeJsonLines(cachePath, merged); |
no test coverage detected