test pds oauth did
This commit is contained in:
		@@ -10,6 +10,10 @@ license = "MIT"
 | 
			
		||||
name = "ailog"
 | 
			
		||||
path = "src/main.rs"
 | 
			
		||||
 | 
			
		||||
[lib]
 | 
			
		||||
name = "ailog"
 | 
			
		||||
path = "src/lib.rs"
 | 
			
		||||
 | 
			
		||||
[dependencies]
 | 
			
		||||
clap = { version = "4.5", features = ["derive"] }
 | 
			
		||||
pulldown-cmark = "0.11"
 | 
			
		||||
 
 | 
			
		||||
@@ -28,4 +28,4 @@ redirect = "oauth/callback"
 | 
			
		||||
admin = "ai.syui.ai"
 | 
			
		||||
collection = "ai.syui.log"
 | 
			
		||||
pds = "syu.is"
 | 
			
		||||
handle_list = ["syui.syui.ai", "yui.syui.ai", "ai.syui.ai", "syui.syu.is", "ai.syu.is", "ai.ai"]
 | 
			
		||||
handle_list = ["syui.syui.ai", "ai.syui.ai", "ai.ai"]
 | 
			
		||||
 
 | 
			
		||||
@@ -9,7 +9,7 @@ VITE_ADMIN_HANDLE=ai.syui.ai
 | 
			
		||||
VITE_AI_HANDLE=ai.syui.ai
 | 
			
		||||
VITE_OAUTH_COLLECTION=ai.syui.log
 | 
			
		||||
VITE_ATPROTO_WEB_URL=https://bsky.app
 | 
			
		||||
VITE_ATPROTO_HANDLE_LIST=["syui.syui.ai", "yui.syui.ai", "ai.syui.ai", "syui.syu.is", "ai.syu.is", "ai.ai"]
 | 
			
		||||
VITE_ATPROTO_HANDLE_LIST=["syui.syui.ai", "ai.syui.ai", "ai.ai"]
 | 
			
		||||
 | 
			
		||||
# AI Configuration
 | 
			
		||||
