From 06ca713dc82c7fb47ad3866c77b6b8c82f1f4ebf Mon Sep 17 00:00:00 2001 From: syui Date: Wed, 21 Jan 2026 02:30:44 +0900 Subject: [PATCH] add ai.syui.card.old --- public/service/ai.syui.card.old.png | Bin 0 -> 49740 bytes src/web/components/card-migrate.ts | 239 ++++++++++++++++++++++++++++ src/web/components/profile.ts | 27 +++- src/web/lib/api.ts | 141 ++++++++++++++++ src/web/lib/auth.ts | 50 ++++++ src/web/lib/router.ts | 10 +- src/web/main.ts | 29 +++- src/web/styles/card-migrate.css | 44 +++++ 8 files changed, 532 insertions(+), 8 deletions(-) create mode 100644 public/service/ai.syui.card.old.png create mode 100644 src/web/components/card-migrate.ts create mode 100644 src/web/styles/card-migrate.css diff --git a/public/service/ai.syui.card.old.png b/public/service/ai.syui.card.old.png new file mode 100644 index 0000000000000000000000000000000000000000..ed4f62e8ff674582c817455173038c6e659982fc GIT binary patch literal 49740 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%0DWO8u7f_-?;D^1&c=QL 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 E0RN57-2eap literal 0 HcmV?d00001 diff --git a/src/web/components/card-migrate.ts b/src/web/components/card-migrate.ts new file mode 100644 index 0000000..3a12b97 --- /dev/null +++ b/src/web/components/card-migrate.ts @@ -0,0 +1,239 @@ +// Card migration component - migrate from api.syui.ai to ATProto + +import { getOldApiUserByDid, getOldApiCards, getCardOldRecordKey, generateChecksum, type OldApiUser, type OldApiCard } from '../lib/api' +import { saveMigratedCardData, isLoggedIn, getLoggedInDid } from '../lib/auth' + +export interface MigrationState { + loading: boolean + oldApiUser: OldApiUser | null + oldApiCards: OldApiCard[] + hasMigrated: boolean + migratedRkey: string | null + error: string | null +} + +// Check migration status for a user +export async function checkMigrationStatus(did: string): Promise { + const state: MigrationState = { + loading: true, + oldApiUser: null, + oldApiCards: [], + hasMigrated: false, + migratedRkey: null, + error: null + } + + try { + // Check if already migrated + state.migratedRkey = await getCardOldRecordKey(did) + state.hasMigrated = state.migratedRkey !== null + + // Check if user exists in api.syui.ai + state.oldApiUser = await getOldApiUserByDid(did) + + if (state.oldApiUser) { + // Load cards + state.oldApiCards = await getOldApiCards(state.oldApiUser.id) + } + } catch (err) { + state.error = String(err) + } + + state.loading = false + return state +} + +// Convert datetime to ISO UTC format +function toUtcDatetime(dateStr: string): string { + try { + return new Date(dateStr).toISOString() + } catch { + return new Date().toISOString() + } +} + +// Perform migration +export async function performMigration(user: OldApiUser, cards: OldApiCard[]): Promise { + const checksum = generateChecksum(user, cards) + + // Convert user data (only required + used fields, matching lexicon types) + // Note: ATProto doesn't support float, so planet is converted to integer + const userData = { + username: user.username, + did: user.did, + aiten: Math.floor(user.aiten), + fav: Math.floor(user.fav), + coin: Math.floor(user.coin), + planet: Math.floor(user.planet), + createdAt: toUtcDatetime(user.created_at), + updatedAt: toUtcDatetime(user.updated_at), + } + + // Convert card data (only required + used fields) + const cardData = cards.map(c => ({ + id: c.id, + card: c.card, + cp: c.cp, + status: c.status || 'normal', + skill: c.skill || 'normal', + createdAt: toUtcDatetime(c.created_at), + })) + + const result = await saveMigratedCardData(userData, cardData, checksum) + return result !== null +} + +// Render migration icon for profile (shown when user has api.syui.ai account) +export function renderMigrationIcon(handle: string, hasOldApi: boolean, hasMigrated: boolean): string { + if (!hasOldApi) return '' + + const icon = hasMigrated ? '/service/ai.syui.card.png' : '/service/ai.syui.card.old.png' + const title = hasMigrated ? 'Card (Migrated)' : 'Card Migration Available' + + return ` + + Card Migration + + ` +} + +// Convert status to rarity +function statusToRare(status: string): number { + switch (status) { + case 'super': return 3 // unique + case 'shiny': return 2 // shiny (assumed from skill or special status) + case 'first': return 1 // rare + default: return 0 // normal + } +} + +// Render migration page (simplified) +export function renderMigrationPage( + state: MigrationState, + handle: string, + isOwner: boolean +): string { + const { oldApiUser, oldApiCards, hasMigrated, migratedRkey, error } = state + const jsonUrl = migratedRkey + ? `/@${handle}/at/collection/ai.syui.card.old/${migratedRkey}` + : `/@${handle}/at/collection/ai.syui.card.old` + + if (error) { + return ` +
+
Error: ${error}
+
+ ` + } + + if (!oldApiUser) { + return ` +
+

