add ai.syui.card.old

This commit is contained in:
2026-01-21 02:30:44 +09:00
parent 55c504e338
commit 3ea39cb224
8 changed files with 532 additions and 8 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

View File

@@ -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<MigrationState> {
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<boolean> {
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 `
<a href="/@${handle}/at/card-old" class="service-icon-link" title="${title}">
<img src="${icon}" alt="Card Migration" class="service-icon ${hasMigrated ? '' : 'migration-available'}" />
</a>
`
}
// 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 `
<div class="card-migrate-page">
<div class="error">Error: ${error}</div>
</div>
`
}
if (!oldApiUser) {
return `
<div class="card-migrate-page">
<p class="no-data">No api.syui.ai account found</p>
</div>
`
}
// Button or migrated status
let buttonHtml = ''
if (hasMigrated) {
buttonHtml = `<span class="migrated-badge">✓ migrated</span>`
} else if (isOwner && isLoggedIn()) {
buttonHtml = `<button id="migrate-btn" class="migrate-btn">Migrate</button>`
}
// Card grid (same style as /card page)
const cardGroups = new Map<number, { card: OldApiCard, count: number, maxCp: number, rare: number }>()
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 ? `
<div class="card-status pattern-${rarityClass}"></div>
<div class="card-status color-${rarityClass}"></div>
` : ''
const countBadge = count > 1 ? `<span class="card-count">x${count}</span>` : ''
return `
<div class="card-item">
<div class="card-wrapper">
<div class="card-reflection">
<img src="/card/${card.card}.webp" alt="Card ${card.card}" loading="lazy" />
</div>
${effectsHtml}
${countBadge}
</div>
<div class="card-detail">
<span class="card-cp">${maxCp}</span>
</div>
</div>
`
}).join('')
return `
<div class="card-page">
<div class="card-header">
<span class="migrate-title">api.syui.ai → ai.syui.card.old</span>
${buttonHtml}
<a href="${jsonUrl}" class="json-btn">json</a>
</div>
<div class="card-grid">${cardsHtml}</div>
</div>
`
}
// 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
})
}

View File

@@ -9,8 +9,14 @@ export interface ServiceLink {
collection: string collection: string
} }
// Migration state for api.syui.ai users
export interface MigrationInfo {
hasOldApi: boolean
hasMigrated: boolean
}
// Get available services based on user's collections // 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[] = [] const services: ServiceLink[] = []
if (collections.includes('ai.syui.card.user')) { 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 return services
} }
@@ -31,7 +47,8 @@ export async function renderProfile(
handle: string, handle: string,
webUrl?: string, webUrl?: string,
localOnly = false, localOnly = false,
collections: string[] = [] collections: string[] = [],
migration?: MigrationInfo
): Promise<string> { ): Promise<string> {
// Local mode: sync, no API call. Remote mode: async with API call // Local mode: sync, no API call. Remote mode: async with API call
const avatarUrl = localOnly const avatarUrl = localOnly
@@ -51,10 +68,10 @@ export async function renderProfile(
? `<img src="${avatarUrl}" alt="${escapeHtml(displayName)}" class="profile-avatar">` ? `<img src="${avatarUrl}" alt="${escapeHtml(displayName)}" class="profile-avatar">`
: `<div class="profile-avatar-placeholder"></div>` : `<div class="profile-avatar-placeholder"></div>`
// Service icons (show for users with matching collections) // Service icons (show for users with matching collections or migration available)
let serviceIconsHtml = '' let serviceIconsHtml = ''
if (collections.length > 0) { if (collections.length > 0 || migration?.hasOldApi) {
const services = getServiceLinks(handle, collections) const services = getServiceLinks(handle, collections, migration)
if (services.length > 0) { if (services.length > 0) {
const iconsHtml = services.map(s => ` const iconsHtml = services.map(s => `
<a href="${s.url}" class="service-icon-link" title="${s.name}"> <a href="${s.url}" class="service-icon-link" title="${s.name}">

View File

@@ -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<OldApiUser | null> {
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<OldApiCard[]> {
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<string | null> {
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<boolean> {
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) // Get user's card collection (ai.syui.card.user)
export async function getCards( export async function getCards(
did: string, did: string,

View File

@@ -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 // Delete record
export async function deleteRecord( export async function deleteRecord(
collection: string, collection: string,

View File

@@ -1,5 +1,5 @@
export interface Route { 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 handle?: string
rkey?: string rkey?: string
service?: string service?: string
@@ -57,6 +57,12 @@ export function parseRoute(): Route {
return { type: 'card', handle: cardMatch[1] } 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} // Chat thread: /@handle/at/chat/{rkey}
const chatThreadMatch = path.match(/^\/@([^/]+)\/at\/chat\/([^/]+)$/) const chatThreadMatch = path.match(/^\/@([^/]+)\/at\/chat\/([^/]+)$/)
if (chatThreadMatch) { if (chatThreadMatch) {
@@ -99,6 +105,8 @@ export function navigate(route: Route): void {
path = `/@${route.handle}/at/collection/${route.collection}/${route.rkey}` path = `/@${route.handle}/at/collection/${route.collection}/${route.rkey}`
} else if (route.type === 'card' && route.handle) { } else if (route.type === 'card' && route.handle) {
path = `/@${route.handle}/at/card` 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) { } else if (route.type === 'chat' && route.handle) {
path = `/@${route.handle}/at/chat` path = `/@${route.handle}/at/chat`
} else if (route.type === 'chat-thread' && route.handle && route.rkey) { } else if (route.type === 'chat-thread' && route.handle && route.rkey) {

View File

@@ -1,6 +1,7 @@
import './styles/main.css' import './styles/main.css'
import './styles/card.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 { parseRoute, onRouteChange, navigate, type Route } from './lib/router'
import { login, logout, handleCallback, restoreSession, isLoggedIn, getLoggedInHandle, getLoggedInDid, deleteRecord, updatePost } from './lib/auth' import { login, logout, handleCallback, restoreSession, isLoggedIn, getLoggedInHandle, getLoggedInDid, deleteRecord, updatePost } from './lib/auth'
import { validateRecord } from './lib/lexicon' import { validateRecord } from './lib/lexicon'
@@ -13,6 +14,7 @@ import { renderModeTabs, renderLangSelector, setupModeTabs } from './components/
import { renderFooter } from './components/footer' import { renderFooter } from './components/footer'
import { renderChatListPage, renderChatThreadPage } from './components/chat' import { renderChatListPage, renderChatThreadPage } from './components/chat'
import { renderCardPage } from './components/card' import { renderCardPage } from './components/card'
import { checkMigrationStatus, renderMigrationPage, setupMigrationButton } from './components/card-migrate'
import { showLoading, hideLoading } from './components/loading' import { showLoading, hideLoading } from './components/loading'
const app = document.getElementById('app')! const app = document.getElementById('app')!
@@ -173,13 +175,21 @@ async function render(route: Route): Promise<void> {
const loggedInDid = getLoggedInDid() const loggedInDid = getLoggedInDid()
const isOwner = isLoggedIn() && loggedInDid === did 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 // Profile section
if (profile) { 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 // Content section based on route type
let currentRecord: { uri: string; cid: string; value: unknown } | null = null let currentRecord: { uri: string; cid: string; value: unknown } | null = null
let cardMigrationState: Awaited<ReturnType<typeof checkMigrationStatus>> | null = null
if (route.type === 'record' && route.collection && route.rkey) { if (route.type === 'record' && route.collection && route.rkey) {
// AT-Browser: Single record view // AT-Browser: Single record view
@@ -238,6 +248,12 @@ async function render(route: Route): Promise<void> {
html += `<div id="content">${renderCardPage(cards, handle, cardCollection)}</div>` html += `<div id="content">${renderCardPage(cards, handle, cardCollection)}</div>`
html += `<nav class="back-nav"><a href="/@${handle}">${handle}</a></nav>` html += `<nav class="back-nav"><a href="/@${handle}">${handle}</a></nav>`
} else if (route.type === 'card-old') {
// Card migration page
cardMigrationState = await checkMigrationStatus(did)
html += `<div id="content">${renderMigrationPage(cardMigrationState, handle, isOwner)}</div>`
html += `<nav class="back-nav"><a href="/@${handle}">${handle}</a></nav>`
} else if (route.type === 'chat') { } else if (route.type === 'chat') {
// Chat list page - show threads started by this user // Chat list page - show threads started by this user
if (!config.bot) { if (!config.bot) {
@@ -365,6 +381,15 @@ async function render(route: Route): Promise<void> {
} }
} }
// 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) { } catch (error) {
console.error('Render error:', error) console.error('Render error:', error)
app.innerHTML = ` app.innerHTML = `

View File

@@ -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);
}
}