mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-25 09:34:38 +02:00
chore: archive inactive projects to apps-archived/
Move inactive projects out of active workspace: - bauntown (community website) - maerchenzauber (AI story generation) - memoro (voice memo app) - news (news aggregation) - nutriphi (nutrition tracking) - reader (reading app) - uload (URL shortener) - wisekeep (AI wisdom extraction) Update CLAUDE.md documentation: - Add presi to active projects - Document archived projects section - Update workspace configuration Archived apps can be re-activated by moving back to apps/ 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
b97149ac12
commit
61d181fbc2
3148 changed files with 437 additions and 46640 deletions
|
|
@ -0,0 +1 @@
|
|||
v2.31.4
|
||||
|
|
@ -0,0 +1 @@
|
|||
v2.177.0
|
||||
|
|
@ -0,0 +1 @@
|
|||
postgresql://postgres.tiecnhktvovcqsrnunko:[YOUR-PASSWORD]@aws-0-eu-central-1.pooler.supabase.com:6543/postgres
|
||||
|
|
@ -0,0 +1 @@
|
|||
17.4.1.054
|
||||
|
|
@ -0,0 +1 @@
|
|||
tiecnhktvovcqsrnunko
|
||||
|
|
@ -0,0 +1 @@
|
|||
v12.2.3
|
||||
|
|
@ -0,0 +1 @@
|
|||
custom-metadata
|
||||
|
|
@ -0,0 +1,299 @@
|
|||
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';
|
||||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
|
||||
import { Readability } from 'https://esm.sh/@mozilla/readability@0.5.0';
|
||||
import { DOMParser } from 'https://deno.land/x/deno_dom@v0.1.38/deno-dom-wasm.ts';
|
||||
|
||||
const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
||||
};
|
||||
|
||||
serve(async (req) => {
|
||||
if (req.method === 'OPTIONS') {
|
||||
return new Response('ok', { headers: corsHeaders });
|
||||
}
|
||||
|
||||
try {
|
||||
const authHeader = req.headers.get('Authorization');
|
||||
if (!authHeader) {
|
||||
return new Response(JSON.stringify({ error: 'No authorization header' }), {
|
||||
status: 401,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const supabaseClient = createClient(
|
||||
Deno.env.get('SUPABASE_URL') ?? '',
|
||||
Deno.env.get('SUPABASE_ANON_KEY') ?? '',
|
||||
{
|
||||
global: {
|
||||
headers: { Authorization: authHeader },
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
error: authError,
|
||||
} = await supabaseClient.auth.getUser();
|
||||
|
||||
if (authError || !user) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
status: 401,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const { url } = await req.json();
|
||||
|
||||
if (!url) {
|
||||
return new Response(JSON.stringify({ error: 'URL is required' }), {
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Validate URL
|
||||
let validatedUrl;
|
||||
try {
|
||||
validatedUrl = new URL(url);
|
||||
if (!['http:', 'https:'].includes(validatedUrl.protocol)) {
|
||||
throw new Error('Invalid protocol');
|
||||
}
|
||||
} catch {
|
||||
return new Response(JSON.stringify({ error: 'Invalid URL format' }), {
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Use ScrapingBee API - requires API key in environment
|
||||
const scrapingBeeApiKey = Deno.env.get('SCRAPINGBEE_API_KEY');
|
||||
if (!scrapingBeeApiKey) {
|
||||
console.error('SCRAPINGBEE_API_KEY not configured, falling back to direct fetch');
|
||||
// Fallback to direct fetch if API key not configured
|
||||
return fallbackExtraction(validatedUrl, corsHeaders);
|
||||
}
|
||||
|
||||
// ScrapingBee API request
|
||||
const scrapingBeeUrl = new URL('https://app.scrapingbee.com/api/v1/');
|
||||
scrapingBeeUrl.searchParams.append('api_key', scrapingBeeApiKey);
|
||||
scrapingBeeUrl.searchParams.append('url', validatedUrl.toString());
|
||||
scrapingBeeUrl.searchParams.append('render_js', 'true'); // Render JavaScript
|
||||
scrapingBeeUrl.searchParams.append('wait', '3000'); // Wait 3s for content to load
|
||||
scrapingBeeUrl.searchParams.append('block_ads', 'true'); // Block ads
|
||||
scrapingBeeUrl.searchParams.append('stealth_mode', 'true'); // Bypass anti-bot measures
|
||||
|
||||
// Custom JavaScript to remove cookie banners
|
||||
const jsScript = `
|
||||
// Remove cookie banners
|
||||
const selectors = [
|
||||
'[class*="cookie"]', '[id*="cookie"]',
|
||||
'[class*="consent"]', '[id*="consent"]',
|
||||
'[class*="gdpr"]', '[id*="gdpr"]',
|
||||
'.privacy-banner', '#privacy-banner'
|
||||
];
|
||||
selectors.forEach(sel => {
|
||||
document.querySelectorAll(sel).forEach(el => {
|
||||
if (el.textContent.toLowerCase().includes('cookie') ||
|
||||
el.textContent.toLowerCase().includes('consent')) {
|
||||
el.remove();
|
||||
}
|
||||
});
|
||||
});
|
||||
// Click accept buttons if needed
|
||||
const acceptButtons = document.querySelectorAll('button, a');
|
||||
acceptButtons.forEach(btn => {
|
||||
const text = btn.textContent.toLowerCase();
|
||||
if ((text.includes('accept') || text.includes('akzeptieren')) &&
|
||||
(text.includes('cookie') || text.includes('all'))) {
|
||||
btn.click();
|
||||
}
|
||||
});
|
||||
`;
|
||||
scrapingBeeUrl.searchParams.append('js_scenario', btoa(jsScript));
|
||||
|
||||
const response = await fetch(scrapingBeeUrl.toString());
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('ScrapingBee error:', response.status, await response.text());
|
||||
return fallbackExtraction(validatedUrl, corsHeaders);
|
||||
}
|
||||
|
||||
const html = await response.text();
|
||||
|
||||
// Parse and extract content
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||
|
||||
if (!doc) {
|
||||
return new Response(JSON.stringify({ error: 'Failed to parse HTML' }), {
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Use Readability to extract article content
|
||||
const reader = new Readability(doc);
|
||||
const article = reader.parse();
|
||||
|
||||
if (!article || article.textContent.length < 200) {
|
||||
// Try manual extraction
|
||||
return manualExtraction(doc, validatedUrl, corsHeaders);
|
||||
}
|
||||
|
||||
// Extract metadata
|
||||
const metadata = extractMetadata(doc);
|
||||
const tags = generateTags(metadata.keywords);
|
||||
|
||||
// Clean content
|
||||
const cleanedContent = article.textContent
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
title: article.title || 'Untitled',
|
||||
content: cleanedContent,
|
||||
excerpt: article.excerpt || metadata.description || '',
|
||||
source: validatedUrl.toString(),
|
||||
domain: validatedUrl.hostname,
|
||||
author: article.byline || metadata.author || '',
|
||||
publishDate: metadata.publishDate || '',
|
||||
wordCount: cleanedContent.split(/\s+/).length,
|
||||
readingTime: Math.ceil(cleanedContent.split(/\s+/).length / 200),
|
||||
tags,
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Extract URL error:', error);
|
||||
return new Response(JSON.stringify({ error: error.message || 'Internal server error' }), {
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Helper functions
|
||||
async function fallbackExtraction(url: URL, corsHeaders: any) {
|
||||
// Original extraction logic as fallback
|
||||
const response = await fetch(url.toString(), {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return new Response(JSON.stringify({ error: `Failed to fetch URL: ${response.status}` }), {
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const html = await response.text();
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||
|
||||
return manualExtraction(doc!, url, corsHeaders);
|
||||
}
|
||||
|
||||
function manualExtraction(doc: any, url: URL, corsHeaders: any) {
|
||||
let content = '';
|
||||
let title = '';
|
||||
|
||||
// Find title
|
||||
const titleElement = doc.querySelector('h1') || doc.querySelector('title');
|
||||
if (titleElement) {
|
||||
title = titleElement.textContent?.trim() || '';
|
||||
}
|
||||
|
||||
// Find content
|
||||
const contentSelectors = [
|
||||
'main',
|
||||
'article',
|
||||
'[role="main"]',
|
||||
'.content',
|
||||
'#content',
|
||||
'.post',
|
||||
'.entry-content',
|
||||
'.article-content',
|
||||
];
|
||||
|
||||
for (const selector of contentSelectors) {
|
||||
const element = doc.querySelector(selector);
|
||||
if (element && element.textContent) {
|
||||
content = element.textContent.trim();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Get paragraphs
|
||||
if (!content || content.length < 200) {
|
||||
const paragraphs = doc.querySelectorAll('p');
|
||||
const texts: string[] = [];
|
||||
paragraphs.forEach((p: any) => {
|
||||
const text = p.textContent?.trim();
|
||||
if (text && text.length > 50) {
|
||||
texts.push(text);
|
||||
}
|
||||
});
|
||||
content = texts.join('\n\n');
|
||||
}
|
||||
|
||||
if (!content || content.length < 100) {
|
||||
return new Response(JSON.stringify({ error: 'Could not extract meaningful content' }), {
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
title: title || 'Untitled',
|
||||
content: content,
|
||||
excerpt: content.substring(0, 200),
|
||||
source: url.toString(),
|
||||
domain: url.hostname,
|
||||
author: '',
|
||||
publishDate: '',
|
||||
wordCount: content.split(/\s+/).length,
|
||||
readingTime: Math.ceil(content.split(/\s+/).length / 200),
|
||||
tags: [],
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function extractMetadata(doc: any) {
|
||||
const metadata: Record<string, string> = {};
|
||||
const metaTags = doc.querySelectorAll('meta');
|
||||
|
||||
metaTags.forEach((meta: any) => {
|
||||
const name = meta.getAttribute('name') || meta.getAttribute('property');
|
||||
const content = meta.getAttribute('content');
|
||||
|
||||
if (name && content) {
|
||||
if (name.includes('author')) metadata.author = content;
|
||||
if (name.includes('description')) metadata.description = content;
|
||||
if (name.includes('keywords')) metadata.keywords = content;
|
||||
if (name.includes('publish')) metadata.publishDate = content;
|
||||
}
|
||||
});
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
function generateTags(keywords?: string): string[] {
|
||||
if (!keywords) return [];
|
||||
return keywords
|
||||
.split(',')
|
||||
.map((k) => k.trim())
|
||||
.filter((k) => k.length > 0)
|
||||
.slice(0, 5);
|
||||
}
|
||||
|
|
@ -0,0 +1,332 @@
|
|||
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';
|
||||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
|
||||
import { Readability } from 'https://esm.sh/@mozilla/readability@0.5.0';
|
||||
import { DOMParser } from 'https://deno.land/x/deno_dom@v0.1.38/deno-dom-wasm.ts';
|
||||
|
||||
const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
||||
};
|
||||
|
||||
serve(async (req) => {
|
||||
if (req.method === 'OPTIONS') {
|
||||
return new Response('ok', { headers: corsHeaders });
|
||||
}
|
||||
|
||||
try {
|
||||
const authHeader = req.headers.get('Authorization');
|
||||
if (!authHeader) {
|
||||
return new Response(JSON.stringify({ error: 'No authorization header' }), {
|
||||
status: 401,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const supabaseClient = createClient(
|
||||
Deno.env.get('SUPABASE_URL') ?? '',
|
||||
Deno.env.get('SUPABASE_ANON_KEY') ?? '',
|
||||
{
|
||||
global: {
|
||||
headers: { Authorization: authHeader },
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
error: authError,
|
||||
} = await supabaseClient.auth.getUser();
|
||||
|
||||
if (authError || !user) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
status: 401,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const { url } = await req.json();
|
||||
|
||||
if (!url) {
|
||||
return new Response(JSON.stringify({ error: 'URL is required' }), {
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Validate URL
|
||||
let validatedUrl;
|
||||
try {
|
||||
validatedUrl = new URL(url);
|
||||
if (!['http:', 'https:'].includes(validatedUrl.protocol)) {
|
||||
throw new Error('Invalid protocol');
|
||||
}
|
||||
} catch {
|
||||
return new Response(JSON.stringify({ error: 'Invalid URL format' }), {
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Try Jina.ai Reader API for better extraction
|
||||
try {
|
||||
const jinaUrl = `https://r.jina.ai/${validatedUrl.toString()}`;
|
||||
const jinaResponse = await fetch(jinaUrl, {
|
||||
headers: {
|
||||
Accept: 'text/plain',
|
||||
'X-Return-Format': 'text',
|
||||
},
|
||||
signal: AbortSignal.timeout(15000), // 15 second timeout
|
||||
});
|
||||
|
||||
if (jinaResponse.ok) {
|
||||
const content = await jinaResponse.text();
|
||||
|
||||
// Check if we got meaningful content (not just cookie banner)
|
||||
if (
|
||||
content &&
|
||||
content.length > 500 &&
|
||||
!content.toLowerCase().includes('cookies zustimmen') &&
|
||||
!content.toLowerCase().includes('cookie banner')
|
||||
) {
|
||||
// Extract title from content (usually first line)
|
||||
const lines = content.split('\n').filter((line) => line.trim());
|
||||
const title = lines[0] || 'Untitled';
|
||||
const actualContent = lines.slice(1).join('\n\n');
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
title: title.substring(0, 200), // Limit title length
|
||||
content: actualContent || content,
|
||||
excerpt: actualContent.substring(0, 200),
|
||||
source: validatedUrl.toString(),
|
||||
domain: validatedUrl.hostname,
|
||||
author: '',
|
||||
publishDate: '',
|
||||
wordCount: content.split(/\s+/).length,
|
||||
readingTime: Math.ceil(content.split(/\s+/).length / 200),
|
||||
tags: [],
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (jinaError) {
|
||||
console.log('Jina.ai extraction failed:', jinaError);
|
||||
}
|
||||
|
||||
// Fallback to direct webpage fetch
|
||||
const response = await fetch(validatedUrl.toString(), {
|
||||
headers: {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
|
||||
'Accept-Language': 'de-DE,de;q=0.9,en;q=0.8',
|
||||
},
|
||||
signal: AbortSignal.timeout(15000), // 15 second timeout
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: `Failed to fetch URL: ${response.status} ${response.statusText}` }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const html = await response.text();
|
||||
|
||||
// Parse HTML and extract content
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||
|
||||
if (!doc) {
|
||||
return new Response(JSON.stringify({ error: 'Failed to parse HTML' }), {
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Try to remove common cookie banners and overlays
|
||||
const elementsToRemove = [
|
||||
// Cookie banners
|
||||
'[class*="cookie"]',
|
||||
'[id*="cookie"]',
|
||||
'[class*="consent"]',
|
||||
'[id*="consent"]',
|
||||
'[class*="gdpr"]',
|
||||
'[id*="gdpr"]',
|
||||
'[class*="privacy"]',
|
||||
'[id*="privacy-banner"]',
|
||||
// Overlays
|
||||
'[class*="overlay"]',
|
||||
'[class*="modal"]',
|
||||
'[class*="popup"]',
|
||||
// Specific patterns
|
||||
'.cookie-banner',
|
||||
'#cookie-banner',
|
||||
'.privacy-banner',
|
||||
'#privacy-banner',
|
||||
];
|
||||
|
||||
elementsToRemove.forEach((selector) => {
|
||||
try {
|
||||
const elements = doc.querySelectorAll(selector);
|
||||
elements.forEach((el: any) => {
|
||||
// Only remove if it looks like a banner/overlay (not main content)
|
||||
const text = el.textContent || '';
|
||||
if (
|
||||
text.toLowerCase().includes('cookie') ||
|
||||
text.toLowerCase().includes('datenschutz') ||
|
||||
text.toLowerCase().includes('privacy') ||
|
||||
text.toLowerCase().includes('consent')
|
||||
) {
|
||||
el.remove();
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
// Ignore selector errors
|
||||
}
|
||||
});
|
||||
|
||||
// Use Readability to extract article content
|
||||
const reader = new Readability(doc);
|
||||
const article = reader.parse();
|
||||
|
||||
if (!article) {
|
||||
// Fallback: Try to extract content manually
|
||||
let content = '';
|
||||
let title = '';
|
||||
|
||||
// Try to find title
|
||||
const titleElement = doc.querySelector('h1') || doc.querySelector('title');
|
||||
if (titleElement) {
|
||||
title = titleElement.textContent?.trim() || '';
|
||||
}
|
||||
|
||||
// Try to find main content areas
|
||||
const contentSelectors = [
|
||||
'main',
|
||||
'article',
|
||||
'[role="main"]',
|
||||
'.content',
|
||||
'#content',
|
||||
'.post',
|
||||
'.entry-content',
|
||||
'.article-content',
|
||||
'.main-content',
|
||||
];
|
||||
|
||||
for (const selector of contentSelectors) {
|
||||
const element = doc.querySelector(selector);
|
||||
if (element && element.textContent) {
|
||||
content = element.textContent.trim();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If still no content, get all paragraphs
|
||||
if (!content) {
|
||||
const paragraphs = doc.querySelectorAll('p');
|
||||
const texts: string[] = [];
|
||||
paragraphs.forEach((p: any) => {
|
||||
const text = p.textContent?.trim();
|
||||
if (text && text.length > 50) {
|
||||
// Filter out short paragraphs
|
||||
texts.push(text);
|
||||
}
|
||||
});
|
||||
content = texts.join('\n\n');
|
||||
}
|
||||
|
||||
if (!content || content.length < 100) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Could not extract meaningful article content' }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Create a pseudo-article object
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
title: title || 'Untitled',
|
||||
content: content,
|
||||
excerpt: content.substring(0, 200),
|
||||
source: validatedUrl.toString(),
|
||||
domain: validatedUrl.hostname,
|
||||
author: '',
|
||||
publishDate: '',
|
||||
wordCount: content.split(/\s+/).length,
|
||||
readingTime: Math.ceil(content.split(/\s+/).length / 200),
|
||||
tags: [],
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Extract additional metadata
|
||||
const metaTags = doc.querySelectorAll('meta');
|
||||
const metadata: Record<string, string> = {};
|
||||
|
||||
metaTags.forEach((meta: any) => {
|
||||
const name = meta.getAttribute('name') || meta.getAttribute('property');
|
||||
const content = meta.getAttribute('content');
|
||||
|
||||
if (name && content) {
|
||||
if (name.includes('author')) metadata.author = content;
|
||||
if (name.includes('description')) metadata.description = content;
|
||||
if (name.includes('keywords')) metadata.keywords = content;
|
||||
if (name.includes('publish')) metadata.publishDate = content;
|
||||
}
|
||||
});
|
||||
|
||||
// Generate tags from keywords if available
|
||||
const tags = metadata.keywords
|
||||
? metadata.keywords
|
||||
.split(',')
|
||||
.map((k) => k.trim())
|
||||
.filter((k) => k.length > 0)
|
||||
.slice(0, 5)
|
||||
: [];
|
||||
|
||||
// Clean and format the extracted text
|
||||
const cleanedContent = article.textContent
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
title: article.title || 'Untitled',
|
||||
content: cleanedContent,
|
||||
excerpt: article.excerpt || metadata.description || '',
|
||||
source: validatedUrl.toString(),
|
||||
domain: validatedUrl.hostname,
|
||||
author: article.byline || metadata.author || '',
|
||||
publishDate: metadata.publishDate || '',
|
||||
wordCount: cleanedContent.split(/\s+/).length,
|
||||
readingTime: Math.ceil(cleanedContent.split(/\s+/).length / 200), // Assuming 200 words per minute
|
||||
tags,
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Extract URL error:', error);
|
||||
return new Response(JSON.stringify({ error: error.message || 'Internal server error' }), {
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
@ -0,0 +1,512 @@
|
|||
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';
|
||||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
|
||||
|
||||
const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
||||
};
|
||||
|
||||
interface AudioRequest {
|
||||
textId: string;
|
||||
content: string;
|
||||
voice: string;
|
||||
provider: 'google' | 'elevenlabs' | 'openai';
|
||||
speed: number;
|
||||
chunkSize?: number;
|
||||
versionId?: string;
|
||||
}
|
||||
|
||||
interface AudioChunk {
|
||||
id: string;
|
||||
start: number;
|
||||
end: number;
|
||||
content: string;
|
||||
}
|
||||
|
||||
serve(async (req) => {
|
||||
// Handle CORS preflight requests
|
||||
if (req.method === 'OPTIONS') {
|
||||
return new Response('ok', { headers: corsHeaders });
|
||||
}
|
||||
|
||||
try {
|
||||
// Parse request first to get provider
|
||||
const requestData: AudioRequest = await req.json();
|
||||
const { provider = 'google' } = requestData;
|
||||
|
||||
// Check required environment variables based on provider
|
||||
let apiKeyPresent = false;
|
||||
let missingKeyMessage = '';
|
||||
|
||||
switch (provider) {
|
||||
case 'google':
|
||||
apiKeyPresent = !!Deno.env.get('GOOGLE_TTS_API_KEY');
|
||||
missingKeyMessage = 'Missing GOOGLE_TTS_API_KEY environment variable';
|
||||
break;
|
||||
case 'elevenlabs':
|
||||
apiKeyPresent = !!Deno.env.get('ELEVENLABS_API_KEY');
|
||||
missingKeyMessage = 'Missing ELEVENLABS_API_KEY environment variable';
|
||||
break;
|
||||
case 'openai':
|
||||
apiKeyPresent = !!Deno.env.get('OPENAI_API_KEY');
|
||||
missingKeyMessage = 'Missing OPENAI_API_KEY environment variable';
|
||||
break;
|
||||
}
|
||||
|
||||
if (!apiKeyPresent) {
|
||||
console.error(missingKeyMessage);
|
||||
return new Response(JSON.stringify({ error: 'TTS service not configured' }), {
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize Supabase client
|
||||
const supabaseClient = createClient(
|
||||
Deno.env.get('SUPABASE_URL') ?? '',
|
||||
Deno.env.get('SUPABASE_ANON_KEY') ?? '',
|
||||
{
|
||||
global: {
|
||||
headers: { Authorization: req.headers.get('Authorization')! },
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// Get user from JWT token
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabaseClient.auth.getUser();
|
||||
|
||||
if (!user) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
status: 401,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const { textId, content, voice, speed, chunkSize = 1000, versionId } = requestData;
|
||||
|
||||
// Validate input
|
||||
if (!textId || !content) {
|
||||
return new Response(JSON.stringify({ error: 'Missing required fields' }), {
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Split text into chunks
|
||||
const chunks: AudioChunk[] = [];
|
||||
for (let i = 0; i < content.length; i += chunkSize) {
|
||||
chunks.push({
|
||||
id: `chunk-${chunks.length}`,
|
||||
start: i,
|
||||
end: Math.min(i + chunkSize, content.length),
|
||||
content: content.slice(i, Math.min(i + chunkSize, content.length)),
|
||||
});
|
||||
}
|
||||
|
||||
// Generate audio based on the provider
|
||||
let audioResult;
|
||||
|
||||
switch (provider) {
|
||||
case 'elevenlabs':
|
||||
audioResult = await generateElevenLabsTTS(chunks, voice, speed);
|
||||
break;
|
||||
case 'openai':
|
||||
audioResult = await generateOpenAITTS(chunks, voice, speed);
|
||||
break;
|
||||
case 'google':
|
||||
default:
|
||||
audioResult = await generateGoogleTTS(chunks, voice, speed);
|
||||
break;
|
||||
}
|
||||
|
||||
const { audioChunks, totalSize } = audioResult;
|
||||
|
||||
// Store audio chunks in Supabase Storage
|
||||
const storedChunks = [];
|
||||
for (const chunkData of audioChunks) {
|
||||
try {
|
||||
// Use versionId in path if provided, otherwise use default path
|
||||
const fileName = versionId
|
||||
? `${user.id}/${textId}/${versionId}/${chunkData.id}.mp3`
|
||||
: `${user.id}/${textId}/${chunkData.id}.mp3`;
|
||||
|
||||
const { error: uploadError } = await supabaseClient.storage
|
||||
.from('audio')
|
||||
.upload(fileName, chunkData.audioBuffer, {
|
||||
contentType: 'audio/mpeg',
|
||||
upsert: true,
|
||||
});
|
||||
|
||||
if (uploadError) {
|
||||
console.error('Upload error:', uploadError);
|
||||
throw uploadError;
|
||||
}
|
||||
|
||||
// Create audio chunk metadata for storage
|
||||
storedChunks.push({
|
||||
id: chunkData.id,
|
||||
start: chunkData.start,
|
||||
end: chunkData.end,
|
||||
filename: fileName,
|
||||
size: chunkData.size,
|
||||
duration: chunkData.duration,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Error storing chunk ${chunkData.id}:`, error);
|
||||
// Continue with other chunks, but log the error
|
||||
}
|
||||
}
|
||||
|
||||
// Update text record with audio metadata
|
||||
const { error: updateError } = await supabaseClient
|
||||
.from('texts')
|
||||
.update({
|
||||
data: {
|
||||
audio: {
|
||||
hasLocalCache: false, // Will be set to true when downloaded to device
|
||||
chunks: storedChunks,
|
||||
totalSize,
|
||||
lastGenerated: new Date().toISOString(),
|
||||
settings: { voice, speed, provider },
|
||||
},
|
||||
},
|
||||
})
|
||||
.eq('id', textId)
|
||||
.eq('user_id', user.id);
|
||||
|
||||
if (updateError) {
|
||||
throw updateError;
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
chunksGenerated: storedChunks.length,
|
||||
totalSize,
|
||||
chunks: storedChunks,
|
||||
provider,
|
||||
}),
|
||||
{
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error in generate-audio function:', error);
|
||||
return new Response(JSON.stringify({ error: error.message }), {
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function extractLanguageCode(voiceId: string): string {
|
||||
// Extract language code from voice ID (e.g., "de-DE" from "de-DE-Neural2-G")
|
||||
const parts = voiceId.split('-');
|
||||
if (parts.length >= 2) {
|
||||
return `${parts[0]}-${parts[1]}`;
|
||||
}
|
||||
return 'de-DE'; // Default fallback
|
||||
}
|
||||
|
||||
function getVoiceName(voiceId: string): string {
|
||||
// If it's already a full voice ID (contains more than just language code), return it
|
||||
if (voiceId.includes('-') && voiceId.split('-').length > 2) {
|
||||
return voiceId;
|
||||
}
|
||||
|
||||
// Legacy support: map old language codes to default voices
|
||||
const legacyVoiceMap: Record<string, string> = {
|
||||
'de-DE': 'de-DE-Neural2-A',
|
||||
'en-US': 'en-US-Neural2-A',
|
||||
'en-GB': 'en-GB-Neural2-A',
|
||||
};
|
||||
|
||||
return legacyVoiceMap[voiceId] || 'de-DE-Neural2-A';
|
||||
}
|
||||
|
||||
function estimateAudioDuration(text: string, speed: number): number {
|
||||
// Rough estimate: 150 words per minute for normal speech
|
||||
const wordsPerMinute = 150 * speed;
|
||||
const wordCount = text.split(/\s+/).length;
|
||||
return Math.ceil((wordCount / wordsPerMinute) * 60);
|
||||
}
|
||||
|
||||
// Google Cloud TTS Implementation
|
||||
async function generateGoogleTTS(chunks: AudioChunk[], voice: string, speed: number) {
|
||||
const googleApiKey = Deno.env.get('GOOGLE_TTS_API_KEY');
|
||||
if (!googleApiKey) {
|
||||
throw new Error('Google TTS API key not configured');
|
||||
}
|
||||
|
||||
const audioChunks = [];
|
||||
let totalSize = 0;
|
||||
|
||||
for (const chunk of chunks) {
|
||||
let retries = 0;
|
||||
const maxRetries = 3;
|
||||
let delay = 1000; // Start with 1 second delay
|
||||
|
||||
while (retries < maxRetries) {
|
||||
try {
|
||||
const ttsResponse = await fetch(
|
||||
`https://texttospeech.googleapis.com/v1/text:synthesize?key=${googleApiKey}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
input: { text: chunk.content },
|
||||
voice: {
|
||||
languageCode: extractLanguageCode(voice),
|
||||
name: getVoiceName(voice),
|
||||
},
|
||||
audioConfig: {
|
||||
audioEncoding: 'MP3',
|
||||
speakingRate: speed,
|
||||
pitch: 0,
|
||||
volumeGainDb: 0,
|
||||
},
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (ttsResponse.status === 429 || ttsResponse.status === 503) {
|
||||
retries++;
|
||||
if (retries < maxRetries) {
|
||||
console.log(
|
||||
`Rate limited on chunk ${chunk.id}, retrying in ${delay}ms (attempt ${retries}/${maxRetries})`
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
delay *= 2; // Exponential backoff
|
||||
continue;
|
||||
} else {
|
||||
throw new Error(
|
||||
`Google TTS error: ${ttsResponse.status} - Rate limit exceeded after ${maxRetries} attempts`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!ttsResponse.ok) {
|
||||
const errorBody = await ttsResponse.text();
|
||||
console.error('Google TTS API Error:', {
|
||||
status: ttsResponse.status,
|
||||
body: errorBody,
|
||||
});
|
||||
throw new Error(`Google TTS error: ${ttsResponse.status}`);
|
||||
}
|
||||
|
||||
const ttsData = await ttsResponse.json();
|
||||
const audioContent = ttsData.audioContent;
|
||||
const audioBuffer = Uint8Array.from(atob(audioContent), (c) => c.charCodeAt(0));
|
||||
const audioSize = audioBuffer.length;
|
||||
|
||||
totalSize += audioSize;
|
||||
audioChunks.push({
|
||||
id: chunk.id,
|
||||
start: chunk.start,
|
||||
end: chunk.end,
|
||||
audioBuffer,
|
||||
size: audioSize,
|
||||
duration: estimateAudioDuration(chunk.content, speed),
|
||||
});
|
||||
break; // Success, exit retry loop
|
||||
} catch (error) {
|
||||
retries++;
|
||||
console.error(
|
||||
`Error processing Google TTS chunk ${chunk.id} (attempt ${retries}/${maxRetries}):`,
|
||||
error
|
||||
);
|
||||
if (retries >= maxRetries) {
|
||||
throw error; // Re-throw after all retries exhausted
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
delay *= 2; // Exponential backoff for other errors too
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { audioChunks, totalSize };
|
||||
}
|
||||
|
||||
// ElevenLabs TTS Implementation
|
||||
async function generateElevenLabsTTS(chunks: AudioChunk[], voice: string, speed: number) {
|
||||
const elevenLabsApiKey = Deno.env.get('ELEVENLABS_API_KEY');
|
||||
if (!elevenLabsApiKey) {
|
||||
throw new Error('ElevenLabs API key not configured');
|
||||
}
|
||||
|
||||
const audioChunks = [];
|
||||
let totalSize = 0;
|
||||
|
||||
// Map voice IDs to ElevenLabs voice IDs
|
||||
const voiceMapping: Record<string, string> = {
|
||||
eleven_multilingual_v2: '21m00Tcm4TlvDq8ikWAM', // Rachel
|
||||
eleven_multilingual_v1: 'pNInz6obpgDQGcFmaJgB', // Adam
|
||||
eleven_turbo_v2: '21m00Tcm4TlvDq8ikWAM', // Rachel Turbo
|
||||
eleven_monolingual_v1: '2EiwWnXFnvU5JabPnv8n', // Clyde
|
||||
};
|
||||
|
||||
const elevenLabsVoiceId = voiceMapping[voice] || '21m00Tcm4TlvDq8ikWAM';
|
||||
|
||||
for (const chunk of chunks) {
|
||||
let retries = 0;
|
||||
const maxRetries = 3;
|
||||
let delay = 1000; // Start with 1 second delay
|
||||
|
||||
while (retries < maxRetries) {
|
||||
try {
|
||||
const ttsResponse = await fetch(
|
||||
`https://api.elevenlabs.io/v1/text-to-speech/${elevenLabsVoiceId}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'xi-api-key': elevenLabsApiKey,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
text: chunk.content,
|
||||
model_id: voice.includes('turbo') ? 'eleven_turbo_v2' : 'eleven_multilingual_v2',
|
||||
voice_settings: {
|
||||
stability: 0.5,
|
||||
similarity_boost: 0.5,
|
||||
style: 0.5,
|
||||
use_speaker_boost: true,
|
||||
},
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (ttsResponse.status === 429 || ttsResponse.status === 503) {
|
||||
retries++;
|
||||
if (retries < maxRetries) {
|
||||
console.log(
|
||||
`Rate limited on chunk ${chunk.id}, retrying in ${delay}ms (attempt ${retries}/${maxRetries})`
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
delay *= 2; // Exponential backoff
|
||||
continue;
|
||||
} else {
|
||||
throw new Error(
|
||||
`ElevenLabs TTS error: ${ttsResponse.status} - Rate limit exceeded after ${maxRetries} attempts`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!ttsResponse.ok) {
|
||||
throw new Error(`ElevenLabs TTS error: ${ttsResponse.status}`);
|
||||
}
|
||||
|
||||
const audioBuffer = new Uint8Array(await ttsResponse.arrayBuffer());
|
||||
const audioSize = audioBuffer.length;
|
||||
|
||||
totalSize += audioSize;
|
||||
audioChunks.push({
|
||||
id: chunk.id,
|
||||
start: chunk.start,
|
||||
end: chunk.end,
|
||||
audioBuffer,
|
||||
size: audioSize,
|
||||
duration: estimateAudioDuration(chunk.content, speed),
|
||||
});
|
||||
break; // Success, exit retry loop
|
||||
} catch (error) {
|
||||
retries++;
|
||||
console.error(
|
||||
`Error processing ElevenLabs chunk ${chunk.id} (attempt ${retries}/${maxRetries}):`,
|
||||
error
|
||||
);
|
||||
if (retries >= maxRetries) {
|
||||
throw error; // Re-throw after all retries exhausted
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
delay *= 2; // Exponential backoff for other errors too
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { audioChunks, totalSize };
|
||||
}
|
||||
|
||||
// OpenAI TTS Implementation
|
||||
async function generateOpenAITTS(chunks: AudioChunk[], voice: string, speed: number) {
|
||||
const openaiApiKey = Deno.env.get('OPENAI_API_KEY');
|
||||
if (!openaiApiKey) {
|
||||
throw new Error('OpenAI API key not configured');
|
||||
}
|
||||
|
||||
const audioChunks = [];
|
||||
let totalSize = 0;
|
||||
|
||||
for (const chunk of chunks) {
|
||||
let retries = 0;
|
||||
const maxRetries = 3;
|
||||
let delay = 1000; // Start with 1 second delay
|
||||
|
||||
while (retries < maxRetries) {
|
||||
try {
|
||||
const ttsResponse = await fetch('https://api.openai.com/v1/audio/speech', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${openaiApiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'tts-1-hd', // Using HD model for better quality
|
||||
input: chunk.content,
|
||||
voice: voice,
|
||||
speed: speed,
|
||||
}),
|
||||
});
|
||||
|
||||
if (ttsResponse.status === 429) {
|
||||
retries++;
|
||||
if (retries < maxRetries) {
|
||||
console.log(
|
||||
`Rate limited on chunk ${chunk.id}, retrying in ${delay}ms (attempt ${retries}/${maxRetries})`
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
delay *= 2; // Exponential backoff
|
||||
continue;
|
||||
} else {
|
||||
throw new Error(
|
||||
`OpenAI TTS error: ${ttsResponse.status} - Rate limit exceeded after ${maxRetries} attempts`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!ttsResponse.ok) {
|
||||
throw new Error(`OpenAI TTS error: ${ttsResponse.status}`);
|
||||
}
|
||||
|
||||
const audioBuffer = new Uint8Array(await ttsResponse.arrayBuffer());
|
||||
const audioSize = audioBuffer.length;
|
||||
|
||||
totalSize += audioSize;
|
||||
audioChunks.push({
|
||||
id: chunk.id,
|
||||
start: chunk.start,
|
||||
end: chunk.end,
|
||||
audioBuffer,
|
||||
size: audioSize,
|
||||
duration: estimateAudioDuration(chunk.content, speed),
|
||||
});
|
||||
break; // Success, exit retry loop
|
||||
} catch (error) {
|
||||
retries++;
|
||||
console.error(
|
||||
`Error processing OpenAI chunk ${chunk.id} (attempt ${retries}/${maxRetries}):`,
|
||||
error
|
||||
);
|
||||
if (retries >= maxRetries) {
|
||||
throw error; // Re-throw after all retries exhausted
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
delay *= 2; // Exponential backoff for other errors too
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { audioChunks, totalSize };
|
||||
}
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';
|
||||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
|
||||
|
||||
const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
||||
};
|
||||
|
||||
interface AudioUrlRequest {
|
||||
textId: string;
|
||||
chunkId: string;
|
||||
}
|
||||
|
||||
serve(async (req) => {
|
||||
// Handle CORS preflight requests
|
||||
if (req.method === 'OPTIONS') {
|
||||
return new Response('ok', { headers: corsHeaders });
|
||||
}
|
||||
|
||||
try {
|
||||
// Initialize Supabase client
|
||||
const supabaseClient = createClient(
|
||||
Deno.env.get('SUPABASE_URL') ?? '',
|
||||
Deno.env.get('SUPABASE_ANON_KEY') ?? '',
|
||||
{
|
||||
global: {
|
||||
headers: { Authorization: req.headers.get('Authorization')! },
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// Get user from JWT token
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabaseClient.auth.getUser();
|
||||
|
||||
if (!user) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
status: 401,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const { textId, chunkId }: AudioUrlRequest = await req.json();
|
||||
|
||||
// Validate input
|
||||
if (!textId || !chunkId) {
|
||||
return new Response(JSON.stringify({ error: 'Missing required fields' }), {
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Verify text belongs to user
|
||||
const { data: text, error: textError } = await supabaseClient
|
||||
.from('texts')
|
||||
.select('data')
|
||||
.eq('id', textId)
|
||||
.eq('user_id', user.id)
|
||||
.single();
|
||||
|
||||
if (textError || !text) {
|
||||
return new Response(JSON.stringify({ error: 'Text not found' }), {
|
||||
status: 404,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Find the chunk
|
||||
const chunk = text.data.audio?.chunks?.find((c: any) => c.id === chunkId);
|
||||
if (!chunk) {
|
||||
return new Response(JSON.stringify({ error: 'Chunk not found' }), {
|
||||
status: 404,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Generate signed URL for the audio file with user-specific path
|
||||
const filePath = `${user.id}/${textId}/${chunkId}.mp3`;
|
||||
const { data: urlData, error: urlError } = await supabaseClient.storage
|
||||
.from('audio')
|
||||
.createSignedUrl(filePath, 3600); // 1 hour expiration
|
||||
|
||||
if (urlError) {
|
||||
throw urlError;
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
url: urlData.signedUrl,
|
||||
chunk: {
|
||||
id: chunk.id,
|
||||
start: chunk.start,
|
||||
end: chunk.end,
|
||||
duration: chunk.duration,
|
||||
},
|
||||
}),
|
||||
{
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error in get-audio-url function:', error);
|
||||
return new Response(JSON.stringify({ error: error.message }), {
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
-- Enable UUID extension
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
-- Die einzige Tabelle die du brauchst
|
||||
CREATE TABLE texts (
|
||||
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
|
||||
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
|
||||
-- Der eigentliche Content
|
||||
title TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
|
||||
-- ALLES andere in einem JSONB Feld
|
||||
data JSONB DEFAULT '{}' NOT NULL,
|
||||
|
||||
-- Nur die absolut nötigen Timestamps
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Indizes für Performance
|
||||
CREATE INDEX idx_texts_user ON texts(user_id);
|
||||
CREATE INDEX idx_texts_data ON texts USING GIN (data);
|
||||
|
||||
-- RLS aktivieren
|
||||
ALTER TABLE texts ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Jeder sieht nur seine eigenen Texte
|
||||
CREATE POLICY "Own texts only" ON texts
|
||||
FOR ALL USING (auth.uid() = user_id);
|
||||
|
||||
-- Update Timestamp Trigger
|
||||
CREATE OR REPLACE FUNCTION update_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER update_texts_updated_at
|
||||
BEFORE UPDATE ON texts
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at();
|
||||
|
||||
-- Hilfsfunktion für atomare Play Count Updates
|
||||
CREATE OR REPLACE FUNCTION increment_play_count(text_id UUID)
|
||||
RETURNS void AS $$
|
||||
BEGIN
|
||||
UPDATE texts
|
||||
SET data = jsonb_set(
|
||||
jsonb_set(
|
||||
data,
|
||||
'{stats,playCount}',
|
||||
to_jsonb(COALESCE((data->'stats'->>'playCount')::int, 0) + 1)
|
||||
),
|
||||
'{tts,lastPlayed}',
|
||||
to_jsonb(NOW())
|
||||
)
|
||||
WHERE id = text_id AND user_id = auth.uid();
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
-- Create audio storage bucket
|
||||
INSERT INTO storage.buckets (id, name, public)
|
||||
VALUES ('audio', 'audio', false);
|
||||
|
||||
-- Create policy for authenticated users to upload their own audio files
|
||||
CREATE POLICY "Users can upload their own audio files" ON storage.objects
|
||||
FOR INSERT WITH CHECK (
|
||||
bucket_id = 'audio' AND
|
||||
auth.uid()::text = (storage.foldername(name))[1]
|
||||
);
|
||||
|
||||
-- Create policy for authenticated users to view their own audio files
|
||||
CREATE POLICY "Users can view their own audio files" ON storage.objects
|
||||
FOR SELECT USING (
|
||||
bucket_id = 'audio' AND
|
||||
auth.uid()::text = (storage.foldername(name))[1]
|
||||
);
|
||||
|
||||
-- Create policy for authenticated users to update their own audio files
|
||||
CREATE POLICY "Users can update their own audio files" ON storage.objects
|
||||
FOR UPDATE USING (
|
||||
bucket_id = 'audio' AND
|
||||
auth.uid()::text = (storage.foldername(name))[1]
|
||||
);
|
||||
|
||||
-- Create policy for authenticated users to delete their own audio files
|
||||
CREATE POLICY "Users can delete their own audio files" ON storage.objects
|
||||
FOR DELETE USING (
|
||||
bucket_id = 'audio' AND
|
||||
auth.uid()::text = (storage.foldername(name))[1]
|
||||
);
|
||||
Loading…
Add table
Add a link
Reference in a new issue