No api.syui.ai account found

+
+ ` + } + + // Button or migrated status + let buttonHtml = '' + if (hasMigrated) { + buttonHtml = `✓ migrated` + } else if (isOwner && isLoggedIn()) { + buttonHtml = `` + } + + // Card grid (same style as /card page) + const cardGroups = new Map() + for (const card of oldApiCards) { + const existing = cardGroups.get(card.card) + const rare = statusToRare(card.status) + if (existing) { + existing.count++ + if (card.cp > existing.maxCp) existing.maxCp = card.cp + if (rare > existing.rare) existing.rare = rare + } else { + cardGroups.set(card.card, { card, count: 1, maxCp: card.cp, rare }) + } + } + + const sortedGroups = Array.from(cardGroups.values()) + .sort((a, b) => a.card.card - b.card.card) + + const cardsHtml = sortedGroups.map(({ card, count, maxCp, rare }) => { + const rarityClass = rare === 3 ? 'unique' : rare === 2 ? 'shiny' : rare === 1 ? 'rare' : '' + const effectsHtml = rarityClass ? ` +
+
+ ` : '' + const countBadge = count > 1 ? `x${count}` : '' + + return ` +
+
+
+ Card ${card.card} +
+ ${effectsHtml} + ${countBadge} +
+
+ ${maxCp} +
+
+ ` + }).join('') + + return ` +
+
+ api.syui.ai → ai.syui.card.old + ${buttonHtml} + json +
+
${cardsHtml}
+
+ ` +} + +// Setup migration button handler +export function setupMigrationButton( + oldApiUser: OldApiUser, + oldApiCards: OldApiCard[], + onSuccess: () => void +): void { + const btn = document.getElementById('migrate-btn') + if (!btn) return + + btn.addEventListener('click', async (e) => { + e.preventDefault() + e.stopPropagation() + + const loggedInDid = getLoggedInDid() + if (!loggedInDid || loggedInDid !== oldApiUser.did) { + alert('DID mismatch. Please login with the correct account.') + return + } + + if (!confirm(`Migrate ${oldApiCards.length} cards to ATProto?`)) { + return + } + + btn.textContent = 'Migrating...' + ;(btn as HTMLButtonElement).disabled = true + + try { + const success = await performMigration(oldApiUser, oldApiCards) + if (success) { + alert('Migration successful!') + onSuccess() + } else { + alert('Migration failed.') + } + } catch (err) { + alert('Migration error: ' + err) + } + + btn.textContent = 'Migrate to ATProto' + ;(btn as HTMLButtonElement).disabled = false + }) +} diff --git a/src/web/components/profile.ts b/src/web/components/profile.ts index 67436b4..a868d83 100644 --- a/src/web/components/profile.ts +++ b/src/web/components/profile.ts @@ -9,8 +9,14 @@ export interface ServiceLink { collection: string } +// Migration state for api.syui.ai users +export interface MigrationInfo { + hasOldApi: boolean + hasMigrated: boolean +} + // Get available services based on user's collections -export function getServiceLinks(handle: string, collections: string[]): ServiceLink[] { +export function getServiceLinks(handle: string, collections: string[], migration?: MigrationInfo): ServiceLink[] { const services: ServiceLink[] = [] if (collections.includes('ai.syui.card.user')) { @@ -22,6 +28,16 @@ export function getServiceLinks(handle: string, collections: string[]): ServiceL }) } + // Add migration link if user has api.syui.ai account + if (migration?.hasOldApi) { + services.push({ + name: 'Card (old)', + icon: '/service/ai.syui.card.old.png', + url: `/@${handle}/at/card-old`, + collection: 'ai.syui.card.old' + }) + } + return services } @@ -31,7 +47,8 @@ export async function renderProfile( handle: string, webUrl?: string, localOnly = false, - collections: string[] = [] + collections: string[] = [], + migration?: MigrationInfo ): Promise { // Local mode: sync, no API call. Remote mode: async with API call const avatarUrl = localOnly @@ -51,10 +68,10 @@ export async function renderProfile( ? `${escapeHtml(displayName)}` : `
` - // Service icons (show for users with matching collections) + // Service icons (show for users with matching collections or migration available) let serviceIconsHtml = '' - if (collections.length > 0) { - const services = getServiceLinks(handle, collections) + if (collections.length > 0 || migration?.hasOldApi) { + const services = getServiceLinks(handle, collections, migration) if (services.length > 0) { const iconsHtml = services.map(s => ` diff --git a/src/web/lib/api.ts b/src/web/lib/api.ts index d0131f9..e22f59e 100644 --- a/src/web/lib/api.ts +++ b/src/web/lib/api.ts @@ -429,6 +429,147 @@ export async function getChatMessages( ) } +// ============================================ +// api.syui.ai migration functions +// ============================================ + +const API_SYUI_AI = 'https://api.syui.ai' + +// Old API user type +export interface OldApiUser { + id: number + username: string + did: string + member: boolean + book: boolean + manga: boolean + badge: boolean + bsky: boolean + mastodon: boolean + delete: boolean + handle: boolean + created_at: string + updated_at: string + raid_at: string + server_at: string + egg_at: string + luck: number + luck_at: string + like: number + like_rank: number + like_at: string + fav: number + ten: boolean + ten_su: number + ten_kai: number + aiten: number + ten_card: string + ten_delete: string + ten_post: string + ten_get: string + ten_at: string + next: string + room: number + model: boolean + model_at: string + model_attack: number + model_limit: number + model_skill: number + model_mode: number + model_critical: number + model_critical_d: number + game: boolean + game_test: boolean + game_end: boolean + game_account: boolean + game_lv: number + game_exp: number + game_story: number + game_limit: boolean + coin: number + coin_open: boolean + coin_at: string + planet: number + planet_at: string + login: boolean + login_at: string + location_x: number + location_y: number + location_z: number + location_n: number +} + +// Old API card type +export interface OldApiCard { + id: number + card: number + skill: string + status: string + cp: number + url: string + count: number + author: string + created_at: string +} + +// Check if user exists in api.syui.ai by DID +export async function getOldApiUserByDid(did: string): Promise { + try { + const res = await fetch(`${API_SYUI_AI}/users?itemsPerPage=2500`) + if (!res.ok) return null + const users: OldApiUser[] = await res.json() + return users.find(u => u.did === did) || null + } catch { + return null + } +} + +// Get user's cards from api.syui.ai +export async function getOldApiCards(userId: number): Promise { + try { + const res = await fetch(`${API_SYUI_AI}/users/${userId}/card?itemsPerPage=5000`) + if (!res.ok) return [] + return res.json() + } catch { + return [] + } +} + +// Check if ai.syui.card.old record exists and return the rkey +export async function getCardOldRecordKey(did: string): Promise { + const pds = await getPds(did) + if (!pds) return null + + try { + const host = pds.replace('https://', '') + const url = `${xrpcUrl(host, comAtprotoRepo.listRecords)}?repo=${did}&collection=ai.syui.card.old&limit=1` + const res = await fetch(url) + if (!res.ok) return null + const data = await res.json() + if (data.records && data.records.length > 0) { + // Extract rkey from URI: at://did/collection/rkey + const uri = data.records[0].uri as string + const rkey = uri.split('/').pop() + return rkey || null + } + return null + } catch { + return null + } +} + +// Check if ai.syui.card.old record exists +export async function hasCardOldRecord(did: string): Promise { + const rkey = await getCardOldRecordKey(did) + return rkey !== null +} + +// Generate checksum for verification +export function generateChecksum(user: OldApiUser, cards: OldApiCard[]): string { + const sum = user.id + user.aiten + user.fav + cards.reduce((acc, c) => acc + c.id + c.cp + c.card, 0) + return btoa(String(sum)) +} + // Get user's card collection (ai.syui.card.user) export async function getCards( did: string, diff --git a/src/web/lib/auth.ts b/src/web/lib/auth.ts index 27aec8b..e4f5ac3 100644 --- a/src/web/lib/auth.ts +++ b/src/web/lib/auth.ts @@ -272,6 +272,56 @@ export async function updatePost( } } +// Save migrated card data to ai.syui.card.old +export async function saveMigratedCardData( + user: { + username: string + did: string + aiten: number + planet: number + fav: number + coin: number + createdAt: string + updatedAt: string + }, + cards: { + id: number + card: number + cp: number + status: string + skill: string + createdAt: string + }[], + checksum: string +): Promise<{ uri: string; cid: string } | null> { + if (!agent) return null + + const collection = 'ai.syui.card.old' + const rkey = 'self' + + try { + const record = { + $type: collection, + user, + cards, + checksum, + migratedAt: new Date().toISOString(), + } + + const result = await agent.com.atproto.repo.putRecord({ + repo: agent.assertDid, + collection, + rkey, + record, + }) + + return { uri: result.data.uri, cid: result.data.cid } + } catch (err) { + console.error('Save migrated card data error:', err) + throw err + } +} + // Delete record export async function deleteRecord( collection: string, diff --git a/src/web/lib/router.ts b/src/web/lib/router.ts index b385c6a..6891bfe 100644 --- a/src/web/lib/router.ts +++ b/src/web/lib/router.ts @@ -1,5 +1,5 @@ export interface Route { - type: 'home' | 'user' | 'post' | 'postpage' | 'atbrowser' | 'service' | 'collection' | 'record' | 'chat' | 'chat-thread' | 'card' + type: 'home' | 'user' | 'post' | 'postpage' | 'atbrowser' | 'service' | 'collection' | 'record' | 'chat' | 'chat-thread' | 'card' | 'card-old' handle?: string rkey?: string service?: string @@ -57,6 +57,12 @@ export function parseRoute(): Route { return { type: 'card', handle: cardMatch[1] } } + // Card migration page: /@handle/at/card-old + const cardOldMatch = path.match(/^\/@([^/]+)\/at\/card-old\/?$/) + if (cardOldMatch) { + return { type: 'card-old', handle: cardOldMatch[1] } + } + // Chat thread: /@handle/at/chat/{rkey} const chatThreadMatch = path.match(/^\/@([^/]+)\/at\/chat\/([^/]+)$/) if (chatThreadMatch) { @@ -99,6 +105,8 @@ export function navigate(route: Route): void { path = `/@${route.handle}/at/collection/${route.collection}/${route.rkey}` } else if (route.type === 'card' && route.handle) { path = `/@${route.handle}/at/card` + } else if (route.type === 'card-old' && route.handle) { + path = `/@${route.handle}/at/card-old` } else if (route.type === 'chat' && route.handle) { path = `/@${route.handle}/at/chat` } else if (route.type === 'chat-thread' && route.handle && route.rkey) { diff --git a/src/web/main.ts b/src/web/main.ts index 8a3c278..a3916cf 100644 --- a/src/web/main.ts +++ b/src/web/main.ts @@ -1,6 +1,7 @@ import './styles/main.css' import './styles/card.css' -import { getConfig, resolveHandle, getProfile, getPosts, getPost, describeRepo, listRecords, getRecord, getPds, getNetworks, getChatMessages, getCards } from './lib/api' +import './styles/card-migrate.css' +import { getConfig, resolveHandle, getProfile, getPosts, getPost, describeRepo, listRecords, getRecord, getPds, getNetworks, getChatMessages, getCards, getOldApiUserByDid, hasCardOldRecord } from './lib/api' import { parseRoute, onRouteChange, navigate, type Route } from './lib/router' import { login, logout, handleCallback, restoreSession, isLoggedIn, getLoggedInHandle, getLoggedInDid, deleteRecord, updatePost } from './lib/auth' import { validateRecord } from './lib/lexicon' @@ -13,6 +14,7 @@ import { renderModeTabs, renderLangSelector, setupModeTabs } from './components/ import { renderFooter } from './components/footer' import { renderChatListPage, renderChatThreadPage } from './components/chat' import { renderCardPage } from './components/card' +import { checkMigrationStatus, renderMigrationPage, setupMigrationButton } from './components/card-migrate' import { showLoading, hideLoading } from './components/loading' const app = document.getElementById('app')! @@ -173,13 +175,21 @@ async function render(route: Route): Promise { const loggedInDid = getLoggedInDid() const isOwner = isLoggedIn() && loggedInDid === did + // Check migration status (api.syui.ai -> ATProto) + const [oldApiUser, hasMigrated] = await Promise.all([ + getOldApiUserByDid(did), + hasCardOldRecord(did) + ]) + const migration = oldApiUser ? { hasOldApi: true, hasMigrated } : undefined + // Profile section if (profile) { - html += await renderProfile(did, profile, handle, webUrl, localOnly, collections) + html += await renderProfile(did, profile, handle, webUrl, localOnly, collections, migration) } // Content section based on route type let currentRecord: { uri: string; cid: string; value: unknown } | null = null + let cardMigrationState: Awaited> | null = null if (route.type === 'record' && route.collection && route.rkey) { // AT-Browser: Single record view @@ -238,6 +248,12 @@ async function render(route: Route): Promise { html += `
${renderCardPage(cards, handle, cardCollection)}
` html += `
` + } else if (route.type === 'card-old') { + // Card migration page + cardMigrationState = await checkMigrationStatus(did) + html += `
${renderMigrationPage(cardMigrationState, handle, isOwner)}
` + html += `` + } else if (route.type === 'chat') { // Chat list page - show threads started by this user if (!config.bot) { @@ -365,6 +381,15 @@ async function render(route: Route): Promise { } } + // Setup card migration button + if (route.type === 'card-old' && cardMigrationState?.oldApiUser && cardMigrationState?.oldApiCards) { + setupMigrationButton( + cardMigrationState.oldApiUser, + cardMigrationState.oldApiCards, + () => render(parseRoute()) // Refresh on success + ) + } + } catch (error) { console.error('Render error:', error) app.innerHTML = ` diff --git a/src/web/styles/card-migrate.css b/src/web/styles/card-migrate.css new file mode 100644 index 0000000..96741d7 --- /dev/null +++ b/src/web/styles/card-migrate.css @@ -0,0 +1,44 @@ +/* Card Migration Page Styles */ + +.migrate-title { + font-weight: 500; + color: var(--text-secondary, #666); +} + +.migrated-badge { + color: #00a060; + font-size: 0.9em; +} + +.migrate-btn { + background: var(--btn-color, #0066cc); + color: white; + border: none; + padding: 6px 16px; + border-radius: 4px; + font-size: 0.9em; + cursor: pointer; + transition: opacity 0.2s; +} + +.migrate-btn:hover { + opacity: 0.9; +} + +.migrate-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.no-data { + color: var(--text-secondary, #666); + text-align: center; + padding: 32px; +} + +/* Dark mode */ +@media (prefers-color-scheme: dark) { + .migrate-title { + color: var(--text-secondary, #aaa); + } +}