VITE_AI_ENABLED=true
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,6 @@
 | 
			
		||||
{
 | 
			
		||||
  "client_id": "https://syui.ai/client-metadata.json",
 | 
			
		||||
  "client_name": "ai.card",
 | 
			
		||||
  "client_name": "ai.log",
 | 
			
		||||
  "client_uri": "https://syui.ai",
 | 
			
		||||
  "logo_uri": "https://syui.ai/favicon.ico",
 | 
			
		||||
  "tos_uri": "https://syui.ai/terms",
 | 
			
		||||
 
 | 
			
		||||
@@ -9,7 +9,7 @@ VITE_ADMIN_HANDLE=ai.syui.ai
 | 
			
		||||
VITE_AI_HANDLE=ai.syui.ai
 | 
			
		||||
VITE_OAUTH_COLLECTION=ai.syui.log
 | 
			
		||||
VITE_ATPROTO_WEB_URL=https://bsky.app
 | 
			
		||||
VITE_ATPROTO_HANDLE_LIST=["syui.syui.ai","yui.syui.ai","syui.syu.is","ai.syu.is"]
 | 
			
		||||
VITE_ATPROTO_HANDLE_LIST=["syui.syui.ai","ai.syui.ai","ai.ai"]
 | 
			
		||||
 | 
			
		||||
# AI Configuration
 | 
			
		||||
VITE_AI_ENABLED=true
 | 
			
		||||
 
 | 
			
		||||
@@ -7,7 +7,9 @@
 | 
			
		||||
    "build": "vite build --mode production",
 | 
			
		||||
    "build:dev": "vite build --mode development",
 | 
			
		||||
    "build:local": "VITE_APP_HOST=http://localhost:4173 vite build --mode development",
 | 
			
		||||
    "preview": "vite preview"
 | 
			
		||||
    "preview": "npm run test:console && vite preview",
 | 
			
		||||
    "test": "vitest",
 | 
			
		||||
    "test:console": "node -r esbuild-register src/tests/console-test.ts"
 | 
			
		||||
  },
 | 
			
		||||
  "dependencies": {
 | 
			
		||||
    "@atproto/api": "^0.15.12",
 | 
			
		||||
@@ -26,6 +28,9 @@
 | 
			
		||||
    "@types/react-dom": "^18.2.18",
 | 
			
		||||
    "@vitejs/plugin-react": "^4.2.1",
 | 
			
		||||
    "typescript": "^5.3.3",
 | 
			
		||||
    "vite": "^5.0.10"
 | 
			
		||||
    "vite": "^5.0.10",
 | 
			
		||||
    "vitest": "^1.1.0",
 | 
			
		||||
    "esbuild": "^0.19.10",
 | 
			
		||||
    "esbuild-register": "^3.5.0"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,13 +1,13 @@
 | 
			
		||||
{
 | 
			
		||||
  "client_id": "https://log.syui.ai/client-metadata.json",
 | 
			
		||||
  "client_name": "ai.card",
 | 
			
		||||
  "client_uri": "https://log.syui.ai",
 | 
			
		||||
  "logo_uri": "https://log.syui.ai/favicon.ico",
 | 
			
		||||
  "tos_uri": "https://log.syui.ai/terms",
 | 
			
		||||
  "policy_uri": "https://log.syui.ai/privacy",
 | 
			
		||||
  "client_id": "https://syui.ai/client-metadata.json",
 | 
			
		||||
  "client_name": "ai.log",
 | 
			
		||||
  "client_uri": "https://syui.ai",
 | 
			
		||||
  "logo_uri": "https://syui.ai/favicon.ico",
 | 
			
		||||
  "tos_uri": "https://syui.ai/terms",
 | 
			
		||||
  "policy_uri": "https://syui.ai/privacy",
 | 
			
		||||
  "redirect_uris": [
 | 
			
		||||
    "https://log.syui.ai/oauth/callback",
 | 
			
		||||
    "https://log.syui.ai/"
 | 
			
		||||
    "https://syui.ai/oauth/callback",
 | 
			
		||||
    "https://syui.ai/"
 | 
			
		||||
  ],
 | 
			
		||||
  "response_types": [
 | 
			
		||||
    "code"
 | 
			
		||||
 
 | 
			
		||||
@@ -5,6 +5,7 @@ import { authService, User } from './services/auth';
 | 
			
		||||
import { atprotoOAuthService } from './services/atproto-oauth';
 | 
			
		||||
import { appConfig, getCollectionNames } from './config/app';
 | 
			
		||||
import { getProfileForUser, detectPdsFromHandle, getApiUrlForUser, verifyPdsDetection, getNetworkConfigFromPdsEndpoint, getNetworkConfig } from './utils/pds-detection';
 | 
			
		||||
import { isValidDid } from './utils/validation';
 | 
			
		||||
import './App.css';
 | 
			
		||||
 | 
			
		||||
function App() {
 | 
			
		||||
@@ -338,16 +339,23 @@ function App() {
 | 
			
		||||
      const currentAdminDid = adminDid || appConfig.adminDid;
 | 
			
		||||
      
 | 
			
		||||
      // Don't proceed if we don't have a valid DID
 | 
			
		||||
      if (!currentAdminDid) {
 | 
			
		||||
      if (!currentAdminDid || !isValidDid(currentAdminDid)) {
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
      // Use admin's PDS from config
 | 
			
		||||
      // Resolve admin's actual PDS from their DID
 | 
			
		||||
      let adminPdsEndpoint;
 | 
			
		||||
      try {
 | 
			
		||||
        const resolved = await import('./utils/pds-detection').then(m => m.resolvePdsFromRepo(currentAdminDid));
 | 
			
		||||
        const config = await import('./utils/pds-detection').then(m => m.getNetworkConfigFromPdsEndpoint(resolved.pds));
 | 
			
		||||
        adminPdsEndpoint = config.pdsApi;
 | 
			
		||||
      } catch {
 | 
			
		||||
        // Fallback to configured PDS
 | 
			
		||||
        const adminConfig = getNetworkConfig(appConfig.atprotoPds);
 | 
			
		||||
      const collections = getCollectionNames(appConfig.collections.base);
 | 
			
		||||
        adminPdsEndpoint = adminConfig.pdsApi;
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
      // First, get user list from admin using their proper PDS
 | 
			
		||||
      const adminPdsEndpoint = adminConfig.pdsApi;
 | 
			
		||||
      const collections = getCollectionNames(appConfig.collections.base);
 | 
			
		||||
      
 | 
			
		||||
      const userListResponse = await fetch(`${adminPdsEndpoint}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(currentAdminDid)}&collection=${encodeURIComponent(collections.user)}&limit=100`);
 | 
			
		||||
      
 | 
			
		||||
@@ -383,6 +391,10 @@ function App() {
 | 
			
		||||
          // Use per-user PDS detection for each user's chat records
 | 
			
		||||
          let userPdsEndpoint;
 | 
			
		||||
          try {
 | 
			
		||||
            // Validate DID format before making API calls
 | 
			
		||||
            if (!userDid || !userDid.startsWith('did:')) {
 | 
			
		||||
              continue;
 | 
			
		||||
            }
 | 
			
		||||
            const resolved = await import('./utils/pds-detection').then(m => m.resolvePdsFromRepo(userDid));
 | 
			
		||||
            const config = await import('./utils/pds-detection').then(m => m.getNetworkConfigFromPdsEndpoint(resolved.pds));
 | 
			
		||||
            userPdsEndpoint = config.pdsApi;
 | 
			
		||||
@@ -396,6 +408,9 @@ function App() {
 | 
			
		||||
            const chatData = await chatResponse.json();
 | 
			
		||||
            const records = chatData.records || [];
 | 
			
		||||
            allChatRecords.push(...records);
 | 
			
		||||
          } else if (chatResponse.status === 400) {
 | 
			
		||||
            // Skip 400 errors (repo not found, etc)
 | 
			
		||||
            continue;
 | 
			
		||||
          }
 | 
			
		||||
        } catch (err) {
 | 
			
		||||
          continue;
 | 
			
		||||
@@ -443,13 +458,21 @@ function App() {
 | 
			
		||||
      const currentAdminDid = adminDid || appConfig.adminDid;
 | 
			
		||||
      
 | 
			
		||||
      // Don't proceed if we don't have a valid DID
 | 
			
		||||
      if (!currentAdminDid) {
 | 
			
		||||
      if (!currentAdminDid || !isValidDid(currentAdminDid)) {
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
      // Use admin's PDS for collection access (from config)
 | 
			
		||||
      // Resolve admin's actual PDS from their DID
 | 
			
		||||
      let atprotoApi;
 | 
			
		||||
      try {
 | 
			
		||||
        const resolved = await import('./utils/pds-detection').then(m => m.resolvePdsFromRepo(currentAdminDid));
 | 
			
		||||
        const config = await import('./utils/pds-detection').then(m => m.getNetworkConfigFromPdsEndpoint(resolved.pds));
 | 
			
		||||
        atprotoApi = config.pdsApi;
 | 
			
		||||
      } catch {
 | 
			
		||||
        // Fallback to configured PDS
 | 
			
		||||
        const adminConfig = getNetworkConfig(appConfig.atprotoPds);
 | 
			
		||||
      const atprotoApi = adminConfig.pdsApi;
 | 
			
		||||
        atprotoApi = adminConfig.pdsApi;
 | 
			
		||||
      }
 | 
			
		||||
      const collections = getCollectionNames(appConfig.collections.base);
 | 
			
		||||
      
 | 
			
		||||
      // Load lang:en records
 | 
			
		||||
 
 | 
			
		||||
@@ -204,25 +204,47 @@ class AtprotoOAuthService {
 | 
			
		||||
    return `${origin}/client-metadata.json`;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private detectPDSFromHandle(handle: string): string {
 | 
			
		||||
  private async detectPDSFromHandle(handle: string): Promise<string> {
 | 
			
		||||
    // Handle detection for OAuth PDS routing
 | 
			
		||||
    
 | 
			
		||||
    
 | 
			
		||||
    // Supported PDS hosts and their corresponding handles
 | 
			
		||||
    // Check if handle ends with known PDS domains first
 | 
			
		||||
    const pdsMapping = {
 | 
			
		||||
      'syu.is': 'https://syu.is',
 | 
			
		||||
      'bsky.social': 'https://bsky.social',
 | 
			
		||||
    };
 | 
			
		||||
    
 | 
			
		||||
    // Check if handle ends with known PDS domains
 | 
			
		||||
    for (const [domain, pdsUrl] of Object.entries(pdsMapping)) {
 | 
			
		||||
      if (handle.endsWith(`.${domain}`)) {
 | 
			
		||||
 | 
			
		||||
        // Using PDS for domain match
 | 
			
		||||
        return pdsUrl;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    // Default to bsky.social
 | 
			
		||||
    // For handles that don't match domain patterns, resolve via API
 | 
			
		||||
    try {
 | 
			
		||||
      // Try to resolve handle to get the actual PDS
 | 
			
		||||
      const endpoints = ['https://syu.is', 'https://bsky.social'];
 | 
			
		||||
      
 | 
			
		||||
      for (const endpoint of endpoints) {
 | 
			
		||||
        try {
 | 
			
		||||
          const response = await fetch(`${endpoint}/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`);
 | 
			
		||||
          if (response.ok) {
 | 
			
		||||
            const data = await response.json();
 | 
			
		||||
            if (data.did) {
 | 
			
		||||
              console.log('[OAuth Debug] Resolved handle via', endpoint, '- using that PDS');
 | 
			
		||||
              return endpoint;
 | 
			
		||||
            }
 | 
			
		||||
          }
 | 
			
		||||
        } catch (e) {
 | 
			
		||||
          continue;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    } catch (e) {
 | 
			
		||||
      console.log('[OAuth Debug] Handle resolution failed, using default');
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    // Default to bsky.social
 | 
			
		||||
    // Using default bsky.social
 | 
			
		||||
    return 'https://bsky.social';
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@@ -250,41 +272,53 @@ class AtprotoOAuthService {
 | 
			
		||||
 | 
			
		||||
      
 | 
			
		||||
      // Detect PDS based on handle
 | 
			
		||||
      const pdsUrl = this.detectPDSFromHandle(handle);
 | 
			
		||||
      const pdsUrl = await this.detectPDSFromHandle(handle);
 | 
			
		||||
      // Starting OAuth flow
 | 
			
		||||
 | 
			
		||||
      
 | 
			
		||||
      // Re-initialize OAuth client with correct PDS if needed
 | 
			
		||||
      if (pdsUrl !== 'https://bsky.social') {
 | 
			
		||||
      // Always re-initialize OAuth client with detected PDS
 | 
			
		||||
      // Re-initializing OAuth client
 | 
			
		||||
      
 | 
			
		||||
      // Clear existing client to force fresh initialization
 | 
			
		||||
      this.oauthClient = null;
 | 
			
		||||
      this.initializePromise = null;
 | 
			
		||||
      
 | 
			
		||||
      this.oauthClient = await BrowserOAuthClient.load({
 | 
			
		||||
        clientId: this.getClientId(),
 | 
			
		||||
        handleResolver: pdsUrl,
 | 
			
		||||
      });
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
      // OAuth client initialized
 | 
			
		||||
      
 | 
			
		||||
      // Start OAuth authorization flow
 | 
			
		||||
 | 
			
		||||
      
 | 
			
		||||
      try {
 | 
			
		||||
        const authUrl = await this.oauthClient.authorize(handle, {
 | 
			
		||||
        // Starting OAuth authorization
 | 
			
		||||
        
 | 
			
		||||
        // Try to authorize with DID instead of handle for syu.is PDS only
 | 
			
		||||
        let authTarget = handle;
 | 
			
		||||
        if (pdsUrl === 'https://syu.is') {
 | 
			
		||||
          try {
 | 
			
		||||
            const resolveResponse = await fetch(`${pdsUrl}/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`);
 | 
			
		||||
            if (resolveResponse.ok) {
 | 
			
		||||
              const resolveData = await resolveResponse.json();
 | 
			
		||||
              authTarget = resolveData.did;
 | 
			
		||||
              // Using DID for syu.is OAuth workaround
 | 
			
		||||
            }
 | 
			
		||||
          } catch (e) {
 | 
			
		||||
            // Could not resolve to DID, using handle
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        const authUrl = await this.oauthClient.authorize(authTarget, {
 | 
			
		||||
          scope: 'atproto transition:generic',
 | 
			
		||||
        });
 | 
			
		||||
        
 | 
			
		||||
 | 
			
		||||
        
 | 
			
		||||
        // Store some debug info before redirect
 | 
			
		||||
        sessionStorage.setItem('oauth_debug_pre_redirect', JSON.stringify({
 | 
			
		||||
          timestamp: new Date().toISOString(),
 | 
			
		||||
          handle: handle,
 | 
			
		||||
          authUrl: authUrl.toString(),
 | 
			
		||||
          currentUrl: window.location.href
 | 
			
		||||
        }));
 | 
			
		||||
        
 | 
			
		||||
        // Redirect to authorization server
 | 
			
		||||
 | 
			
		||||
        window.location.href = authUrl.toString();
 | 
			
		||||
      } catch (authorizeError) {
 | 
			
		||||
 | 
			
		||||
        // Authorization failed
 | 
			
		||||
        throw authorizeError;
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										135
									
								
								oauth/src/tests/console-test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										135
									
								
								oauth/src/tests/console-test.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,135 @@
 | 
			
		||||
// Simple console test for OAuth app
 | 
			
		||||
// This runs before 'npm run preview' to display test results
 | 
			
		||||
 | 
			
		||||
// Mock import.meta.env for Node.js environment
 | 
			
		||||
(global as any).import = {
 | 
			
		||||
  meta: {
 | 
			
		||||
    env: {
 | 
			
		||||
      VITE_ATPROTO_PDS: process.env.VITE_ATPROTO_PDS || 'syu.is',
 | 
			
		||||
      VITE_ADMIN_HANDLE: process.env.VITE_ADMIN_HANDLE || 'ai.syui.ai',
 | 
			
		||||
      VITE_AI_HANDLE: process.env.VITE_AI_HANDLE || 'ai.syui.ai',
 | 
			
		||||
      VITE_OAUTH_COLLECTION: process.env.VITE_OAUTH_COLLECTION || 'ai.syui.log',
 | 
			
		||||
      VITE_ATPROTO_HANDLE_LIST: process.env.VITE_ATPROTO_HANDLE_LIST || '["syui.ai", "ai.syui.ai", "yui.syui.ai"]',
 | 
			
		||||
      VITE_APP_HOST: process.env.VITE_APP_HOST || 'https://log.syui.ai'
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// Simple implementation of functions for testing
 | 
			
		||||
function detectPdsFromHandle(handle: string): string {
 | 
			
		||||
  if (handle.endsWith('.syu.is') || handle.endsWith('.syui.ai')) {
 | 
			
		||||
    return 'syu.is';
 | 
			
		||||
  }
 | 
			
		||||
  if (handle.endsWith('.bsky.social')) {
 | 
			
		||||
    return 'bsky.social';
 | 
			
		||||
  }
 | 
			
		||||
  // Default case - check if it's in the allowed list
 | 
			
		||||
  const allowedHandles = JSON.parse((global as any).import.meta.env.VITE_ATPROTO_HANDLE_LIST || '[]');
 | 
			
		||||
  if (allowedHandles.includes(handle)) {
 | 
			
		||||
    return (global as any).import.meta.env.VITE_ATPROTO_PDS || 'syu.is';
 | 
			
		||||
  }
 | 
			
		||||
  return 'bsky.social';
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function getNetworkConfig(pds: string) {
 | 
			
		||||
  switch (pds) {
 | 
			
		||||
    case 'bsky.social':
 | 
			
		||||
    case 'bsky.app':
 | 
			
		||||
      return {
 | 
			
		||||
        pdsApi: `https://${pds}`,
 | 
			
		||||
        plcApi: 'https://plc.directory',
 | 
			
		||||
        bskyApi: 'https://public.api.bsky.app',
 | 
			
		||||
        webUrl: 'https://bsky.app'
 | 
			
		||||
      };
 | 
			
		||||
    case 'syu.is':
 | 
			
		||||
      return {
 | 
			
		||||
        pdsApi: 'https://syu.is',
 | 
			
		||||
        plcApi: 'https://plc.syu.is',
 | 
			
		||||
        bskyApi: 'https://bsky.syu.is',
 | 
			
		||||
        webUrl: 'https://web.syu.is'
 | 
			
		||||
      };
 | 
			
		||||
    default:
 | 
			
		||||
      return {
 | 
			
		||||
        pdsApi: `https://${pds}`,
 | 
			
		||||
        plcApi: 'https://plc.directory',
 | 
			
		||||
        bskyApi: 'https://public.api.bsky.app',
 | 
			
		||||
        webUrl: 'https://bsky.app'
 | 
			
		||||
      };
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Main test execution
 | 
			
		||||
console.log('\n=== OAuth App Configuration Tests ===\n');
 | 
			
		||||
 | 
			
		||||
// Test 1: Handle input behavior
 | 
			
		||||
console.log('1. Handle Input → PDS Detection:');
 | 
			
		||||
const testHandles = [
 | 
			
		||||
  'syui.ai',
 | 
			
		||||
  'syui.syu.is', 
 | 
			
		||||
  'syui.syui.ai',
 | 
			
		||||
  'test.bsky.social',
 | 
			
		||||
  'unknown.handle'
 | 
			
		||||
];
 | 
			
		||||
 | 
			
		||||
testHandles.forEach(handle => {
 | 
			
		||||
  const pds = detectPdsFromHandle(handle);
 | 
			
		||||
  const config = getNetworkConfig(pds);
 | 
			
		||||
  console.log(`   ${handle.padEnd(20)} → PDS: ${pds.padEnd(12)} → API: ${config.pdsApi}`);
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
// Test 2: Environment variable impact
 | 
			
		||||
console.log('\n2. Current Environment Configuration:');
 | 
			
		||||
const env = (global as any).import.meta.env;
 | 
			
		||||
console.log(`   VITE_ATPROTO_PDS:      ${env.VITE_ATPROTO_PDS}`);
 | 
			
		||||
console.log(`   VITE_ADMIN_HANDLE:     ${env.VITE_ADMIN_HANDLE}`);
 | 
			
		||||
console.log(`   VITE_AI_HANDLE:        ${env.VITE_AI_HANDLE}`);
 | 
			
		||||
console.log(`   VITE_OAUTH_COLLECTION: ${env.VITE_OAUTH_COLLECTION}`);
 | 
			
		||||
console.log(`   VITE_ATPROTO_HANDLE_LIST: ${env.VITE_ATPROTO_HANDLE_LIST}`);
 | 
			
		||||
 | 
			
		||||
// Test 3: API endpoint generation
 | 
			
		||||
console.log('\n3. Generated API Endpoints:');
 | 
			
		||||
const adminPds = detectPdsFromHandle(env.VITE_ADMIN_HANDLE);
 | 
			
		||||
const adminConfig = getNetworkConfig(adminPds);
 | 
			
		||||
console.log(`   Admin PDS detection: ${env.VITE_ADMIN_HANDLE} → ${adminPds}`);
 | 
			
		||||
console.log(`   Admin API endpoints:`);
 | 
			
		||||
console.log(`     - PDS API:  ${adminConfig.pdsApi}`);
 | 
			
		||||
console.log(`     - Bsky API: ${adminConfig.bskyApi}`);
 | 
			
		||||
console.log(`     - Web URL:  ${adminConfig.webUrl}`);
 | 
			
		||||
 | 
			
		||||
// Test 4: Collection URLs
 | 
			
		||||
console.log('\n4. Collection API URLs:');
 | 
			
		||||
const baseCollection = env.VITE_OAUTH_COLLECTION;
 | 
			
		||||
console.log(`   User list: ${adminConfig.pdsApi}/xrpc/com.atproto.repo.listRecords?repo=${env.VITE_ADMIN_HANDLE}&collection=${baseCollection}.user`);
 | 
			
		||||
console.log(`   Chat:      ${adminConfig.pdsApi}/xrpc/com.atproto.repo.listRecords?repo=${env.VITE_ADMIN_HANDLE}&collection=${baseCollection}.chat`);
 | 
			
		||||
console.log(`   Lang:      ${adminConfig.pdsApi}/xrpc/com.atproto.repo.listRecords?repo=${env.VITE_ADMIN_HANDLE}&collection=${baseCollection}.chat.lang`);
 | 
			
		||||
console.log(`   Comment:   ${adminConfig.pdsApi}/xrpc/com.atproto.repo.listRecords?repo=${env.VITE_ADMIN_HANDLE}&collection=${baseCollection}.chat.comment`);
 | 
			
		||||
 | 
			
		||||
// Test 5: OAuth routing logic
 | 
			
		||||
console.log('\n5. OAuth Authorization Logic:');
 | 
			
		||||
const allowedHandles = JSON.parse(env.VITE_ATPROTO_HANDLE_LIST || '[]');
 | 
			
		||||
console.log(`   Allowed handles: ${JSON.stringify(allowedHandles)}`);
 | 
			
		||||
console.log(`   OAuth scenarios:`);
 | 
			
		||||
 | 
			
		||||
const oauthTestCases = [
 | 
			
		||||
  'syui.ai',         // Should use syu.is (in allowed list)
 | 
			
		||||
  'test.syu.is',     // Should use syu.is (*.syu.is pattern)
 | 
			
		||||
  'user.bsky.social' // Should use bsky.social (default)
 | 
			
		||||
];
 | 
			
		||||
 | 
			
		||||
oauthTestCases.forEach(handle => {
 | 
			
		||||
  const pds = detectPdsFromHandle(handle);
 | 
			
		||||
  const isAllowed = allowedHandles.includes(handle);
 | 
			
		||||
  const reason = handle.endsWith('.syu.is') ? '*.syu.is pattern' : 
 | 
			
		||||
                 isAllowed ? 'in allowed list' : 
 | 
			
		||||
                 'default';
 | 
			
		||||
  console.log(`     ${handle.padEnd(20)} → https://${pds}/oauth/authorize (${reason})`);
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
// Test 6: AI Profile Resolution
 | 
			
		||||
console.log('\n6. AI Profile Resolution:');
 | 
			
		||||
const aiPds = detectPdsFromHandle(env.VITE_AI_HANDLE);
 | 
			
		||||
const aiConfig = getNetworkConfig(aiPds);
 | 
			
		||||
console.log(`   AI Handle: ${env.VITE_AI_HANDLE} → PDS: ${aiPds}`);
 | 
			
		||||
console.log(`   AI Profile API: ${aiConfig.bskyApi}/xrpc/app.bsky.actor.getProfile?actor=${env.VITE_AI_HANDLE}`);
 | 
			
		||||
 | 
			
		||||
console.log('\n=== Tests Complete ===\n');
 | 
			
		||||
							
								
								
									
										141
									
								
								oauth/src/tests/oauth.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										141
									
								
								oauth/src/tests/oauth.test.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,141 @@
 | 
			
		||||
import { describe, it, expect, beforeEach } from 'vitest';
 | 
			
		||||
import { getAppConfig } from '../config/app';
 | 
			
		||||
import { detectPdsFromHandle, getNetworkConfig } from '../App';
 | 
			
		||||
 | 
			
		||||
// Test helper to mock environment variables
 | 
			
		||||
const mockEnv = (vars: Record<string, string>) => {
 | 
			
		||||
  Object.keys(vars).forEach(key => {
 | 
			
		||||
    (import.meta.env as any)[key] = vars[key];
 | 
			
		||||
  });
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
describe('OAuth App Tests', () => {
 | 
			
		||||
  describe('Handle Input Behavior', () => {
 | 
			
		||||
    it('should detect PDS for syui.ai (Bluesky)', () => {
 | 
			
		||||
      const pds = detectPdsFromHandle('syui.ai');
 | 
			
		||||
      expect(pds).toBe('bsky.social');
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should detect PDS for syui.syu.is (syu.is)', () => {
 | 
			
		||||
      const pds = detectPdsFromHandle('syui.syu.is');
 | 
			
		||||
      expect(pds).toBe('syu.is');
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should detect PDS for syui.syui.ai (syu.is)', () => {
 | 
			
		||||
      const pds = detectPdsFromHandle('syui.syui.ai');
 | 
			
		||||
      expect(pds).toBe('syu.is');
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should use network config for different PDS', () => {
 | 
			
		||||
      const bskyConfig = getNetworkConfig('bsky.social');
 | 
			
		||||
      expect(bskyConfig.pdsApi).toBe('https://bsky.social');
 | 
			
		||||
      expect(bskyConfig.bskyApi).toBe('https://public.api.bsky.app');
 | 
			
		||||
      expect(bskyConfig.webUrl).toBe('https://bsky.app');
 | 
			
		||||
 | 
			
		||||
      const syuisConfig = getNetworkConfig('syu.is');
 | 
			
		||||
      expect(syuisConfig.pdsApi).toBe('https://syu.is');
 | 
			
		||||
      expect(syuisConfig.bskyApi).toBe('https://bsky.syu.is');
 | 
			
		||||
      expect(syuisConfig.webUrl).toBe('https://web.syu.is');
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  describe('Environment Variable Changes', () => {
 | 
			
		||||
    beforeEach(() => {
 | 
			
		||||
      // Reset environment variables
 | 
			
		||||
      delete (import.meta.env as any).VITE_ATPROTO_PDS;
 | 
			
		||||
      delete (import.meta.env as any).VITE_ADMIN_HANDLE;
 | 
			
		||||
      delete (import.meta.env as any).VITE_AI_HANDLE;
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should use correct PDS for AI profile', () => {
 | 
			
		||||
      mockEnv({
 | 
			
		||||
        VITE_ATPROTO_PDS: 'syu.is',
 | 
			
		||||
        VITE_ADMIN_HANDLE: 'ai.syui.ai',
 | 
			
		||||
        VITE_AI_HANDLE: 'ai.syui.ai'
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      const config = getAppConfig();
 | 
			
		||||
      expect(config.atprotoPds).toBe('syu.is');
 | 
			
		||||
      expect(config.adminHandle).toBe('ai.syui.ai');
 | 
			
		||||
      expect(config.aiHandle).toBe('ai.syui.ai');
 | 
			
		||||
 | 
			
		||||
      // Network config should use syu.is endpoints
 | 
			
		||||
      const networkConfig = getNetworkConfig(config.atprotoPds);
 | 
			
		||||
      expect(networkConfig.bskyApi).toBe('https://bsky.syu.is');
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should construct correct API requests for admin userlist', () => {
 | 
			
		||||
      mockEnv({
 | 
			
		||||
        VITE_ATPROTO_PDS: 'syu.is',
 | 
			
		||||
        VITE_ADMIN_HANDLE: 'ai.syui.ai',
 | 
			
		||||
        VITE_OAUTH_COLLECTION: 'ai.syui.log'
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      const config = getAppConfig();
 | 
			
		||||
      const networkConfig = getNetworkConfig(config.atprotoPds);
 | 
			
		||||
      const userListUrl = `${networkConfig.pdsApi}/xrpc/com.atproto.repo.listRecords?repo=${config.adminHandle}&collection=${config.collections.base}.user`;
 | 
			
		||||
      
 | 
			
		||||
      expect(userListUrl).toBe('https://syu.is/xrpc/com.atproto.repo.listRecords?repo=ai.syui.ai&collection=ai.syui.log.user');
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  describe('OAuth Login Flow', () => {
 | 
			
		||||
    it('should use syu.is OAuth for handles in VITE_ATPROTO_HANDLE_LIST', () => {
 | 
			
		||||
      mockEnv({
 | 
			
		||||
        VITE_ATPROTO_HANDLE_LIST: '["syui.ai", "ai.syui.ai", "yui.syui.ai"]',
 | 
			
		||||
        VITE_ATPROTO_PDS: 'syu.is'
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      const config = getAppConfig();
 | 
			
		||||
      const handle = 'syui.ai';
 | 
			
		||||
      
 | 
			
		||||
      // Check if handle is in allowed list
 | 
			
		||||
      expect(config.allowedHandles).toContain(handle);
 | 
			
		||||
      
 | 
			
		||||
      // Should use configured PDS for OAuth
 | 
			
		||||
      const expectedAuthUrl = `https://${config.atprotoPds}/oauth/authorize`;
 | 
			
		||||
      expect(expectedAuthUrl).toContain('syu.is');
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should use syu.is OAuth for *.syu.is handles', () => {
 | 
			
		||||
      const handle = 'test.syu.is';
 | 
			
		||||
      const pds = detectPdsFromHandle(handle);
 | 
			
		||||
      expect(pds).toBe('syu.is');
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
// Terminal display test output
 | 
			
		||||
export function runTerminalTests() {
 | 
			
		||||
  console.log('\n=== OAuth App Tests ===\n');
 | 
			
		||||
  
 | 
			
		||||
  // Test 1: Handle input behavior
 | 
			
		||||
  console.log('1. Handle Input Detection:');
 | 
			
		||||
  const handles = ['syui.ai', 'syui.syu.is', 'syui.syui.ai'];
 | 
			
		||||
  handles.forEach(handle => {
 | 
			
		||||
    const pds = detectPdsFromHandle(handle);
 | 
			
		||||
    console.log(`   ${handle} → PDS: ${pds}`);
 | 
			
		||||
  });
 | 
			
		||||
  
 | 
			
		||||
  // Test 2: Environment variable impact
 | 
			
		||||
  console.log('\n2. Environment Variables:');
 | 
			
		||||
  const config = getAppConfig();
 | 
			
		||||
  console.log(`   VITE_ATPROTO_PDS: ${config.atprotoPds}`);
 | 
			
		||||
  console.log(`   VITE_ADMIN_HANDLE: ${config.adminHandle}`);
 | 
			
		||||
  console.log(`   VITE_AI_HANDLE: ${config.aiHandle}`);
 | 
			
		||||
  console.log(`   VITE_OAUTH_COLLECTION: ${config.collections.base}`);
 | 
			
		||||
  
 | 
			
		||||
  // Test 3: API endpoints
 | 
			
		||||
  console.log('\n3. API Endpoints:');
 | 
			
		||||
  const networkConfig = getNetworkConfig(config.atprotoPds);
 | 
			
		||||
  console.log(`   Admin PDS API: ${networkConfig.pdsApi}`);
 | 
			
		||||
  console.log(`   Admin Bsky API: ${networkConfig.bskyApi}`);
 | 
			
		||||
  console.log(`   User list URL: ${networkConfig.pdsApi}/xrpc/com.atproto.repo.listRecords?repo=${config.adminHandle}&collection=${config.collections.base}.user`);
 | 
			
		||||
  
 | 
			
		||||
  // Test 4: OAuth routing
 | 
			
		||||
  console.log('\n4. OAuth Routing:');
 | 
			
		||||
  console.log(`   Allowed handles: ${JSON.stringify(config.allowedHandles)}`);
 | 
			
		||||
  console.log(`   OAuth endpoint: https://${config.atprotoPds}/oauth/authorize`);
 | 
			
		||||
  
 | 
			
		||||
  console.log('\n=== End Tests ===\n');
 | 
			
		||||
}
 | 
			
		||||
@@ -1,5 +1,7 @@
 | 
			
		||||
// PDS Detection and API URL mapping utilities
 | 
			
		||||
 | 
			
		||||
import { isValidDid, isValidHandle } from './validation';
 | 
			
		||||
 | 
			
		||||
export interface NetworkConfig {
 | 
			
		||||
  pdsApi: string;
 | 
			
		||||
  plcApi: string;
 | 
			
		||||
@@ -9,12 +11,33 @@ export interface NetworkConfig {
 | 
			
		||||
 | 
			
		||||
// Detect PDS from handle
 | 
			
		||||
export function detectPdsFromHandle(handle: string): string {
 | 
			
		||||
  if (handle.endsWith('.syu.is')) {
 | 
			
		||||
  // Get allowed handles from environment
 | 
			
		||||
  const allowedHandlesStr = import.meta.env.VITE_ATPROTO_HANDLE_LIST || '[]';
 | 
			
		||||
  let allowedHandles: string[] = [];
 | 
			
		||||
  try {
 | 
			
		||||
    allowedHandles = JSON.parse(allowedHandlesStr);
 | 
			
		||||
  } catch {
 | 
			
		||||
    allowedHandles = [];
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  // Get configured PDS from environment
 | 
			
		||||
  const configuredPds = import.meta.env.VITE_ATPROTO_PDS || 'syu.is';
 | 
			
		||||
  
 | 
			
		||||
  // Check if handle is in allowed list
 | 
			
		||||
  if (allowedHandles.includes(handle)) {
 | 
			
		||||
    return configuredPds;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  // Check if handle ends with .syu.is or .syui.ai
 | 
			
		||||
  if (handle.endsWith('.syu.is') || handle.endsWith('.syui.ai')) {
 | 
			
		||||
    return 'syu.is';
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  // Check if handle ends with .bsky.social or .bsky.app
 | 
			
		||||
  if (handle.endsWith('.bsky.social') || handle.endsWith('.bsky.app')) {
 | 
			
		||||
    return 'bsky.social';
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  // Default to Bluesky for unknown domains
 | 
			
		||||
  return 'bsky.social';
 | 
			
		||||
}
 | 
			
		||||
@@ -74,8 +97,13 @@ export function getApiUrlForUser(handle: string): string {
 | 
			
		||||
  return config.bskyApi;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Resolve handle/DID to actual PDS endpoint using com.atproto.repo.describeRepo
 | 
			
		||||
// Resolve handle/DID to actual PDS endpoint using PLC API first
 | 
			
		||||
export async function resolvePdsFromRepo(handleOrDid: string): Promise<{ pds: string; did: string; handle: string }> {
 | 
			
		||||
  // Validate input
 | 
			
		||||
  if (!handleOrDid || (!isValidDid(handleOrDid) && !isValidHandle(handleOrDid))) {
 | 
			
		||||
    throw new Error(`Invalid identifier: ${handleOrDid}`);
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  let targetDid = handleOrDid;
 | 
			
		||||
  let targetHandle = handleOrDid;
 | 
			
		||||
  
 | 
			
		||||
@@ -83,7 +111,7 @@ export async function resolvePdsFromRepo(handleOrDid: string): Promise<{ pds: st
 | 
			
		||||
  if (!handleOrDid.startsWith('did:')) {
 | 
			
		||||
    try {
 | 
			
		||||
      // Try multiple endpoints for handle resolution
 | 
			
		||||
      const resolveEndpoints = ['https://public.api.bsky.app', 'https://bsky.syu.is'];
 | 
			
		||||
      const resolveEndpoints = ['https://public.api.bsky.app', 'https://bsky.syu.is', 'https://syu.is'];
 | 
			
		||||
      let resolved = false;
 | 
			
		||||
      
 | 
			
		||||
      for (const endpoint of resolveEndpoints) {
 | 
			
		||||
@@ -108,7 +136,34 @@ export async function resolvePdsFromRepo(handleOrDid: string): Promise<{ pds: st
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  // Now use com.atproto.repo.describeRepo to get PDS from known PDS endpoints
 | 
			
		||||
  // First, try PLC API to get the authoritative DID document
 | 
			
		||||
  const plcApis = ['https://plc.directory', 'https://plc.syu.is'];
 | 
			
		||||
  
 | 
			
		||||
  for (const plcApi of plcApis) {
 | 
			
		||||
    try {
 | 
			
		||||
      const plcResponse = await fetch(`${plcApi}/${targetDid}`);
 | 
			
		||||
      if (plcResponse.ok) {
 | 
			
		||||
        const didDocument = await plcResponse.json();
 | 
			
		||||
        
 | 
			
		||||
        // Find PDS service in DID document
 | 
			
		||||
        const pdsService = didDocument.service?.find((s: any) => 
 | 
			
		||||
          s.id === '#atproto_pds' || s.type === 'AtprotoPersonalDataServer'
 | 
			
		||||
        );
 | 
			
		||||
        
 | 
			
		||||
        if (pdsService && pdsService.serviceEndpoint) {
 | 
			
		||||
          return {
 | 
			
		||||
            pds: pdsService.serviceEndpoint,
 | 
			
		||||
            did: targetDid,
 | 
			
		||||
            handle: targetHandle
 | 
			
		||||
          };
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      continue;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  // Fallback: use com.atproto.repo.describeRepo to get PDS from known PDS endpoints
 | 
			
		||||
  const pdsEndpoints = ['https://bsky.social', 'https://syu.is'];
 | 
			
		||||
  
 | 
			
		||||
  for (const pdsEndpoint of pdsEndpoints) {
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										21
									
								
								oauth/src/utils/validation.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								oauth/src/utils/validation.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,21 @@
 | 
			
		||||
// Validation utilities for atproto identifiers
 | 
			
		||||
 | 
			
		||||
export function isValidDid(did: string): boolean {
 | 
			
		||||
  if (!did || typeof did !== 'string') return false;
 | 
			
		||||
  
 | 
			
		||||
  // Basic DID format: did:method:identifier
 | 
			
		||||
  const didRegex = /^did:[a-z]+:[a-zA-Z0-9._%-]+$/;
 | 
			
		||||
  return didRegex.test(did);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function isValidHandle(handle: string): boolean {
 | 
			
		||||
  if (!handle || typeof handle !== 'string') return false;
 | 
			
		||||
  
 | 
			
		||||
  // Basic handle format: subdomain.domain.tld
 | 
			
		||||
  const handleRegex = /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/;
 | 
			
		||||
  return handleRegex.test(handle);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function isValidAtprotoIdentifier(identifier: string): boolean {
 | 
			
		||||
  return isValidDid(identifier) || isValidHandle(identifier);
 | 
			
		||||
}
 | 
			
		||||
@@ -6,7 +6,7 @@ function _env() {
 | 
			
		||||
	oauth=$d/oauth
 | 
			
		||||
	myblog=$d/my-blog
 | 
			
		||||
	port=4173
 | 
			
		||||
	source $oauth/.env.production
 | 
			
		||||
	#source $oauth/.env.production
 | 
			
		||||
	case $OSTYPE in
 | 
			
		||||
		darwin*)
 | 
			
		||||
			export NVM_DIR="$HOME/.nvm"
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										14
									
								
								src/lib.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								src/lib.rs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,14 @@
 | 
			
		||||
// Export modules for testing
 | 
			
		||||
pub mod ai;
 | 
			
		||||
pub mod analyzer;
 | 
			
		||||
pub mod atproto;
 | 
			
		||||
pub mod commands;
 | 
			
		||||
pub mod config;
 | 
			
		||||
pub mod doc_generator;
 | 
			
		||||
pub mod generator;
 | 
			
		||||
pub mod markdown;
 | 
			
		||||
pub mod mcp;
 | 
			
		||||
pub mod oauth;
 | 
			
		||||
// pub mod ollama_proxy; // Temporarily disabled - uses actix-web instead of axum
 | 
			
		||||
pub mod template;
 | 
			
		||||
pub mod translator;
 | 
			
		||||
@@ -2,6 +2,7 @@ use anyhow::Result;
 | 
			
		||||
use regex::Regex;
 | 
			
		||||
use super::MarkdownSection;
 | 
			
		||||
 | 
			
		||||
#[derive(Clone)]
 | 
			
		||||
pub struct MarkdownParser {
 | 
			
		||||
    _code_block_regex: Regex,
 | 
			
		||||
    header_regex: Regex,
 | 
			
		||||
 
 | 
			
		||||
@@ -42,9 +42,9 @@ pub enum MarkdownSection {
 | 
			
		||||
 | 
			
		||||
pub trait Translator {
 | 
			
		||||
    #[allow(dead_code)]
 | 
			
		||||
    async fn translate(&self, content: &str, config: &TranslationConfig) -> Result<String>;
 | 
			
		||||
    async fn translate_markdown(&self, content: &str, config: &TranslationConfig) -> Result<String>;
 | 
			
		||||
    async fn translate_sections(&self, sections: Vec<MarkdownSection>, config: &TranslationConfig) -> Result<Vec<MarkdownSection>>;
 | 
			
		||||
    fn translate(&self, content: &str, config: &TranslationConfig) -> impl std::future::Future<Output = Result<String>> + Send;
 | 
			
		||||
    fn translate_markdown(&self, content: &str, config: &TranslationConfig) -> impl std::future::Future<Output = Result<String>> + Send;
 | 
			
		||||
    fn translate_sections(&self, sections: Vec<MarkdownSection>, config: &TranslationConfig) -> impl std::future::Future<Output = Result<Vec<MarkdownSection>>> + Send;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#[allow(dead_code)]
 | 
			
		||||
@@ -67,6 +67,7 @@ pub struct TranslationMetrics {
 | 
			
		||||
    pub sections_preserved: usize,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#[derive(Clone)]
 | 
			
		||||
pub struct LanguageMapping {
 | 
			
		||||
    pub mappings: HashMap<String, LanguageInfo>,
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -5,6 +5,7 @@ use std::time::Instant;
 | 
			
		||||
use super::*;
 | 
			
		||||
use crate::translator::markdown_parser::MarkdownParser;
 | 
			
		||||
 | 
			
		||||
#[derive(Clone)]
 | 
			
		||||
pub struct OllamaTranslator {
 | 
			
		||||
    client: Client,
 | 
			
		||||
    language_mapping: LanguageMapping,
 | 
			
		||||
@@ -129,12 +130,15 @@ Translation:"#,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl Translator for OllamaTranslator {
 | 
			
		||||
    async fn translate(&self, content: &str, config: &TranslationConfig) -> Result<String> {
 | 
			
		||||
    fn translate(&self, content: &str, config: &TranslationConfig) -> impl std::future::Future<Output = Result<String>> + Send {
 | 
			
		||||
        async move {
 | 
			
		||||
            let prompt = self.build_translation_prompt(content, config)?;
 | 
			
		||||
            self.call_ollama(&prompt, config).await
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    async fn translate_markdown(&self, content: &str, config: &TranslationConfig) -> Result<String> {
 | 
			
		||||
    fn translate_markdown(&self, content: &str, config: &TranslationConfig) -> impl std::future::Future<Output = Result<String>> + Send {
 | 
			
		||||
        async move {
 | 
			
		||||
            println!("🔄 Parsing markdown content...");
 | 
			
		||||
            let sections = self.parser.parse_markdown(content)?;
 | 
			
		||||
            
 | 
			
		||||
@@ -146,8 +150,21 @@ impl Translator for OllamaTranslator {
 | 
			
		||||
            
 | 
			
		||||
            Ok(result)
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    fn translate_sections(&self, sections: Vec<MarkdownSection>, config: &TranslationConfig) -> impl std::future::Future<Output = Result<Vec<MarkdownSection>>> + Send {
 | 
			
		||||
        let config = config.clone();
 | 
			
		||||
        let client = self.client.clone();
 | 
			
		||||
        let parser = self.parser.clone();
 | 
			
		||||
        let language_mapping = self.language_mapping.clone();
 | 
			
		||||
        
 | 
			
		||||
        async move {
 | 
			
		||||
            let translator = OllamaTranslator {
 | 
			
		||||
                client,
 | 
			
		||||
                language_mapping,
 | 
			
		||||
                parser,
 | 
			
		||||
            };
 | 
			
		||||
            
 | 
			
		||||
    async fn translate_sections(&self, sections: Vec<MarkdownSection>, config: &TranslationConfig) -> Result<Vec<MarkdownSection>> {
 | 
			
		||||
            let mut translated_sections = Vec::new();
 | 
			
		||||
            let start_time = Instant::now();
 | 
			
		||||
        
 | 
			
		||||
@@ -169,8 +186,8 @@ impl Translator for OllamaTranslator {
 | 
			
		||||
                            section // Preserve links
 | 
			
		||||
                        } else {
 | 
			
		||||
                            // Translate link text only
 | 
			
		||||
                        let prompt = self.build_section_translation_prompt(&MarkdownSection::Text(text.clone()), config)?;
 | 
			
		||||
                        let translated_text = self.call_ollama(&prompt, config).await?;
 | 
			
		||||
                            let prompt = translator.build_section_translation_prompt(&MarkdownSection::Text(text.clone()), &config)?;
 | 
			
		||||
                            let translated_text = translator.call_ollama(&prompt, &config).await?;
 | 
			
		||||
                            MarkdownSection::Link(translated_text.trim().to_string(), url.clone())
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
@@ -180,15 +197,15 @@ impl Translator for OllamaTranslator {
 | 
			
		||||
                    }
 | 
			
		||||
                    MarkdownSection::Table(content) => {
 | 
			
		||||
                        println!("    📊 Translating table content");
 | 
			
		||||
                    let prompt = self.build_section_translation_prompt(&MarkdownSection::Text(content.clone()), config)?;
 | 
			
		||||
                    let translated_content = self.call_ollama(&prompt, config).await?;
 | 
			
		||||
                        let prompt = translator.build_section_translation_prompt(&MarkdownSection::Text(content.clone()), &config)?;
 | 
			
		||||
                        let translated_content = translator.call_ollama(&prompt, &config).await?;
 | 
			
		||||
                        MarkdownSection::Table(translated_content.trim().to_string())
 | 
			
		||||
                    }
 | 
			
		||||
                    _ => {
 | 
			
		||||
                        // Translate text sections
 | 
			
		||||
                        println!("    🔤 Translating text");
 | 
			
		||||
                    let prompt = self.build_section_translation_prompt(§ion, config)?;
 | 
			
		||||
                    let translated_text = self.call_ollama(&prompt, config).await?;
 | 
			
		||||
                        let prompt = translator.build_section_translation_prompt(§ion, &config)?;
 | 
			
		||||
                        let translated_text = translator.call_ollama(&prompt, &config).await?;
 | 
			
		||||
                        
 | 
			
		||||
                        match section {
 | 
			
		||||
                            MarkdownSection::Text(_) => MarkdownSection::Text(translated_text.trim().to_string()),
 | 
			
		||||
@@ -212,3 +229,4 @@ impl Translator for OllamaTranslator {
 | 
			
		||||
            Ok(translated_sections)
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
		Reference in New Issue
	
	Block a user