mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-04-05 07:14:02 -04:00
Add profile auti-fill from content or URL
This commit is contained in:
@@ -11,8 +11,8 @@ android {
|
||||
applicationId "com.compassconnections.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 71
|
||||
versionName "1.13.0"
|
||||
versionCode 72
|
||||
versionName "1.14.0"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@compass/api",
|
||||
"version": "1.28.0",
|
||||
"version": "1.29.0",
|
||||
"private": true,
|
||||
"description": "Backend API endpoints",
|
||||
"main": "src/serve.ts",
|
||||
@@ -28,6 +28,7 @@
|
||||
"dependencies": {
|
||||
"@google-cloud/monitoring": "4.0.0",
|
||||
"@google-cloud/secret-manager": "4.2.1",
|
||||
"@mozilla/readability": "0.6.0",
|
||||
"@sentry/node": "10.41.0",
|
||||
"@tiptap/core": "2.10.4",
|
||||
"cors": "2.8.5",
|
||||
@@ -35,8 +36,10 @@
|
||||
"express": "5.0.0",
|
||||
"firebase-admin": "13.5.0",
|
||||
"gcp-metadata": "6.1.0",
|
||||
"jsdom": "29.0.1",
|
||||
"jsonwebtoken": "9.0.0",
|
||||
"lodash": "4.17.23",
|
||||
"marked": "17.0.5",
|
||||
"openapi-types": "12.1.3",
|
||||
"pg-promise": "12.6.1",
|
||||
"posthog-node": "4.11.0",
|
||||
@@ -50,6 +53,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "2.8.17",
|
||||
"@types/jsdom": "28.0.1",
|
||||
"@types/jsonwebtoken": "^9.0.0",
|
||||
"@types/lodash": "^4.17.0",
|
||||
"@types/swagger-ui-express": "4.1.8",
|
||||
|
||||
@@ -10,7 +10,7 @@ import {getLastSeenChannelTime, setChannelLastSeenTime} from 'api/get-channel-se
|
||||
import {getHiddenProfiles} from 'api/get-hidden-profiles'
|
||||
import {getLastMessages} from 'api/get-last-messages'
|
||||
import {getMessagesCountEndpoint} from 'api/get-messages-count'
|
||||
import {getOptions} from 'api/get-options'
|
||||
import {getOptionsEndpoint} from 'api/get-options'
|
||||
import {getPinnedCompatibilityQuestions} from 'api/get-pinned-compatibility-questions'
|
||||
import {getChannelMessagesEndpoint} from 'api/get-private-messages'
|
||||
import {getUser} from 'api/get-user'
|
||||
@@ -78,11 +78,12 @@ import {type APIHandler, typedEndpoint} from './helpers/endpoint'
|
||||
import {hideComment} from './hide-comment'
|
||||
import {leavePrivateUserMessageChannel} from './leave-private-user-message-channel'
|
||||
import {likeProfile} from './like-profile'
|
||||
import {llmExtractProfileEndpoint} from './llm-extract-profile'
|
||||
import {markAllNotifsRead} from './mark-all-notifications-read'
|
||||
import {removePinnedPhoto} from './remove-pinned-photo'
|
||||
import {report} from './report'
|
||||
import {rsvpEvent} from './rsvp-event'
|
||||
import {searchLocation} from './search-location'
|
||||
import {searchLocationEndpoint} from './search-location'
|
||||
import {searchNearCity} from './search-near-city'
|
||||
import {searchUsers} from './search-users'
|
||||
import {setCompatibilityAnswer} from './set-compatibility-answer'
|
||||
@@ -602,7 +603,7 @@ const handlers: {[k in APIPath]: APIHandler<k>} = {
|
||||
'get-likes-and-ships': getLikesAndShips,
|
||||
'get-messages-count': getMessagesCountEndpoint,
|
||||
'get-notifications': getNotifications,
|
||||
'get-options': getOptions,
|
||||
'get-options': getOptionsEndpoint,
|
||||
'get-profile-answers': getProfileAnswers,
|
||||
'get-profiles': getProfiles,
|
||||
'get-supabase-token': getSupabaseToken,
|
||||
@@ -622,7 +623,7 @@ const handlers: {[k in APIPath]: APIHandler<k>} = {
|
||||
'remove-pinned-photo': removePinnedPhoto,
|
||||
'save-subscription': saveSubscription,
|
||||
'save-subscription-mobile': saveSubscriptionMobile,
|
||||
'search-location': searchLocation,
|
||||
'search-location': searchLocationEndpoint,
|
||||
'search-near-city': searchNearCity,
|
||||
'search-users': searchUsers,
|
||||
'set-channel-seen-time': setChannelLastSeenTime,
|
||||
@@ -644,6 +645,7 @@ const handlers: {[k in APIPath]: APIHandler<k>} = {
|
||||
'user/by-id/:id/unblock': unblockUser,
|
||||
vote: vote,
|
||||
'validate-username': validateUsernameEndpoint,
|
||||
'llm-extract-profile': llmExtractProfileEndpoint,
|
||||
// 'user/:username': getUser,
|
||||
// 'user/:username/lite': getDisplayUser,
|
||||
// 'user/by-id/:id/lite': getDisplayUser,
|
||||
|
||||
@@ -1,24 +1,44 @@
|
||||
import {APIErrors, APIHandler} from 'api/helpers/endpoint'
|
||||
import {OPTION_TABLES} from 'common/profiles/constants'
|
||||
import {OptionTableKey} from 'common/profiles/constants'
|
||||
import {validateTable} from 'common/profiles/options'
|
||||
import {tryCatch} from 'common/util/try-catch'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {log} from 'shared/utils'
|
||||
|
||||
export const getOptions: APIHandler<'get-options'> = async ({table}, _auth) => {
|
||||
if (!OPTION_TABLES.includes(table)) throw APIErrors.badRequest('Invalid table')
|
||||
export async function getOptions(table: OptionTableKey, locale?: string): Promise<string[]> {
|
||||
validateTable(table)
|
||||
|
||||
const pg = createSupabaseDirectClient()
|
||||
|
||||
const result = await tryCatch(
|
||||
pg.manyOrNone<{name: string}>(`SELECT interests.name
|
||||
FROM interests`),
|
||||
)
|
||||
let query: string
|
||||
const params: any[] = []
|
||||
|
||||
if (locale) {
|
||||
// Get translated options for the specified locale
|
||||
const translationTable = `${table}_translations`
|
||||
query = `
|
||||
SELECT COALESCE(t.name, o.name) as name
|
||||
FROM ${table} o
|
||||
LEFT JOIN ${translationTable} t ON o.id = t.option_id AND t.locale = $1
|
||||
ORDER BY o.id
|
||||
`
|
||||
params.push(locale)
|
||||
} else {
|
||||
// Get default options (fallback to English)
|
||||
query = `SELECT name FROM ${table} ORDER BY id`
|
||||
}
|
||||
|
||||
const result = await tryCatch(pg.manyOrNone<{name: string}>(query, params))
|
||||
|
||||
if (result.error) {
|
||||
log('Error getting profile options', result.error)
|
||||
throw APIErrors.internalServerError('Error getting profile options')
|
||||
}
|
||||
|
||||
const names = result.data.map((row) => row.name)
|
||||
return result.data.map((row) => row.name)
|
||||
}
|
||||
|
||||
export const getOptionsEndpoint: APIHandler<'get-options'> = async ({table, locale}, _auth) => {
|
||||
const names = await getOptions(table, locale)
|
||||
return {names}
|
||||
}
|
||||
|
||||
374
backend/api/src/llm-extract-profile.ts
Normal file
374
backend/api/src/llm-extract-profile.ts
Normal file
@@ -0,0 +1,374 @@
|
||||
import {JSONContent} from '@tiptap/core'
|
||||
import {getOptions} from 'api/get-options'
|
||||
import {APIErrors, APIHandler} from 'api/helpers/endpoint'
|
||||
import {searchLocation} from 'api/search-location'
|
||||
import {
|
||||
DIET_CHOICES,
|
||||
EDUCATION_CHOICES,
|
||||
GENDERS,
|
||||
LANGUAGE_CHOICES,
|
||||
MBTI_CHOICES,
|
||||
POLITICAL_CHOICES,
|
||||
RACE_CHOICES,
|
||||
RELATIONSHIP_CHOICES,
|
||||
RELATIONSHIP_STATUS_CHOICES,
|
||||
RELIGION_CHOICES,
|
||||
ROMANTIC_CHOICES,
|
||||
} from 'common/choices'
|
||||
import {debug} from 'common/logger'
|
||||
import {ProfileWithoutUser} from 'common/profiles/profile'
|
||||
import {parseJsonContentToText} from 'common/util/parse'
|
||||
import {createHash} from 'crypto'
|
||||
import {promises as fs} from 'fs'
|
||||
import {tmpdir} from 'os'
|
||||
import {join} from 'path'
|
||||
import {log} from 'shared/monitoring/log'
|
||||
import {convertToJSONContent, extractGoogleDocId} from 'shared/parse'
|
||||
|
||||
const MAX_CONTEXT_LENGTH = 7 * 10 * 30 * 50
|
||||
const USE_CACHE = true
|
||||
const CACHE_DIR = join(tmpdir(), 'compass-llm-cache')
|
||||
const CACHE_TTL_MS = 24 * 60 * 60 * 1000 // 24 hours
|
||||
|
||||
function getCacheKey(content: string): string {
|
||||
if (!USE_CACHE) return ''
|
||||
const hash = createHash('sha256')
|
||||
hash.update(content)
|
||||
return hash.digest('hex')
|
||||
}
|
||||
|
||||
async function getCachedResult(cacheKey: string): Promise<Partial<ProfileWithoutUser> | null> {
|
||||
if (!USE_CACHE) return null
|
||||
try {
|
||||
const cacheFile = join(CACHE_DIR, `${cacheKey}.json`)
|
||||
const stats = await fs.stat(cacheFile)
|
||||
|
||||
if (Date.now() - stats.mtime.getTime() > CACHE_TTL_MS) {
|
||||
await fs.unlink(cacheFile)
|
||||
return null
|
||||
}
|
||||
|
||||
const cachedData = await fs.readFile(cacheFile, 'utf-8')
|
||||
return JSON.parse(cachedData)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function setCachedResult(
|
||||
cacheKey: string,
|
||||
result: Partial<ProfileWithoutUser>,
|
||||
): Promise<void> {
|
||||
if (!USE_CACHE) return
|
||||
try {
|
||||
await fs.mkdir(CACHE_DIR, {recursive: true})
|
||||
const cacheFile = join(CACHE_DIR, `${cacheKey}.json`)
|
||||
await fs.writeFile(cacheFile, JSON.stringify(result), 'utf-8')
|
||||
debug('Cached LLM result', {cacheKey: cacheKey.substring(0, 8)})
|
||||
} catch (error) {
|
||||
log('Failed to write cache', {cacheKey, error})
|
||||
// Don't throw - caching failure shouldn't break the main flow
|
||||
}
|
||||
}
|
||||
|
||||
async function callGemini(text: string) {
|
||||
const apiKey = process.env.GEMINI_API_KEY
|
||||
|
||||
if (!apiKey) {
|
||||
log('GEMINI_API_KEY not configured')
|
||||
throw APIErrors.internalServerError('Profile extraction service is not configured')
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${apiKey}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
contents: [
|
||||
{
|
||||
parts: [
|
||||
{
|
||||
text: text.slice(0, MAX_CONTEXT_LENGTH),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
generationConfig: {
|
||||
temperature: 0,
|
||||
topP: 0.95,
|
||||
topK: 40,
|
||||
responseMimeType: 'application/json',
|
||||
},
|
||||
}),
|
||||
},
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
log('Gemini API error', {status: response.status, error: errorText})
|
||||
throw APIErrors.internalServerError('Failed to extract profile data')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const outputText = data.candidates?.[0]?.content?.parts?.[0]?.text
|
||||
return outputText
|
||||
}
|
||||
|
||||
async function _callClaude(text: string) {
|
||||
// We don't use it as there is no free tier
|
||||
const apiKey = process.env.ANTHROPIC_API_KEY
|
||||
|
||||
if (!apiKey) {
|
||||
log('ANTHROPIC_API_KEY not configured')
|
||||
throw APIErrors.internalServerError('Profile extraction service is not configured')
|
||||
}
|
||||
|
||||
const response = await fetch('https://api.anthropic.com/v1/messages', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': apiKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'claude-sonnet-4-5',
|
||||
max_tokens: 1024,
|
||||
temperature: 0,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: text.slice(0, MAX_CONTEXT_LENGTH),
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
log('Anthropic API error', {status: response.status, error: errorText})
|
||||
throw APIErrors.internalServerError('Failed to extract profile data')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const outputText = data.content?.[0]?.text
|
||||
return outputText
|
||||
}
|
||||
|
||||
async function callLLM(content: string, locale?: string): Promise<Partial<ProfileWithoutUser>> {
|
||||
const [INTERESTS, CAUSE_AREAS, WORK_AREAS] = await Promise.all([
|
||||
getOptions('interests', locale),
|
||||
getOptions('causes', locale),
|
||||
getOptions('work', locale),
|
||||
])
|
||||
|
||||
const PROFILE_FIELDS: Partial<Record<keyof ProfileWithoutUser, any>> = {
|
||||
// Basic info
|
||||
age: 'Number. Age in years.',
|
||||
gender: `One of: ${Object.values(GENDERS).join(', ')}. Infer if you have enough evidence`,
|
||||
height_in_inches: 'Number. Height converted to inches.',
|
||||
city: 'String. Current city of residence (English spelling).',
|
||||
country: 'String. Current country of residence (English spelling).',
|
||||
city_latitude: 'Number. Latitude of current city.',
|
||||
city_longitude: 'Number. Longitude of current city.',
|
||||
|
||||
// Background
|
||||
raised_in_city: 'String. City where they grew up (English spelling).',
|
||||
raised_in_country: 'String. Country where they grew up (English spelling).',
|
||||
raised_in_lat: 'Number. Latitude of city where they grew up.',
|
||||
raised_in_lon: 'Number. Longitude of city where they grew up.',
|
||||
university: 'String. University or college attended.',
|
||||
education_level: `One of: ${Object.values(EDUCATION_CHOICES).join(', ')}`,
|
||||
company: 'String. Current employer or company name.',
|
||||
occupation_title: 'String. Current job title.',
|
||||
|
||||
// Lifestyle
|
||||
is_smoker: 'Boolean. Whether they smoke.',
|
||||
drinks_per_month: 'Number. Estimated alcoholic drinks per month.',
|
||||
has_kids: 'Number. 0 if no kids, otherwise number of kids.',
|
||||
wants_kids_strength:
|
||||
'Number 0–4. How strongly they want kids (0 = definitely not, 4 = definitely yes).',
|
||||
diet: `Array. Any of: ${Object.values(DIET_CHOICES).join(', ')}`,
|
||||
ethnicity: `Array. Any of: ${Object.values(RACE_CHOICES).join(', ')}`,
|
||||
|
||||
// Identity — big5 only if person explicitly states a score, never infer from personality description
|
||||
mbti: `One of: ${Object.values(MBTI_CHOICES).join(', ')}`,
|
||||
big5_openness: 'Number 0–100. Only if explicitly self-reported, never infer.',
|
||||
big5_conscientiousness: 'Number 0–100. Only if explicitly self-reported, never infer.',
|
||||
big5_extraversion: 'Number 0–100. Only if explicitly self-reported, never infer.',
|
||||
big5_agreeableness: 'Number 0–100. Only if explicitly self-reported, never infer.',
|
||||
big5_neuroticism: 'Number 0–100. Only if explicitly self-reported, never infer.',
|
||||
|
||||
// Beliefs
|
||||
religion: `Array. Any of: ${Object.values(RELIGION_CHOICES).join(', ')}`,
|
||||
religious_beliefs:
|
||||
'String. Free-form elaboration on religious views, only if explicitly stated.',
|
||||
political_beliefs: `Array. Any of: ${Object.values(POLITICAL_CHOICES).join(', ')}`,
|
||||
political_details:
|
||||
'String. Free-form elaboration on political views, only if explicitly stated.',
|
||||
|
||||
// Preferences
|
||||
pref_age_min: 'Number. Minimum preferred age of match.',
|
||||
pref_age_max: 'Number. Maximum preferred age of match.',
|
||||
pref_gender: `Array. Any of: ${Object.values(GENDERS).join(', ')}`,
|
||||
pref_relation_styles: `Array. Any of: ${Object.values(RELATIONSHIP_CHOICES).join(', ')}`,
|
||||
pref_romantic_styles: `Array. Any of: ${Object.values(ROMANTIC_CHOICES).join(', ')}`,
|
||||
relationship_status: `Array. Any of: ${Object.values(RELATIONSHIP_STATUS_CHOICES).join(', ')}`,
|
||||
|
||||
// Languages
|
||||
languages: `Array. Any of: ${Object.values(LANGUAGE_CHOICES).join(', ')}. If none, infer from text.`,
|
||||
|
||||
// Free-form
|
||||
headline:
|
||||
'String. Summary of who they are, in their own voice (first person). Maximum 200 characters total. Cannot be null.',
|
||||
keywords: 'Array of 3–6 short tags summarising the person.',
|
||||
links: 'Object. Any personal URLs found (site, github, linkedin, twitter, etc.).',
|
||||
|
||||
// Taxonomies — match existing labels first, only add new if truly no close match exists
|
||||
interests: `Array. Prefer existing labels, only add new if no close match. Any of: ${INTERESTS.join(', ')}`,
|
||||
causes: `Array. Prefer existing labels, only add new if no close match. Any of: ${CAUSE_AREAS.join(', ')}`,
|
||||
work: `Array. Use only existing labels, do not add new if no close match. Any of: ${WORK_AREAS.join(', ')}`,
|
||||
}
|
||||
|
||||
const EXTRACTION_PROMPT = `You are a profile information extraction expert analyzing text from a personal webpage, LinkedIn, bio, or similar source.
|
||||
|
||||
TASK: Extract structured profile data and return it as a single valid JSON object.
|
||||
|
||||
RULES:
|
||||
- Only extract information that is EXPLICITLY stated — do not infer, guess, or hallucinate
|
||||
- Return null for missing scalar fields, [] for missing array fields
|
||||
- For taxonomy fields (interests, causes, work): match existing labels first; only add a new label if truly no existing one is close
|
||||
- For big5 scores: only populate if the person explicitly states a test result — never infer from personality description
|
||||
- Return valid JSON only — no markdown, no explanation, no extra text
|
||||
|
||||
SCHEMA (each value describes the expected type and accepted values):
|
||||
${JSON.stringify(PROFILE_FIELDS, null, 2)}
|
||||
|
||||
TEXT TO ANALYZE:
|
||||
`
|
||||
const text = EXTRACTION_PROMPT + content
|
||||
if (text.length > MAX_CONTEXT_LENGTH) {
|
||||
log('Content exceeds maximum length', {length: text.length})
|
||||
throw APIErrors.badRequest('Content exceeds maximum length')
|
||||
}
|
||||
debug({text})
|
||||
|
||||
const cacheKey = getCacheKey(text)
|
||||
const cached = await getCachedResult(cacheKey)
|
||||
if (cached) {
|
||||
debug('Using cached LLM result', {cacheKey: cacheKey.substring(0, 8)})
|
||||
return cached
|
||||
}
|
||||
|
||||
const outputText = await callGemini(text)
|
||||
// const outputText = JSON.stringify({})
|
||||
|
||||
if (!outputText) {
|
||||
throw APIErrors.internalServerError('Failed to parse LLM response')
|
||||
}
|
||||
|
||||
let parsed: Partial<ProfileWithoutUser>
|
||||
try {
|
||||
parsed = JSON.parse(outputText)
|
||||
} catch (parseError) {
|
||||
log('Failed to parse LLM response as JSON', {outputText, parseError})
|
||||
throw APIErrors.internalServerError('Failed to parse extracted data')
|
||||
}
|
||||
|
||||
if (parsed.city) {
|
||||
if (!parsed.city_latitude || !parsed.city_longitude) {
|
||||
const result = await searchLocation({term: parsed.city, limit: 1})
|
||||
const locations = result.data?.data
|
||||
parsed.city_latitude = locations?.[0]?.latitude
|
||||
parsed.city_longitude = locations?.[0]?.longitude
|
||||
parsed.country ??= locations?.[0]?.country
|
||||
}
|
||||
}
|
||||
if (parsed.raised_in_city) {
|
||||
if (!parsed.raised_in_lat || !parsed.raised_in_lon) {
|
||||
const result = await searchLocation({term: parsed.raised_in_city, limit: 1})
|
||||
const locations = result.data?.data
|
||||
parsed.raised_in_lat = locations?.[0]?.latitude
|
||||
parsed.raised_in_lon = locations?.[0]?.longitude
|
||||
parsed.raised_in_country ??= locations?.[0]?.country
|
||||
}
|
||||
}
|
||||
|
||||
await setCachedResult(cacheKey, parsed)
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
export async function fetchOnlineProfile(url: string | undefined): Promise<JSONContent> {
|
||||
if (!url) throw APIErrors.badRequest('Content or URL is required')
|
||||
|
||||
try {
|
||||
// 1. Google Docs shortcut
|
||||
const googleDocId = extractGoogleDocId(url)
|
||||
if (googleDocId) {
|
||||
url = `https://docs.google.com/document/d/${googleDocId}/export?format=html`
|
||||
}
|
||||
|
||||
// 2. Fetch with proper headers
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (compatible; bot/1.0)',
|
||||
Accept: 'text/html,text/plain,*/*',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') ?? ''
|
||||
const content = await response.text()
|
||||
|
||||
log('Fetched content from URL', {url, contentType, contentLength: content.length})
|
||||
debug({content})
|
||||
|
||||
// 3. Route by content type
|
||||
return convertToJSONContent(content, contentType, url)
|
||||
} catch (error) {
|
||||
log('Error fetching URL', {url, error})
|
||||
throw APIErrors.badRequest('Failed to fetch content from URL')
|
||||
}
|
||||
}
|
||||
|
||||
export const llmExtractProfileEndpoint: APIHandler<'llm-extract-profile'> = async (
|
||||
parsedBody,
|
||||
auth,
|
||||
) => {
|
||||
const {url, locale} = parsedBody
|
||||
let content = parsedBody.content
|
||||
|
||||
log('Extracting profile from content', {
|
||||
contentLength: content?.length,
|
||||
url,
|
||||
locale,
|
||||
userId: auth.uid,
|
||||
})
|
||||
|
||||
if (content && url) {
|
||||
throw APIErrors.badRequest('Content and URL cannot be provided together')
|
||||
}
|
||||
|
||||
let bio
|
||||
if (!content) {
|
||||
bio = await fetchOnlineProfile(url)
|
||||
debug(JSON.stringify(bio, null, 2))
|
||||
content = parseJsonContentToText(bio)
|
||||
}
|
||||
|
||||
const extracted = await callLLM(content, locale)
|
||||
|
||||
if (bio) {
|
||||
extracted.bio = bio
|
||||
}
|
||||
|
||||
log('Profile extracted successfully', {extracted})
|
||||
|
||||
return extracted
|
||||
}
|
||||
@@ -1,8 +1,13 @@
|
||||
import {ValidatedAPIParams} from 'common/api/schema'
|
||||
import {geodbFetch} from 'common/geodb'
|
||||
|
||||
import {APIHandler} from './helpers/endpoint'
|
||||
|
||||
export const searchLocation: APIHandler<'search-location'> = async (body) => {
|
||||
export const searchLocationEndpoint: APIHandler<'search-location'> = async (body) => {
|
||||
return await searchLocation(body)
|
||||
}
|
||||
|
||||
export async function searchLocation(body: ValidatedAPIParams<'search-location'>) {
|
||||
const {term, limit} = body
|
||||
const endpoint = `/cities?namePrefix=${term}&limit=${limit ?? 10}&offset=0&sort=-population`
|
||||
// const endpoint = `/countries?namePrefix=${term}&limit=${limit ?? 10}&offset=0`
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
import {APIErrors, APIHandler} from 'api/helpers/endpoint'
|
||||
import {OPTION_TABLES} from 'common/profiles/constants'
|
||||
import {OptionTableKey} from 'common/profiles/constants'
|
||||
import {validateTable} from 'common/profiles/options'
|
||||
import {tryCatch} from 'common/util/try-catch'
|
||||
import {createSupabaseDirectClient, SupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {log} from 'shared/utils'
|
||||
|
||||
function validateTable(table: 'interests' | 'causes' | 'work') {
|
||||
if (!OPTION_TABLES.includes(table)) throw APIErrors.badRequest('Invalid table')
|
||||
}
|
||||
|
||||
export async function setProfileOptions(
|
||||
tx: SupabaseDirectClient,
|
||||
profileId: number,
|
||||
userId: string,
|
||||
table: 'interests' | 'causes' | 'work',
|
||||
table: OptionTableKey,
|
||||
values: string[] | undefined | null,
|
||||
) {
|
||||
validateTable(table)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
jest.mock('shared/supabase/init')
|
||||
jest.mock('common/util/try-catch')
|
||||
|
||||
import {getOptions} from 'api/get-options'
|
||||
import {getOptionsEndpoint} from 'api/get-options'
|
||||
import {AuthedUser} from 'api/helpers/endpoint'
|
||||
import {sqlMatch} from 'common/test-utils'
|
||||
import {tryCatch} from 'common/util/try-catch'
|
||||
@@ -31,7 +31,7 @@ describe('getOptions', () => {
|
||||
;(mockPg.manyOrNone as jest.Mock).mockResolvedValue(null)
|
||||
;(tryCatch as jest.Mock).mockResolvedValue({data: mockData, error: null})
|
||||
|
||||
const result: any = await getOptions({table: mockTable}, mockAuth, mockReq)
|
||||
const result: any = await getOptionsEndpoint({table: mockTable}, mockAuth, mockReq)
|
||||
|
||||
expect(result.names).toContain(mockData[0].name)
|
||||
expect(mockPg.manyOrNone).toBeCalledTimes(1)
|
||||
@@ -48,7 +48,9 @@ describe('getOptions', () => {
|
||||
|
||||
jest.spyOn(Array.prototype, 'includes').mockReturnValue(false)
|
||||
|
||||
expect(getOptions({table: mockTable}, mockAuth, mockReq)).rejects.toThrow('Invalid table')
|
||||
expect(getOptionsEndpoint({table: mockTable}, mockAuth, mockReq)).rejects.toThrow(
|
||||
'Invalid table',
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw if unable to get profile options', async () => {
|
||||
@@ -60,7 +62,7 @@ describe('getOptions', () => {
|
||||
;(mockPg.manyOrNone as jest.Mock).mockResolvedValue(null)
|
||||
;(tryCatch as jest.Mock).mockResolvedValue({data: null, error: Error})
|
||||
|
||||
expect(getOptions({table: mockTable}, mockAuth, mockReq)).rejects.toThrow(
|
||||
expect(getOptionsEndpoint({table: mockTable}, mockAuth, mockReq)).rejects.toThrow(
|
||||
'Error getting profile options',
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
jest.mock('common/geodb')
|
||||
|
||||
import {AuthedUser} from 'api/helpers/endpoint'
|
||||
import {searchLocation} from 'api/search-location'
|
||||
import {searchLocationEndpoint} from 'api/search-location'
|
||||
import * as geodbModules from 'common/geodb'
|
||||
|
||||
describe('searchLocation', () => {
|
||||
@@ -24,7 +24,7 @@ describe('searchLocation', () => {
|
||||
|
||||
;(geodbModules.geodbFetch as jest.Mock).mockResolvedValue(mockReturn)
|
||||
|
||||
const result = await searchLocation(mockBody, mockAuth, mockReq)
|
||||
const result = await searchLocationEndpoint(mockBody, mockAuth, mockReq)
|
||||
|
||||
expect(result).toBe(mockReturn)
|
||||
expect(geodbModules.geodbFetch).toBeCalledTimes(1)
|
||||
|
||||
458
backend/shared/src/parse.ts
Normal file
458
backend/shared/src/parse.ts
Normal file
@@ -0,0 +1,458 @@
|
||||
import {Readability} from '@mozilla/readability'
|
||||
import {JSONContent} from '@tiptap/core'
|
||||
import {debug} from 'common/logger'
|
||||
import {JSDOM} from 'jsdom'
|
||||
import {marked} from 'marked'
|
||||
|
||||
export function htmlToJSONContent(html: string, url: string): JSONContent {
|
||||
const originalDom = new JSDOM(html, {url})
|
||||
const classStyles = extractClassStyles(originalDom.window.document)
|
||||
|
||||
const isGoogleDoc = !!extractGoogleDocId(url)
|
||||
if (!isGoogleDoc) {
|
||||
const reader = new Readability(originalDom.window.document)
|
||||
const article = reader.parse()
|
||||
if (article?.content) {
|
||||
const cleanDom = new JSDOM(article.content)
|
||||
return parseHtmlBodyToJSONContent(cleanDom.window.document, classStyles)
|
||||
}
|
||||
}
|
||||
|
||||
return parseHtmlBodyToJSONContent(originalDom.window.document, classStyles)
|
||||
}
|
||||
|
||||
function plainTextToJSONContent(text: string): JSONContent {
|
||||
const paragraphs = text
|
||||
.split(/\n{2,}/) // split on blank lines
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean)
|
||||
.map((p) => ({
|
||||
type: 'paragraph' as const,
|
||||
content: [{type: 'text' as const, text: p}],
|
||||
}))
|
||||
|
||||
return {type: 'doc', content: paragraphs}
|
||||
}
|
||||
|
||||
function extractClassStyles(document: Document): Map<string, Record<string, string>> {
|
||||
const classStyles = new Map<string, Record<string, string>>()
|
||||
|
||||
for (const styleEl of document.querySelectorAll('style')) {
|
||||
const css = styleEl.textContent ?? ''
|
||||
|
||||
// Match .className { prop: value; prop: value }
|
||||
const ruleRegex = /\.([a-zA-Z0-9_-]+)\s*\{([^}]+)}/g
|
||||
let match
|
||||
while ((match = ruleRegex.exec(css)) !== null) {
|
||||
const className = match[1]
|
||||
const declarations = match[2]
|
||||
const styles = parseStyleString(declarations)
|
||||
classStyles.set(className, styles)
|
||||
}
|
||||
}
|
||||
|
||||
return classStyles
|
||||
}
|
||||
|
||||
export function parseHtmlBodyToJSONContent(
|
||||
document: Document,
|
||||
classStyles?: Map<string, Record<string, string>>,
|
||||
): JSONContent {
|
||||
const body = document.body
|
||||
classStyles ??= extractClassStyles(document)
|
||||
const content = parseBlockElements(body.children, classStyles)
|
||||
return {type: 'doc', content}
|
||||
}
|
||||
|
||||
function parseBlockElements(
|
||||
children: HTMLCollection | Element[],
|
||||
classStyles: Map<string, Record<string, string>>,
|
||||
): JSONContent[] {
|
||||
const content: JSONContent[] = []
|
||||
|
||||
for (const el of Array.from(children)) {
|
||||
const tag = el.tagName.toLowerCase()
|
||||
const node = parseBlockElement(el, tag, classStyles)
|
||||
if (!node) continue
|
||||
|
||||
if ((node as any).type === '__fragment') {
|
||||
// Recursively flatten — fragments can contain fragments
|
||||
content.push(...flattenFragment(node as any))
|
||||
} else {
|
||||
content.push(node)
|
||||
}
|
||||
}
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
function flattenFragment(node: any): JSONContent[] {
|
||||
return node.content.flatMap((child: any) =>
|
||||
child.type === '__fragment' ? flattenFragment(child) : [child],
|
||||
)
|
||||
}
|
||||
|
||||
function parseBlockElement(
|
||||
el: Element,
|
||||
tag: string,
|
||||
classStyles: Map<string, Record<string, string>>,
|
||||
): JSONContent | null {
|
||||
// console.debug('parseBlockElement', {tag, el})
|
||||
// Headings h1–h6
|
||||
if (/^h[1-6]$/.test(tag)) {
|
||||
return {
|
||||
type: 'heading',
|
||||
attrs: {level: parseInt(tag[1])},
|
||||
content: parseInlineElements(el, classStyles),
|
||||
}
|
||||
}
|
||||
|
||||
// Paragraph
|
||||
if (tag === 'p') {
|
||||
const inline = parseInlineElements(el, classStyles)
|
||||
return inline.length > 0 ? {type: 'paragraph', content: inline} : null
|
||||
}
|
||||
|
||||
// Lists
|
||||
if (tag === 'ol') {
|
||||
return {
|
||||
type: 'orderedList',
|
||||
attrs: {start: 1}, // ← required by TipTap's OrderedList extension
|
||||
content: parseListItems(el, classStyles),
|
||||
}
|
||||
}
|
||||
if (tag === 'ul') {
|
||||
return {
|
||||
type: 'bulletList',
|
||||
attrs: {},
|
||||
content: parseListItems(el, classStyles),
|
||||
}
|
||||
}
|
||||
// Blockquote
|
||||
if (tag === 'blockquote') {
|
||||
return {
|
||||
type: 'blockquote',
|
||||
content: parseBlockElements(el.children, classStyles),
|
||||
}
|
||||
}
|
||||
|
||||
// Code block <pre><code>...</code></pre>
|
||||
if (tag === 'pre') {
|
||||
const codeEl = el.querySelector('code')
|
||||
const language = codeEl?.className.match(/language-(\w+)/)?.[1] ?? null
|
||||
return {
|
||||
type: 'codeBlock',
|
||||
attrs: {language},
|
||||
content: [{type: 'text', text: (codeEl ?? el).textContent ?? ''}],
|
||||
}
|
||||
}
|
||||
|
||||
// Inline code outside of pre (treat as paragraph)
|
||||
if (tag === 'code') {
|
||||
return {
|
||||
type: 'paragraph',
|
||||
content: [{type: 'text', text: el.textContent ?? '', marks: [{type: 'code'}]}],
|
||||
}
|
||||
}
|
||||
|
||||
// Horizontal rule
|
||||
if (tag === 'hr') {
|
||||
return {type: 'horizontalRule'}
|
||||
}
|
||||
|
||||
// Image
|
||||
if (tag === 'img') {
|
||||
const src = el.getAttribute('src')
|
||||
if (!src || !src.startsWith('http')) return null
|
||||
return {
|
||||
type: 'image',
|
||||
attrs: {
|
||||
src,
|
||||
alt: el.getAttribute('alt') ?? null,
|
||||
title: el.getAttribute('title') ?? null,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Figure (image + optional caption)
|
||||
if (tag === 'figure') {
|
||||
const img = el.querySelector('img')
|
||||
const caption = el.querySelector('figcaption')?.textContent ?? null
|
||||
const src = img?.getAttribute('src')
|
||||
if (!src || !src.startsWith('http')) return null
|
||||
return {
|
||||
type: 'image',
|
||||
attrs: {
|
||||
src: img?.getAttribute('src'),
|
||||
alt: img?.getAttribute('alt') ?? caption,
|
||||
title: caption,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Table
|
||||
if (tag === 'table') {
|
||||
return parseTable(el, classStyles)
|
||||
}
|
||||
|
||||
// Container elements — recurse into children
|
||||
if (['div', 'section', 'article', 'main', 'header', 'footer', 'aside'].includes(tag)) {
|
||||
const inner = parseBlockElements(el.children, classStyles)
|
||||
if (inner.length === 0) return null
|
||||
if (inner.length === 1) return inner[0]
|
||||
|
||||
// Always use fragment — never paragraph — for multiple block children
|
||||
return {type: '__fragment', content: inner} as any
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function parseStyleString(style: string): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
style
|
||||
.split(';')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.map((declaration) => {
|
||||
const [prop, ...rest] = declaration.split(':')
|
||||
const value = rest.join(':').trim()
|
||||
// Convert kebab-case to camelCase (e.g. font-weight → fontWeight)
|
||||
const camelProp = prop.trim().replace(/-([a-z])/g, (_, c) => c.toUpperCase())
|
||||
return [camelProp, value]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function parseListItems(
|
||||
listEl: Element,
|
||||
classStyles: Map<string, Record<string, string>>,
|
||||
): JSONContent[] {
|
||||
return Array.from(listEl.querySelectorAll(':scope > li')).map((li) => {
|
||||
const nestedList = li.querySelector('ul, ol')
|
||||
const blockContent: JSONContent[] = [
|
||||
{type: 'paragraph', content: parseInlineElements(li, classStyles, true)},
|
||||
]
|
||||
|
||||
if (nestedList) {
|
||||
const nestedTag = nestedList.tagName.toLowerCase()
|
||||
blockContent.push({
|
||||
type: nestedTag === 'ul' ? 'bulletList' : 'orderedList',
|
||||
content: parseListItems(nestedList, classStyles),
|
||||
})
|
||||
}
|
||||
|
||||
return {type: 'listItem', content: blockContent}
|
||||
})
|
||||
}
|
||||
|
||||
function parseTable(
|
||||
tableEl: Element,
|
||||
classStyles: Map<string, Record<string, string>>,
|
||||
): JSONContent {
|
||||
const rows = Array.from(tableEl.querySelectorAll('tr'))
|
||||
|
||||
return {
|
||||
type: 'table',
|
||||
content: rows.map((row, rowIndex) => ({
|
||||
type: 'tableRow',
|
||||
content: Array.from(row.querySelectorAll('td, th')).map((cell) => ({
|
||||
type: rowIndex === 0 || cell.tagName.toLowerCase() === 'th' ? 'tableHeader' : 'tableCell',
|
||||
attrs: {
|
||||
colspan: parseInt(cell.getAttribute('colspan') ?? '1'),
|
||||
rowspan: parseInt(cell.getAttribute('rowspan') ?? '1'),
|
||||
},
|
||||
content: [{type: 'paragraph', content: parseInlineElements(cell, classStyles)}],
|
||||
})),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
function parseInlineElements(
|
||||
el: Element,
|
||||
classStyles: Map<string, Record<string, string>>,
|
||||
skipNested = false,
|
||||
): JSONContent[] {
|
||||
const nodes: JSONContent[] = []
|
||||
|
||||
for (const child of el.childNodes) {
|
||||
// Plain text node
|
||||
if (child.nodeType === 3) {
|
||||
let text = child.textContent ?? ''
|
||||
|
||||
// Remove HTML tags from text
|
||||
text = text.replace('<aside>', '\n').replace('</aside>', '\n')
|
||||
|
||||
if (text.trim()) nodes.push({type: 'text', text})
|
||||
continue
|
||||
}
|
||||
|
||||
if (child.nodeType !== 1) continue
|
||||
const childEl = child as Element
|
||||
const tag = childEl.tagName.toLowerCase()
|
||||
|
||||
// Skip nested lists when extracting list item text
|
||||
if (skipNested && ['ul', 'ol'].includes(tag)) continue
|
||||
|
||||
// Line break
|
||||
if (tag === 'br') {
|
||||
nodes.push({type: 'hardBreak'})
|
||||
continue
|
||||
}
|
||||
|
||||
// Inline image
|
||||
if (tag === 'img') {
|
||||
const src = childEl.getAttribute('src')
|
||||
if (src && src.startsWith('http')) nodes.push({type: 'image', attrs: {src}})
|
||||
continue
|
||||
}
|
||||
|
||||
// Marks
|
||||
const marks = getMarks(childEl, tag, classStyles)
|
||||
|
||||
const isInlineContainer = [
|
||||
'span',
|
||||
'a',
|
||||
'strong',
|
||||
'em',
|
||||
'b',
|
||||
'i',
|
||||
'u',
|
||||
's',
|
||||
'mark',
|
||||
'code',
|
||||
'label',
|
||||
].includes(tag)
|
||||
const hasChildElements = childEl.children.length > 0
|
||||
|
||||
if (isInlineContainer && hasChildElements) {
|
||||
// Recurse into children and apply this element's marks on top
|
||||
const innerNodes = parseInlineElements(childEl, classStyles, skipNested)
|
||||
for (const inner of innerNodes) {
|
||||
if (inner.type === 'text' && marks.length > 0) {
|
||||
// Merge marks — avoid duplicates
|
||||
const existingTypes = new Set((inner.marks ?? []).map((m: any) => m.type))
|
||||
const newMarks = marks.filter((m) => !existingTypes.has(m.type as string))
|
||||
nodes.push({
|
||||
...inner,
|
||||
marks: [...(inner.marks ?? []), ...newMarks],
|
||||
} as JSONContent)
|
||||
} else {
|
||||
nodes.push(inner)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const text = childEl.textContent ?? ''
|
||||
if (!text) continue
|
||||
|
||||
nodes.push({
|
||||
type: 'text',
|
||||
text,
|
||||
...(marks.length > 0 && {marks: marks as Array<{type: string; attrs?: Record<string, any>}>}),
|
||||
})
|
||||
}
|
||||
|
||||
return nodes
|
||||
}
|
||||
|
||||
function getMarks(
|
||||
el: Element,
|
||||
tag: string,
|
||||
classStyles: Map<string, Record<string, string>>,
|
||||
): JSONContent[] {
|
||||
const marks: JSONContent[] = []
|
||||
|
||||
if (['b', 'strong'].includes(tag)) marks.push({type: 'bold'})
|
||||
if (['i', 'em'].includes(tag)) marks.push({type: 'italic'})
|
||||
if (tag === 'u') marks.push({type: 'underline'})
|
||||
if (['s', 'strike', 'del'].includes(tag)) marks.push({type: 'strike'})
|
||||
if (tag === 'code') marks.push({type: 'code'})
|
||||
if (tag === 'mark') marks.push({type: 'highlight'})
|
||||
|
||||
if (tag === 'a') {
|
||||
const href = cleanHref(el.getAttribute('href') ?? '')
|
||||
marks.push({
|
||||
type: 'link',
|
||||
attrs: {href, target: '_blank'},
|
||||
})
|
||||
}
|
||||
|
||||
const style: Record<string, string> = {}
|
||||
const classes = Array.from(el.classList)
|
||||
for (const cls of classes) {
|
||||
const resolved = classStyles.get(cls)
|
||||
if (resolved) Object.assign(style, resolved)
|
||||
}
|
||||
const inlineStyle = parseStyleString(el.getAttribute('style') ?? '')
|
||||
Object.assign(style, inlineStyle)
|
||||
|
||||
if (!marks.find((m) => m.type === 'bold') && /^(bold|[7-9]\d{2})$/.test(style.fontWeight ?? '')) {
|
||||
marks.push({type: 'bold'})
|
||||
}
|
||||
|
||||
if (!marks.find((m) => m.type === 'italic') && style.fontStyle === 'italic') {
|
||||
marks.push({type: 'italic'})
|
||||
}
|
||||
|
||||
if (style.textDecoration?.includes('underline') && !marks.find((m) => m.type === 'underline')) {
|
||||
marks.push({type: 'underline'})
|
||||
}
|
||||
|
||||
if (style.textDecoration?.includes('line-through') && !marks.find((m) => m.type === 'strike')) {
|
||||
marks.push({type: 'strike'})
|
||||
}
|
||||
|
||||
return marks
|
||||
}
|
||||
|
||||
function cleanHref(href: string): string {
|
||||
try {
|
||||
const url = new URL(href)
|
||||
if (url.hostname === 'www.google.com' && url.pathname === '/url') {
|
||||
return url.searchParams.get('q') ?? href
|
||||
}
|
||||
} catch (error) {
|
||||
debug('Invalid URL:', href, error)
|
||||
}
|
||||
return href
|
||||
}
|
||||
|
||||
export function extractGoogleDocId(url: string) {
|
||||
const patterns = [
|
||||
/\/document\/d\/([a-zA-Z0-9-_]+)/, // standard /d/{id}/ format
|
||||
/id=([a-zA-Z0-9-_]+)/, // ?id= query param format
|
||||
/^([a-zA-Z0-9-_]+)$/, // raw ID passed directly
|
||||
]
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = url.match(pattern)
|
||||
if (match) return match[1]
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function markdownToJSONContent(markdown: string): JSONContent {
|
||||
const html = marked(markdown) as string
|
||||
const dom = new JSDOM(html)
|
||||
return parseHtmlBodyToJSONContent(dom.window.document)
|
||||
}
|
||||
|
||||
export function convertToJSONContent(
|
||||
content: string,
|
||||
contentType: string,
|
||||
url: string,
|
||||
): JSONContent {
|
||||
if (contentType.includes('text/html')) {
|
||||
return htmlToJSONContent(content, url) // use Readability for articles
|
||||
}
|
||||
|
||||
if (contentType.includes('text/markdown') || url.endsWith('.md')) {
|
||||
return markdownToJSONContent(content)
|
||||
}
|
||||
|
||||
// plain text fallback
|
||||
return plainTextToJSONContent(content)
|
||||
}
|
||||
@@ -17,6 +17,11 @@
|
||||
"@tiptap/extension-image": "2.10.4",
|
||||
"@tiptap/extension-link": "2.10.4",
|
||||
"@tiptap/extension-mention": "2.10.4",
|
||||
"@tiptap/extension-table": "2.10.4",
|
||||
"@tiptap/extension-table-cell": "2.10.4",
|
||||
"@tiptap/extension-table-header": "2.10.4",
|
||||
"@tiptap/extension-table-row": "2.10.4",
|
||||
"@tiptap/extension-underline": "2.10.4",
|
||||
"@tiptap/pm": "2.10.4",
|
||||
"@tiptap/starter-kit": "2.10.4",
|
||||
"@tiptap/suggestion": "2.10.4",
|
||||
|
||||
@@ -11,7 +11,7 @@ import {ChatMessage} from 'common/chat-message'
|
||||
import {Notification} from 'common/notifications'
|
||||
import {CompatibilityScore} from 'common/profiles/compatibility-score'
|
||||
import {MAX_COMPATIBILITY_QUESTION_LENGTH, OPTION_TABLES} from 'common/profiles/constants'
|
||||
import {Profile, ProfileRow} from 'common/profiles/profile'
|
||||
import {Profile, ProfileRow, ProfileWithoutUser} from 'common/profiles/profile'
|
||||
import {Stats} from 'common/stats' // mqp: very unscientific, just balancing our willingness to accept load
|
||||
import {PrivateMessageChannel} from 'common/supabase/private-messages'
|
||||
import {Row} from 'common/supabase/utils'
|
||||
@@ -1266,6 +1266,21 @@ export const API = (_apiTypeCheck = {
|
||||
summary: 'Validate if a username is available',
|
||||
tag: 'Users',
|
||||
},
|
||||
'llm-extract-profile': {
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
props: z
|
||||
.object({
|
||||
content: z.string().min(1).optional(),
|
||||
url: z.string().url().optional(),
|
||||
locale: z.string().optional(),
|
||||
})
|
||||
.strict(),
|
||||
returns: {} as Partial<ProfileWithoutUser>,
|
||||
summary: 'Extract profile information from text using LLM',
|
||||
tag: 'Profiles',
|
||||
},
|
||||
} as const)
|
||||
|
||||
export type APIPath = keyof typeof API
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import {debug} from 'common/logger'
|
||||
import {ProfileRow} from 'common/profiles/profile'
|
||||
import {sleep} from 'common/util/time'
|
||||
|
||||
export const geodbHost = 'wft-geo-db.p.rapidapi.com'
|
||||
|
||||
export const geodbFetch = async (endpoint: string) => {
|
||||
export const geodbFetch = async (
|
||||
endpoint: string,
|
||||
): Promise<{status: 'success' | 'failure'; data: any}> => {
|
||||
const apiKey = process.env.GEODB_API_KEY
|
||||
if (!apiKey) {
|
||||
return {status: 'failure', data: 'Missing GEODB API key'}
|
||||
@@ -21,6 +24,11 @@ export const geodbFetch = async (endpoint: string) => {
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 429) {
|
||||
debug('geodbFetch', endpoint, 'Rate limited')
|
||||
await sleep(1100)
|
||||
return geodbFetch(endpoint)
|
||||
}
|
||||
throw new Error(`HTTP error! Status: ${res.status} ${await res.text()}`)
|
||||
}
|
||||
|
||||
|
||||
@@ -11,3 +11,35 @@ export function trimStrings<T extends Record<string, unknown | string>>(body: T)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
export const isUrl = (text: string): boolean => {
|
||||
if (!text || typeof text !== 'string') return false
|
||||
|
||||
// Remove leading/trailing whitespace
|
||||
const trimmedText = text.trim()
|
||||
|
||||
// If it already starts with a protocol, test as-is
|
||||
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmedText)) {
|
||||
try {
|
||||
new URL(trimmedText)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Try adding https:// prefix for common domain patterns
|
||||
if (
|
||||
/^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/.test(trimmedText) ||
|
||||
/^www\.[a-zA-Z0-9.-]+/.test(trimmedText)
|
||||
) {
|
||||
try {
|
||||
new URL(`https://${trimmedText}`)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
6
common/src/profiles/options.ts
Normal file
6
common/src/profiles/options.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import {APIErrors} from 'common/api/utils'
|
||||
import {OPTION_TABLES} from 'common/profiles/constants'
|
||||
|
||||
export function validateTable(table: 'interests' | 'causes' | 'work') {
|
||||
if (!OPTION_TABLES.includes(table)) throw APIErrors.badRequest('Invalid table')
|
||||
}
|
||||
@@ -28,6 +28,7 @@ export const secrets = (
|
||||
'VAPID_PRIVATE_KEY',
|
||||
'DB_ENC_MASTER_KEY_BASE64',
|
||||
'GOOGLE_CLIENT_SECRET',
|
||||
'GEMINI_API_KEY',
|
||||
// Some typescript voodoo to keep the string literal types while being not readonly.
|
||||
] as const
|
||||
).concat()
|
||||
|
||||
@@ -2,6 +2,11 @@ import {getSchema, getText, getTextSerializersFromSchema, JSONContent} from '@ti
|
||||
import {Image} from '@tiptap/extension-image'
|
||||
import {Link} from '@tiptap/extension-link'
|
||||
import {Mention} from '@tiptap/extension-mention'
|
||||
import Table from '@tiptap/extension-table'
|
||||
import TableCell from '@tiptap/extension-table-cell'
|
||||
import TableHeader from '@tiptap/extension-table-header'
|
||||
import TableRow from '@tiptap/extension-table-row'
|
||||
import Underline from '@tiptap/extension-underline'
|
||||
import {Node as ProseMirrorNode} from '@tiptap/pm/model'
|
||||
import {StarterKit} from '@tiptap/starter-kit'
|
||||
import {find} from 'linkifyjs'
|
||||
@@ -49,6 +54,11 @@ export const extensions = [
|
||||
Iframe.extend({
|
||||
renderText: ({node}) => ('[embed]' + node.attrs.src ? `(${node.attrs.src})` : ''),
|
||||
}),
|
||||
Table.configure({resizable: false}),
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
Underline,
|
||||
]
|
||||
|
||||
const extensionSchema = getSchema(extensions)
|
||||
|
||||
@@ -5,3 +5,8 @@ export function removeEmojis(str: string) {
|
||||
'',
|
||||
)
|
||||
}
|
||||
|
||||
export function urlize(s: string) {
|
||||
if (s.startsWith('http://') || s.startsWith('https://')) return s
|
||||
return `https://${s}`
|
||||
}
|
||||
|
||||
21
package.json
21
package.json
@@ -52,11 +52,32 @@
|
||||
"@tiptap/extension-blockquote": "2.10.4",
|
||||
"@tiptap/extension-bold": "2.10.4",
|
||||
"@tiptap/extension-bubble-menu": "2.10.4",
|
||||
"@tiptap/extension-bullet-list": "2.10.4",
|
||||
"@tiptap/extension-code": "2.10.4",
|
||||
"@tiptap/extension-code-block": "2.10.4",
|
||||
"@tiptap/extension-document": "2.10.4",
|
||||
"@tiptap/extension-dropcursor": "2.10.4",
|
||||
"@tiptap/extension-floating-menu": "2.10.4",
|
||||
"@tiptap/extension-gapcursor": "2.10.4",
|
||||
"@tiptap/extension-hard-break": "2.10.4",
|
||||
"@tiptap/extension-heading": "2.10.4",
|
||||
"@tiptap/extension-history": "2.10.4",
|
||||
"@tiptap/extension-horizontal-rule": "2.10.4",
|
||||
"@tiptap/extension-image": "2.10.4",
|
||||
"@tiptap/extension-italic": "2.10.4",
|
||||
"@tiptap/extension-link": "2.10.4",
|
||||
"@tiptap/extension-list-item": "2.10.4",
|
||||
"@tiptap/extension-mention": "2.10.4",
|
||||
"@tiptap/extension-ordered-list": "2.10.4",
|
||||
"@tiptap/extension-paragraph": "2.10.4",
|
||||
"@tiptap/extension-strike": "2.10.4",
|
||||
"@tiptap/extension-table": "2.10.4",
|
||||
"@tiptap/extension-table-cell": "2.10.4",
|
||||
"@tiptap/extension-table-header": "2.10.4",
|
||||
"@tiptap/extension-table-row": "2.10.4",
|
||||
"@tiptap/extension-text": "2.10.4",
|
||||
"@tiptap/extension-text-style": "2.10.4",
|
||||
"@tiptap/extension-underline": "2.10.4",
|
||||
"@tiptap/html": "2.10.4",
|
||||
"@tiptap/pm": "2.10.4",
|
||||
"@tiptap/starter-kit": "2.10.4",
|
||||
|
||||
@@ -10,6 +10,7 @@ cd "$(dirname "$0")"/..
|
||||
|
||||
export NEXT_PUBLIC_ISOLATED_ENV=true
|
||||
|
||||
export $(cat .env.local | grep -v '^#' | xargs)
|
||||
export $(cat .env.test | grep -v '^#' | xargs)
|
||||
|
||||
# Ensure Supabase local stack is running; if not, reset/start it
|
||||
|
||||
@@ -203,3 +203,33 @@ export function BaseBio({defaultValue, onBlur, onEditor, onClickTips}: BaseBioPr
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface BaseTextEditorProps {
|
||||
placeholder?: any
|
||||
defaultValue?: any
|
||||
onBlur?: (editor: any) => void
|
||||
onEditor?: (editor: any) => void
|
||||
onChange?: () => void
|
||||
}
|
||||
|
||||
export function BaseTextEditor({
|
||||
placeholder,
|
||||
defaultValue,
|
||||
onBlur,
|
||||
onEditor,
|
||||
onChange,
|
||||
}: BaseTextEditorProps) {
|
||||
const t = useT()
|
||||
const editor = useTextEditor({
|
||||
max: MAX_DESCRIPTION_LENGTH,
|
||||
defaultValue: defaultValue,
|
||||
placeholder: placeholder ?? t('common.text_editor.placeholder', 'Write here...'),
|
||||
onChange: onChange,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
onEditor?.(editor)
|
||||
}, [editor, onEditor])
|
||||
|
||||
return <TextEditor editor={editor} onBlur={() => onBlur?.(editor)} />
|
||||
}
|
||||
|
||||
@@ -29,7 +29,15 @@ export function FloatingFormatMenu(props: {
|
||||
const unsetLink = () => editor.chain().focus().unsetLink().run()
|
||||
|
||||
return (
|
||||
<BubbleMenu editor={editor} className="text-ink-0 bg-ink-700 flex gap-2 rounded-sm p-1">
|
||||
<BubbleMenu
|
||||
editor={editor}
|
||||
shouldShow={({state}) => {
|
||||
// CellSelection has $anchorCell, regular selections don't
|
||||
if ('$anchorCell' in state.selection) return false
|
||||
return !state.selection.empty
|
||||
}}
|
||||
className="text-ink-0 bg-ink-700 flex gap-2 rounded-sm p-1"
|
||||
>
|
||||
{url === null ? (
|
||||
<>
|
||||
{advanced && (
|
||||
|
||||
65
web/components/llm-extract-section.tsx
Normal file
65
web/components/llm-extract-section.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import {isUrl} from 'common/parsing'
|
||||
import {useT} from 'web/lib/locale'
|
||||
|
||||
import {BaseTextEditor} from './bio/editable-bio'
|
||||
import {Button} from './buttons/button'
|
||||
import {Col} from './layout/col'
|
||||
|
||||
interface LLMExtractSectionProps {
|
||||
parsingEditor: any
|
||||
setParsingEditor: (editor: any) => void
|
||||
isExtracting: boolean
|
||||
isSubmitting: boolean
|
||||
onExtract: () => void
|
||||
}
|
||||
|
||||
export function LLMExtractSection({
|
||||
parsingEditor,
|
||||
setParsingEditor,
|
||||
isExtracting,
|
||||
isSubmitting,
|
||||
onExtract,
|
||||
}: LLMExtractSectionProps) {
|
||||
const t = useT()
|
||||
const parsingText = parsingEditor?.getText?.()
|
||||
|
||||
return (
|
||||
<Col className={'gap-4'}>
|
||||
<div className="">
|
||||
{t(
|
||||
'profile.llm.extract.description',
|
||||
'Auto-fill your profile by dropping a link (LinkedIn, Notion, Google Docs, personal website, etc.) or pasting your content directly.',
|
||||
)}
|
||||
</div>
|
||||
<div className="guidance">
|
||||
{t(
|
||||
'profile.llm.extract.guidance',
|
||||
'Heads up: we use Google AI to extract your info. Google may use this content to improve their models. Prefer to keep things private? Just fill the form manually — no AI involved.',
|
||||
)}
|
||||
</div>
|
||||
<BaseTextEditor
|
||||
onEditor={(e) => {
|
||||
if (e) setParsingEditor(e)
|
||||
}}
|
||||
onChange={() => {
|
||||
// Trigger re-render to update button state and text on every key stroke
|
||||
setParsingEditor({...parsingEditor})
|
||||
}}
|
||||
placeholder={t(
|
||||
'profile.llm.extract.placeholder',
|
||||
'Insert a URL or paste your profile content here.',
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
onClick={onExtract}
|
||||
disabled={isExtracting || !parsingEditor?.getJSON?.() || isSubmitting}
|
||||
loading={isExtracting}
|
||||
className="self-start"
|
||||
>
|
||||
{isUrl(parsingText)
|
||||
? t('profile.llm.extract.button_url', 'Extract Profile Data from URL')
|
||||
: t('profile.llm.extract.button_text', 'Extract Profile Data from Text')}
|
||||
</Button>
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import {PlusIcon, XMarkIcon} from '@heroicons/react/24/solid'
|
||||
import {Editor} from '@tiptap/react'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
@@ -15,36 +14,38 @@ import {
|
||||
ROMANTIC_CHOICES,
|
||||
} from 'common/choices'
|
||||
import {debug} from 'common/logger'
|
||||
import {isUrl} from 'common/parsing'
|
||||
import {MultipleChoiceOptions} from 'common/profiles/multiple-choice'
|
||||
import {Profile, ProfileWithoutUser} from 'common/profiles/profile'
|
||||
import {PLATFORM_LABELS, type Site, SITE_ORDER, Socials} from 'common/socials'
|
||||
import {BaseUser} from 'common/user'
|
||||
import {range} from 'lodash'
|
||||
import {Fragment, useRef, useState} from 'react'
|
||||
import {urlize} from 'common/util/string'
|
||||
import {invert, range} from 'lodash'
|
||||
import {useRef, useState} from 'react'
|
||||
import Textarea from 'react-expanding-textarea'
|
||||
import toast from 'react-hot-toast'
|
||||
import {AddOptionEntry} from 'web/components/add-option-entry'
|
||||
import {SignupBio} from 'web/components/bio/editable-bio'
|
||||
import {Button, IconButton} from 'web/components/buttons/button'
|
||||
import {Button} from 'web/components/buttons/button'
|
||||
import {Col} from 'web/components/layout/col'
|
||||
import {Row} from 'web/components/layout/row'
|
||||
import {CustomLink} from 'web/components/links'
|
||||
import {LLMExtractSection} from 'web/components/llm-extract-section'
|
||||
import {MultiCheckbox} from 'web/components/multi-checkbox'
|
||||
import {City, CityRow, profileToCity, useCitySearch} from 'web/components/search-location'
|
||||
import {SocialLinksSection} from 'web/components/social-links-section'
|
||||
import {Carousel} from 'web/components/widgets/carousel'
|
||||
import {ChoicesToggleGroup} from 'web/components/widgets/choices-toggle-group'
|
||||
import {Input} from 'web/components/widgets/input'
|
||||
import {PlatformSelect} from 'web/components/widgets/platform-select'
|
||||
import {RadioToggleGroup} from 'web/components/widgets/radio-toggle-group'
|
||||
import {Select} from 'web/components/widgets/select'
|
||||
import {Slider} from 'web/components/widgets/slider'
|
||||
import {Title} from 'web/components/widgets/title'
|
||||
import {useChoicesContext} from 'web/hooks/use-choices'
|
||||
import {api} from 'web/lib/api'
|
||||
import {useT} from 'web/lib/locale'
|
||||
import {track} from 'web/lib/service/analytics'
|
||||
import {colClassName, labelClassName} from 'web/pages/signup'
|
||||
|
||||
import {SocialIcon} from './user/social'
|
||||
import {AddPhotosWidget} from './widgets/add-photos'
|
||||
|
||||
export const OptionalProfileUserForm = (props: {
|
||||
@@ -59,20 +60,8 @@ export const OptionalProfileUserForm = (props: {
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [uploadingImages, setUploadingImages] = useState(false)
|
||||
const [lookingRelationship, setLookingRelationship] = useState(
|
||||
(profile.pref_relation_styles || []).includes('relationship'),
|
||||
)
|
||||
const [ageError, setAgeError] = useState<string | null>(null)
|
||||
const t = useT()
|
||||
const [heightFeet, setHeightFeet] = useState<number | undefined>(
|
||||
profile.height_in_inches ? Math.floor((profile['height_in_inches'] ?? 0) / 12) : undefined,
|
||||
)
|
||||
const [heightInches, setHeightInches] = useState<number | undefined>(
|
||||
profile.height_in_inches ? Math.floor((profile['height_in_inches'] ?? 0) % 12) : undefined,
|
||||
)
|
||||
|
||||
const [newLinkPlatform, setNewLinkPlatform] = useState('')
|
||||
const [newLinkValue, setNewLinkValue] = useState('')
|
||||
|
||||
const choices = useChoicesContext()
|
||||
const [interestChoices, setInterestChoices] = useState(choices.interests)
|
||||
@@ -81,13 +70,92 @@ export const OptionalProfileUserForm = (props: {
|
||||
|
||||
const [keywordsString, setKeywordsString] = useState<string>(profile.keywords?.join(', ') || '')
|
||||
|
||||
const lookingRelationship = (profile.pref_relation_styles || []).includes('relationship')
|
||||
|
||||
const heightFeet =
|
||||
typeof profile.height_in_inches === 'number'
|
||||
? Math.floor(profile.height_in_inches / 12)
|
||||
: undefined
|
||||
|
||||
const heightInches =
|
||||
typeof profile.height_in_inches === 'number' ? profile.height_in_inches % 12 : undefined
|
||||
|
||||
const [isExtracting, setIsExtracting] = useState(false)
|
||||
const [parsingEditor, setParsingEditor] = useState<any>(null)
|
||||
|
||||
const handleLLMExtract = async () => {
|
||||
const llmContent = parsingEditor?.getText?.() ?? ''
|
||||
if (!llmContent) {
|
||||
toast.error(t('profile.llm.extract.error_empty', 'Please enter content to extract from'))
|
||||
return
|
||||
}
|
||||
setIsExtracting(true)
|
||||
try {
|
||||
const isInputUrl = isUrl(llmContent)
|
||||
const payload = isInputUrl ? {url: urlize(llmContent).trim()} : {content: llmContent.trim()}
|
||||
|
||||
const extracted = await api('llm-extract-profile', payload)
|
||||
for (const data of Object.entries(extracted)) {
|
||||
const key = data[0]
|
||||
let value = data[1]
|
||||
let choices, setChoices: any
|
||||
if (key === 'interests') {
|
||||
choices = interestChoices
|
||||
setChoices = setInterestChoices
|
||||
} else if (key === 'causes') {
|
||||
choices = causeChoices
|
||||
setChoices = setCauseChoices
|
||||
} else if (key === 'work') {
|
||||
choices = workChoices
|
||||
setChoices = setWorkChoices
|
||||
}
|
||||
if (choices && setChoices) {
|
||||
const newFields: string[] = []
|
||||
const converter = invert(choices)
|
||||
value = (value as string[]).map((interest: string) => {
|
||||
if (!converter[interest]) newFields.push(interest)
|
||||
return converter[interest] ?? interest
|
||||
})
|
||||
if (newFields.length) {
|
||||
setChoices((prev: any) => ({
|
||||
...prev,
|
||||
...Object.fromEntries(newFields.map((e) => [e, e])),
|
||||
}))
|
||||
}
|
||||
debug({value, converter})
|
||||
} else if (key === 'keywords') setKeywordsString((value as string[]).join(', '))
|
||||
setProfile(
|
||||
key as keyof ProfileWithoutUser,
|
||||
value as ProfileWithoutUser[keyof ProfileWithoutUser],
|
||||
)
|
||||
}
|
||||
if (!isInputUrl) setProfile('bio', parsingEditor?.getJSON?.())
|
||||
debug({text: parsingEditor?.getText?.(), json: parsingEditor?.getJSON?.(), extracted})
|
||||
|
||||
parsingEditor?.commands?.clearContent?.()
|
||||
|
||||
toast.success(
|
||||
t('profile.llm.extract.success', 'Profile data extracted! Please review below.'),
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
toast.error(t('profile.llm.extract.error', 'Failed to extract profile data'))
|
||||
} finally {
|
||||
setIsExtracting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const errorToast = () => {
|
||||
toast.error(t('profile.optional.error.invalid_fields', 'Some fields are incorrect...'))
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (parsingEditor?.getText?.()?.trim()) {
|
||||
await handleLLMExtract()
|
||||
}
|
||||
|
||||
// Validate age before submitting
|
||||
if (profile['age'] !== null && profile['age'] !== undefined) {
|
||||
if (typeof profile['age'] === 'number') {
|
||||
if (profile['age'] < 18) {
|
||||
setAgeError(t('profile.optional.age.error_min', 'You must be at least 18 years old'))
|
||||
setIsSubmitting(false)
|
||||
@@ -115,18 +183,6 @@ export const OptionalProfileUserForm = (props: {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
|
||||
const updateUserLink = (platform: string, value: string | null) => {
|
||||
setProfile('links', {...((profile.links as Socials) ?? {}), [platform]: value})
|
||||
}
|
||||
|
||||
const addNewLink = () => {
|
||||
if (newLinkPlatform && newLinkValue) {
|
||||
updateUserLink(newLinkPlatform.toLowerCase().trim(), newLinkValue.trim())
|
||||
setNewLinkPlatform('')
|
||||
setNewLinkValue('')
|
||||
}
|
||||
}
|
||||
|
||||
function setProfileCity(inputCity: City | undefined) {
|
||||
if (!inputCity) {
|
||||
setProfile('geodb_city_id', null)
|
||||
@@ -189,19 +245,20 @@ export const OptionalProfileUserForm = (props: {
|
||||
|
||||
return (
|
||||
<>
|
||||
{/*<Row className={'justify-end'}>*/}
|
||||
{/* <Button*/}
|
||||
{/* disabled={isSubmitting}*/}
|
||||
{/* loading={isSubmitting}*/}
|
||||
{/* onClick={handleSubmit}*/}
|
||||
{/* >*/}
|
||||
{/* {buttonLabel ?? t('common.next', 'Next')} / {t('common.skip', 'Skip')}*/}
|
||||
{/* </Button>*/}
|
||||
{/*</Row>*/}
|
||||
|
||||
<Title>{t('profile.optional.subtitle', 'Optional information')}</Title>
|
||||
|
||||
<Col className={'gap-8'}>
|
||||
<Category title={t('profile.llm.extract.title', 'Auto-fill')} className={'mt-0'} />
|
||||
<LLMExtractSection
|
||||
parsingEditor={parsingEditor}
|
||||
setParsingEditor={setParsingEditor}
|
||||
isExtracting={isExtracting}
|
||||
isSubmitting={isSubmitting}
|
||||
onExtract={handleLLMExtract}
|
||||
/>
|
||||
|
||||
<hr className="border border-b my-4" />
|
||||
|
||||
<Category
|
||||
title={t('profile.optional.category.personal_info', 'Personal Information')}
|
||||
className={'mt-0'}
|
||||
@@ -286,13 +343,8 @@ export const OptionalProfileUserForm = (props: {
|
||||
type="number"
|
||||
data-testid="height-feet"
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.value === '') {
|
||||
setHeightFeet(undefined)
|
||||
} else {
|
||||
setHeightFeet(Number(e.target.value))
|
||||
const heightInInches = Number(e.target.value) * 12 + (heightInches ?? 0)
|
||||
setProfile('height_in_inches', heightInInches)
|
||||
}
|
||||
const heightInInches = Number(e.target.value || 0) * 12 + (heightInches ?? 0)
|
||||
setProfile('height_in_inches', heightInInches)
|
||||
}}
|
||||
className={'w-16'}
|
||||
value={typeof heightFeet === 'number' ? Math.floor(heightFeet) : ''}
|
||||
@@ -305,13 +357,8 @@ export const OptionalProfileUserForm = (props: {
|
||||
type="number"
|
||||
data-testid="height-inches"
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.value === '') {
|
||||
setHeightInches(undefined)
|
||||
} else {
|
||||
setHeightInches(Number(e.target.value))
|
||||
const heightInInches = Number(e.target.value) + 12 * (heightFeet ?? 0)
|
||||
setProfile('height_in_inches', heightInInches)
|
||||
}
|
||||
const heightInInches = Number(e.target.value || 0) + 12 * (heightFeet ?? 0)
|
||||
setProfile('height_in_inches', heightInInches)
|
||||
}}
|
||||
className={'w-16'}
|
||||
value={typeof heightInches === 'number' ? Math.floor(heightInches) : ''}
|
||||
@@ -328,14 +375,10 @@ export const OptionalProfileUserForm = (props: {
|
||||
data-testid="height-centimeters"
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.value === '') {
|
||||
setHeightFeet(undefined)
|
||||
setHeightInches(undefined)
|
||||
setProfile('height_in_inches', null)
|
||||
} else {
|
||||
// Convert cm to inches
|
||||
const totalInches = Number(e.target.value) / 2.54
|
||||
setHeightFeet(Math.floor(totalInches / 12))
|
||||
setHeightInches(totalInches % 12)
|
||||
setProfile('height_in_inches', totalInches)
|
||||
}
|
||||
}}
|
||||
@@ -449,7 +492,7 @@ export const OptionalProfileUserForm = (props: {
|
||||
.filter(Boolean)
|
||||
setProfile('keywords', keywords)
|
||||
}}
|
||||
className={'w-full sm:w-96'}
|
||||
className={'w-full sm:w-[600px]'}
|
||||
value={keywordsString}
|
||||
placeholder={t(
|
||||
'profile.optional.keywords_placeholder',
|
||||
@@ -534,7 +577,6 @@ export const OptionalProfileUserForm = (props: {
|
||||
translationPrefix={'profile.relationship'}
|
||||
onChange={(selected) => {
|
||||
setProfile('pref_relation_styles', selected)
|
||||
setLookingRelationship((selected || []).includes('relationship'))
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
@@ -915,70 +957,7 @@ export const OptionalProfileUserForm = (props: {
|
||||
|
||||
<Category title={t('profile.optional.socials', 'Socials')} />
|
||||
|
||||
<Col className={clsx(colClassName, 'pb-4')}>
|
||||
{/*<label className={clsx(labelClassName)}>*/}
|
||||
{/* {t('profile.optional.socials', 'Socials')}*/}
|
||||
{/*</label>*/}
|
||||
|
||||
<div className="grid w-full grid-cols-[8rem_1fr_auto] gap-2">
|
||||
{Object.entries((profile.links ?? {}) as Socials)
|
||||
.filter(([_, value]) => value != null)
|
||||
.map(([platform, value]) => (
|
||||
<Fragment key={platform}>
|
||||
<div className="col-span-3 mt-2 flex items-center gap-2 self-center sm:col-span-1">
|
||||
<SocialIcon site={platform as any} className="text-primary-700 h-4 w-4" />
|
||||
{PLATFORM_LABELS[platform as Site] ?? platform}
|
||||
</div>
|
||||
<Input
|
||||
type="text"
|
||||
value={value!}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
updateUserLink(platform, e.target.value)
|
||||
}
|
||||
className="col-span-2 sm:col-span-1"
|
||||
/>
|
||||
<IconButton onClick={() => updateUserLink(platform, null)}>
|
||||
<XMarkIcon className="h-6 w-6" />
|
||||
<div className="sr-only">{t('common.remove', 'Remove')}</div>
|
||||
</IconButton>
|
||||
</Fragment>
|
||||
))}
|
||||
|
||||
{/* Spacer */}
|
||||
<div className="col-span-3 h-4" />
|
||||
|
||||
<PlatformSelect
|
||||
value={newLinkPlatform}
|
||||
onChange={setNewLinkPlatform}
|
||||
className="h-full !w-full"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={
|
||||
SITE_ORDER.includes(newLinkPlatform as any) && newLinkPlatform != 'site'
|
||||
? t('profile.optional.username_or_url', 'Username or URL')
|
||||
: t('profile.optional.site_url', 'Site URL')
|
||||
}
|
||||
value={newLinkValue}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setNewLinkValue(e.target.value)}
|
||||
// disable password managers
|
||||
autoComplete="off"
|
||||
data-1p-ignore
|
||||
data-lpignore="true"
|
||||
data-bwignore="true"
|
||||
data-protonpass-ignore="true"
|
||||
className="w-full"
|
||||
/>
|
||||
<Button
|
||||
color="gray-outline"
|
||||
onClick={addNewLink}
|
||||
disabled={!newLinkPlatform || !newLinkValue}
|
||||
>
|
||||
<PlusIcon className="h-6 w-6" />
|
||||
<div className="sr-only">{t('common.add', 'Add')}</div>
|
||||
</Button>
|
||||
</div>
|
||||
</Col>
|
||||
<SocialLinksSection profile={profile} setProfile={setProfile} />
|
||||
|
||||
<Category title={t('profile.basics.bio', 'Bio')} className={'mt-0'} />
|
||||
<label className={clsx('guidance')}>
|
||||
|
||||
98
web/components/social-links-section.tsx
Normal file
98
web/components/social-links-section.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import {PlusIcon, XMarkIcon} from '@heroicons/react/24/solid'
|
||||
import clsx from 'clsx'
|
||||
import {ProfileWithoutUser} from 'common/profiles/profile'
|
||||
import {PLATFORM_LABELS, type Site, SITE_ORDER, Socials} from 'common/socials'
|
||||
import {Fragment, useState} from 'react'
|
||||
import {Button, IconButton} from 'web/components/buttons/button'
|
||||
import {Col} from 'web/components/layout/col'
|
||||
import {Input} from 'web/components/widgets/input'
|
||||
import {PlatformSelect} from 'web/components/widgets/platform-select'
|
||||
import {useT} from 'web/lib/locale'
|
||||
|
||||
import {SocialIcon} from './user/social'
|
||||
|
||||
interface SocialLinksSectionProps {
|
||||
profile: ProfileWithoutUser
|
||||
setProfile: <K extends keyof ProfileWithoutUser>(key: K, value: ProfileWithoutUser[K]) => void
|
||||
}
|
||||
|
||||
export function SocialLinksSection({profile, setProfile}: SocialLinksSectionProps) {
|
||||
const t = useT()
|
||||
const [newLinkPlatform, setNewLinkPlatform] = useState('')
|
||||
const [newLinkValue, setNewLinkValue] = useState('')
|
||||
|
||||
const updateUserLink = (platform: string, value: string | null) => {
|
||||
setProfile('links', {...((profile.links as Socials) ?? {}), [platform]: value})
|
||||
}
|
||||
|
||||
const addNewLink = () => {
|
||||
if (newLinkPlatform && newLinkValue) {
|
||||
updateUserLink(newLinkPlatform.toLowerCase().trim(), newLinkValue.trim())
|
||||
setNewLinkPlatform('')
|
||||
setNewLinkValue('')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Col className={clsx('pb-4')}>
|
||||
<div className="grid w-full grid-cols-[8rem_1fr_auto] gap-2">
|
||||
{Object.entries((profile.links ?? {}) as Socials)
|
||||
.filter(([_, value]) => value != null)
|
||||
.map(([platform, value]) => (
|
||||
<Fragment key={platform}>
|
||||
<div className="col-span-3 mt-2 flex items-center gap-2 self-center sm:col-span-1">
|
||||
<SocialIcon site={platform as any} className="text-primary-700 h-4 w-4" />
|
||||
{PLATFORM_LABELS[platform as Site] ?? platform}
|
||||
</div>
|
||||
<Input
|
||||
type="text"
|
||||
value={value!}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
updateUserLink(platform, e.target.value)
|
||||
}
|
||||
className="col-span-2 sm:col-span-1"
|
||||
/>
|
||||
<IconButton onClick={() => updateUserLink(platform, null)}>
|
||||
<XMarkIcon className="h-6 w-6" />
|
||||
<div className="sr-only">{t('common.remove', 'Remove')}</div>
|
||||
</IconButton>
|
||||
</Fragment>
|
||||
))}
|
||||
|
||||
{/* Spacer */}
|
||||
<div className="col-span-3 h-4" />
|
||||
|
||||
<PlatformSelect
|
||||
value={newLinkPlatform}
|
||||
onChange={setNewLinkPlatform}
|
||||
className="h-full !w-full"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={
|
||||
SITE_ORDER.includes(newLinkPlatform as any) && newLinkPlatform != 'site'
|
||||
? t('profile.optional.username_or_url', 'Username or URL')
|
||||
: t('profile.optional.site_url', 'Site URL')
|
||||
}
|
||||
value={newLinkValue}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setNewLinkValue(e.target.value)}
|
||||
// disable password managers
|
||||
autoComplete="off"
|
||||
data-1p-ignore
|
||||
data-lpignore="true"
|
||||
data-bwignore="true"
|
||||
data-protonpass-ignore="true"
|
||||
className="w-full"
|
||||
/>
|
||||
<Button
|
||||
color="gray-outline"
|
||||
onClick={addNewLink}
|
||||
disabled={!newLinkPlatform || !newLinkValue}
|
||||
>
|
||||
<PlusIcon className="h-6 w-6" />
|
||||
<div className="sr-only">{t('common.add', 'Add')}</div>
|
||||
</Button>
|
||||
</div>
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
@@ -1,25 +1,31 @@
|
||||
import CharacterCount from '@tiptap/extension-character-count'
|
||||
import {Link} from '@tiptap/extension-link'
|
||||
import Placeholder from '@tiptap/extension-placeholder'
|
||||
import Table from '@tiptap/extension-table'
|
||||
import TableCell from '@tiptap/extension-table-cell'
|
||||
import TableHeader from '@tiptap/extension-table-header'
|
||||
import TableRow from '@tiptap/extension-table-row'
|
||||
import Underline from '@tiptap/extension-underline'
|
||||
import {TextSelection} from '@tiptap/pm/state'
|
||||
import type {Content, JSONContent} from '@tiptap/react'
|
||||
import {Editor, EditorContent, Extensions, mergeAttributes, useEditor} from '@tiptap/react'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import clsx from 'clsx'
|
||||
import {richTextToString} from 'common/util/parse'
|
||||
import Iframe from 'common/util/tiptap-iframe'
|
||||
import {debounce, noop} from 'lodash'
|
||||
import {ReactNode, useCallback, useEffect, useMemo} from 'react'
|
||||
import {debounce} from 'lodash'
|
||||
import {createElement, ReactNode, useCallback, useEffect, useMemo} from 'react'
|
||||
import {CustomLink} from 'web/components/links'
|
||||
import {usePersistentLocalState} from 'web/hooks/use-persistent-local-state'
|
||||
import {safeLocalStorage} from 'web/lib/util/local'
|
||||
|
||||
import {EmojiExtension} from '../editor/emoji/emoji-extension'
|
||||
import {FloatingFormatMenu} from '../editor/floating-format-menu'
|
||||
import {BasicImage, DisplayImage, MediumDisplayImage} from '../editor/image'
|
||||
import {BasicImage, DisplayImage} from '../editor/image'
|
||||
import {nodeViewMiddleware} from '../editor/nodeview-middleware'
|
||||
import {StickyFormatMenu} from '../editor/sticky-format-menu'
|
||||
import {Upload, useUploadMutation} from '../editor/upload-extension'
|
||||
import {DisplayMention} from '../editor/user-mention/mention-extension'
|
||||
import {generateReact} from '../editor/utils'
|
||||
import {Linkify} from './linkify'
|
||||
import {linkClass} from './site-link'
|
||||
|
||||
@@ -49,6 +55,11 @@ const editorExtensions = (simple = false): Extensions =>
|
||||
DisplayMention,
|
||||
Iframe,
|
||||
Upload,
|
||||
Table.configure({resizable: false}),
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
Underline,
|
||||
])
|
||||
|
||||
const proseClass = (size: 'sm' | 'md' | 'lg') =>
|
||||
@@ -72,8 +83,9 @@ export function useTextEditor(props: {
|
||||
key?: string // unique key for autosave. If set, plz call `editor.commands.clearContent(true)` on submit to clear autosave
|
||||
extensions?: Extensions
|
||||
className?: string
|
||||
onChange?: () => void
|
||||
}) {
|
||||
const {placeholder, className, max, defaultValue, size = 'md', key} = props
|
||||
const {placeholder, className, max, defaultValue, size = 'md', key, onChange} = props
|
||||
const simple = size === 'sm'
|
||||
|
||||
const [content, setContent] = usePersistentLocalState<JSONContent | undefined>(
|
||||
@@ -109,13 +121,29 @@ export function useTextEditor(props: {
|
||||
})
|
||||
|
||||
const editor = useEditor({
|
||||
editorProps: getEditorProps(),
|
||||
editorProps: {
|
||||
...getEditorProps(),
|
||||
handleDOMEvents: {},
|
||||
transformPastedHTML: (html) => html,
|
||||
},
|
||||
immediatelyRender: false,
|
||||
onUpdate: !key
|
||||
? noop
|
||||
: ({editor}) => {
|
||||
save(editor.getJSON())
|
||||
},
|
||||
onUpdate: ({editor}) => {
|
||||
if (key) {
|
||||
save(editor.getJSON())
|
||||
}
|
||||
onChange?.()
|
||||
},
|
||||
onTransaction({transaction, editor}) {
|
||||
const {selection} = transaction
|
||||
// If CellSelection sneaks through, convert to TextSelection
|
||||
if ('$anchorCell' in selection) {
|
||||
const {$anchorCell} = selection as any
|
||||
const tr = editor.state.tr.setSelection(
|
||||
TextSelection.create(editor.state.doc, $anchorCell.pos),
|
||||
)
|
||||
editor.view.dispatch(tr)
|
||||
}
|
||||
},
|
||||
extensions: [
|
||||
...editorExtensions(simple),
|
||||
Placeholder.configure({
|
||||
@@ -204,13 +232,7 @@ function RichContent(props: {content: JSONContent; className?: string; size?: 's
|
||||
|
||||
const jsxContent = useMemo(() => {
|
||||
try {
|
||||
return generateReact(content, [
|
||||
StarterKit as any,
|
||||
size === 'sm' ? DisplayImage : size === 'md' ? MediumDisplayImage : BasicImage,
|
||||
DisplayLink,
|
||||
DisplayMention,
|
||||
Iframe,
|
||||
])
|
||||
return renderJSONContent(content, size)
|
||||
} catch (e) {
|
||||
console.error('Error generating react', e, 'for content', content)
|
||||
return ''
|
||||
@@ -233,6 +255,105 @@ function RichContent(props: {content: JSONContent; className?: string; size?: 's
|
||||
)
|
||||
}
|
||||
|
||||
function renderJSONContent(doc: JSONContent, size: 'sm' | 'md' | 'lg'): ReactNode {
|
||||
return recurse(doc, 0, size)
|
||||
}
|
||||
|
||||
function recurse(node: JSONContent, key: number, size: 'sm' | 'md' | 'lg'): ReactNode {
|
||||
const children = node.content?.map((n, i) => recurse(n, i, size))
|
||||
|
||||
switch (node.type) {
|
||||
case 'doc':
|
||||
return <>{children}</>
|
||||
case 'paragraph':
|
||||
return <p key={key}>{children}</p>
|
||||
case 'heading':
|
||||
return createElement(`h${node.attrs?.level ?? 1}`, {key}, children)
|
||||
case 'bulletList':
|
||||
return <ul key={key}>{children}</ul>
|
||||
case 'orderedList':
|
||||
return (
|
||||
<ol key={key} start={node.attrs?.start ?? 1}>
|
||||
{children}
|
||||
</ol>
|
||||
)
|
||||
case 'listItem':
|
||||
return <li key={key}>{children}</li>
|
||||
case 'blockquote':
|
||||
return <blockquote key={key}>{children}</blockquote>
|
||||
case 'codeBlock':
|
||||
return (
|
||||
<pre key={key}>
|
||||
<code>{children}</code>
|
||||
</pre>
|
||||
)
|
||||
case 'horizontalRule':
|
||||
return <hr key={key} />
|
||||
case 'hardBreak':
|
||||
return <br key={key} />
|
||||
case 'image':
|
||||
return (
|
||||
<img
|
||||
key={key}
|
||||
src={node.attrs?.src}
|
||||
alt={node.attrs?.alt ?? ''}
|
||||
title={node.attrs?.title ?? undefined}
|
||||
className={size === 'sm' ? 'max-h-32' : size === 'md' ? 'max-h-64' : undefined}
|
||||
/>
|
||||
)
|
||||
case 'table':
|
||||
return (
|
||||
<table key={key}>
|
||||
<tbody>{children}</tbody>
|
||||
</table>
|
||||
)
|
||||
case 'tableRow':
|
||||
return <tr key={key}>{children}</tr>
|
||||
case 'tableHeader':
|
||||
return (
|
||||
<th key={key} colSpan={node.attrs?.colspan ?? 1} rowSpan={node.attrs?.rowspan ?? 1}>
|
||||
{children}
|
||||
</th>
|
||||
)
|
||||
case 'tableCell':
|
||||
return (
|
||||
<td key={key} colSpan={node.attrs?.colspan ?? 1} rowSpan={node.attrs?.rowspan ?? 1}>
|
||||
{children}
|
||||
</td>
|
||||
)
|
||||
case 'text':
|
||||
return applyMarks(node.text ?? '', node.marks ?? [], key)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function applyMarks(text: string, marks: JSONContent[], key: number): ReactNode {
|
||||
return marks.reduce(
|
||||
(node, mark) => {
|
||||
switch (mark.type) {
|
||||
case 'bold':
|
||||
return <strong>{node}</strong>
|
||||
case 'italic':
|
||||
return <em>{node}</em>
|
||||
case 'underline':
|
||||
return <u>{node}</u>
|
||||
case 'strike':
|
||||
return <s>{node}</s>
|
||||
case 'code':
|
||||
return <code>{node}</code>
|
||||
case 'highlight':
|
||||
return <mark>{node}</mark>
|
||||
case 'link':
|
||||
return <CustomLink href={mark.attrs?.href}>{node}</CustomLink>
|
||||
default:
|
||||
return node
|
||||
}
|
||||
},
|
||||
(<span key={key}>{text}</span>) as ReactNode,
|
||||
)
|
||||
}
|
||||
|
||||
// backwards compatibility: we used to store content as strings
|
||||
export function Content(props: {
|
||||
content: JSONContent | string
|
||||
|
||||
459
yarn.lock
459
yarn.lock
@@ -74,6 +74,33 @@
|
||||
resolved "https://registry.yarnpkg.com/@apphosting/common/-/common-0.0.9.tgz#af0b5375e8169e1d6ad32eebdec9d67d5caf669b"
|
||||
integrity sha512-ZbPZDcVhEN+8m0sf90PmQN4xWaKmmySnBSKKPaIOD0JvcDsRr509WenFEFlojP++VSxwFZDGG/TYsHs1FMMqpw==
|
||||
|
||||
"@asamuzakjp/css-color@^5.0.1":
|
||||
version "5.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@asamuzakjp/css-color/-/css-color-5.0.1.tgz#3b9462a9b52f3c6680a0945a3d0851881017550f"
|
||||
integrity sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==
|
||||
dependencies:
|
||||
"@csstools/css-calc" "^3.1.1"
|
||||
"@csstools/css-color-parser" "^4.0.2"
|
||||
"@csstools/css-parser-algorithms" "^4.0.0"
|
||||
"@csstools/css-tokenizer" "^4.0.0"
|
||||
lru-cache "^11.2.6"
|
||||
|
||||
"@asamuzakjp/dom-selector@^7.0.3":
|
||||
version "7.0.4"
|
||||
resolved "https://registry.yarnpkg.com/@asamuzakjp/dom-selector/-/dom-selector-7.0.4.tgz#744ad572c70b00cc8e92e76d539b14afb7bd99b1"
|
||||
integrity sha512-jXR6x4AcT3eIrS2fSNAwJpwirOkGcd+E7F7CP3zjdTqz9B/2huHOL8YJZBgekKwLML+u7qB/6P1LXQuMScsx0w==
|
||||
dependencies:
|
||||
"@asamuzakjp/nwsapi" "^2.3.9"
|
||||
bidi-js "^1.0.3"
|
||||
css-tree "^3.2.1"
|
||||
is-potential-custom-element-name "^1.0.1"
|
||||
lru-cache "^11.2.7"
|
||||
|
||||
"@asamuzakjp/nwsapi@^2.3.9":
|
||||
version "2.3.9"
|
||||
resolved "https://registry.yarnpkg.com/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz#ad5549322dfe9d153d4b4dd6f7ff2ae234b06e24"
|
||||
integrity sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==
|
||||
|
||||
"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.12.13", "@babel/code-frame@^7.23.5", "@babel/code-frame@^7.24.2":
|
||||
version "7.24.2"
|
||||
resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.24.2.tgz#718b4b19841809a58b29b68cde80bc5e1aa6d9ae"
|
||||
@@ -1272,6 +1299,13 @@
|
||||
resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39"
|
||||
integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==
|
||||
|
||||
"@bramus/specificity@^2.4.2":
|
||||
version "2.4.2"
|
||||
resolved "https://registry.yarnpkg.com/@bramus/specificity/-/specificity-2.4.2.tgz#aa8db8eb173fdee7324f82284833106adeecc648"
|
||||
integrity sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==
|
||||
dependencies:
|
||||
css-tree "^3.0.0"
|
||||
|
||||
"@capacitor/android@7.4.4":
|
||||
version "7.4.4"
|
||||
resolved "https://registry.yarnpkg.com/@capacitor/android/-/android-7.4.4.tgz#a14a1e844bd5079982427e247fdd17555b5fbedd"
|
||||
@@ -1405,6 +1439,39 @@
|
||||
dependencies:
|
||||
"@jridgewell/trace-mapping" "0.3.9"
|
||||
|
||||
"@csstools/color-helpers@^6.0.2":
|
||||
version "6.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@csstools/color-helpers/-/color-helpers-6.0.2.tgz#82c59fd30649cf0b4d3c82160489748666e6550b"
|
||||
integrity sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==
|
||||
|
||||
"@csstools/css-calc@^3.1.1":
|
||||
version "3.1.1"
|
||||
resolved "https://registry.yarnpkg.com/@csstools/css-calc/-/css-calc-3.1.1.tgz#78b494996dac41a02797dcca18ac3b46d25b3fd7"
|
||||
integrity sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==
|
||||
|
||||
"@csstools/css-color-parser@^4.0.2":
|
||||
version "4.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz#c27e03a3770d0352db92d668d6dde427a37859e5"
|
||||
integrity sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==
|
||||
dependencies:
|
||||
"@csstools/color-helpers" "^6.0.2"
|
||||
"@csstools/css-calc" "^3.1.1"
|
||||
|
||||
"@csstools/css-parser-algorithms@^4.0.0":
|
||||
version "4.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz#e1c65dc09378b42f26a111fca7f7075fc2c26164"
|
||||
integrity sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==
|
||||
|
||||
"@csstools/css-syntax-patches-for-csstree@^1.1.1":
|
||||
version "1.1.2"
|
||||
resolved "https://registry.yarnpkg.com/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.2.tgz#bef07c507732a052b662302e7cb4e9fdf522af90"
|
||||
integrity sha512-5GkLzz4prTIpoyeUiIu3iV6CSG3Plo7xRVOFPKI7FVEJ3mZ0A8SwK0XU3Gl7xAkiQ+mDyam+NNp875/C5y+jSA==
|
||||
|
||||
"@csstools/css-tokenizer@^4.0.0":
|
||||
version "4.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz#798a33950d11226a0ebb6acafa60f5594424967f"
|
||||
integrity sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==
|
||||
|
||||
"@dabh/diagnostics@^2.0.8":
|
||||
version "2.0.8"
|
||||
resolved "https://registry.yarnpkg.com/@dabh/diagnostics/-/diagnostics-2.0.8.tgz#ead97e72ca312cf0e6dd7af0d300b58993a31a5e"
|
||||
@@ -1739,6 +1806,11 @@
|
||||
"@eslint/core" "^0.17.0"
|
||||
levn "^0.4.1"
|
||||
|
||||
"@exodus/bytes@^1.11.0", "@exodus/bytes@^1.15.0", "@exodus/bytes@^1.6.0":
|
||||
version "1.15.0"
|
||||
resolved "https://registry.yarnpkg.com/@exodus/bytes/-/bytes-1.15.0.tgz#54479e0f406cbad024d6fe1c3190ecca4468df3b"
|
||||
integrity sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==
|
||||
|
||||
"@faker-js/faker@10.1.0":
|
||||
version "10.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@faker-js/faker/-/faker-10.1.0.tgz#eb72869d01ccbff41a77aa7ac851ce1ac9371129"
|
||||
@@ -3291,6 +3363,11 @@
|
||||
zod "^3.25 || ^4.0"
|
||||
zod-to-json-schema "^3.25.0"
|
||||
|
||||
"@mozilla/readability@0.6.0":
|
||||
version "0.6.0"
|
||||
resolved "https://registry.yarnpkg.com/@mozilla/readability/-/readability-0.6.0.tgz#134e3ce3ff1676716e550de0b8de957bcc59208b"
|
||||
integrity sha512-juG5VWh4qAivzTAeMzvY9xs9HY5rAcr2E4I7tiSSCokRFi7XIZCAu92ZkSTsIj1OPceCifL3cpfteP3pDT9/QQ==
|
||||
|
||||
"@next/env@16.1.6":
|
||||
version "16.1.6"
|
||||
resolved "https://registry.yarnpkg.com/@next/env/-/env-16.1.6.tgz#0f85979498249a94ef606ef535042a831f905e89"
|
||||
@@ -5017,35 +5094,35 @@
|
||||
dependencies:
|
||||
tippy.js "^6.3.7"
|
||||
|
||||
"@tiptap/extension-bullet-list@^2.10.4":
|
||||
version "2.27.2"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-bullet-list/-/extension-bullet-list-2.27.2.tgz#2347683ab898471ab7df2c3e63b20e8d3d7c46f3"
|
||||
integrity sha512-gmFuKi97u5f8uFc/GQs+zmezjiulZmFiDYTh3trVoLRoc2SAHOjGEB7qxdx7dsqmMN7gwiAWAEVurLKIi1lnnw==
|
||||
"@tiptap/extension-bullet-list@2.10.4", "@tiptap/extension-bullet-list@^2.10.4":
|
||||
version "2.10.4"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-bullet-list/-/extension-bullet-list-2.10.4.tgz#0b86fb2782b4a7fc012a23d3e265bcc798e70a12"
|
||||
integrity sha512-JVwDPgOBYRU2ivaadOh4IaQYXQEiSw6sB36KT/bwqJF2GnEvLiMwptdRMn9Uuh6xYR3imjIZtV6uZAoneZdd6g==
|
||||
|
||||
"@tiptap/extension-character-count@2.10.4":
|
||||
version "2.10.4"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-character-count/-/extension-character-count-2.10.4.tgz#dccf731a48eaf93075500709929a1bf0eff1d8f9"
|
||||
integrity sha512-NoVLQI/zTEdA0EZe5+oinQ+F/WQMblZRdEWgfsUtQoLRloSAF+pFeQwDenpejdOuWqixT4vdzpboBocj4uQLsw==
|
||||
|
||||
"@tiptap/extension-code-block@^2.10.4":
|
||||
version "2.27.2"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-code-block/-/extension-code-block-2.27.2.tgz#0a622d5bf92c9db55e9f5eaba1a6a8d7a015b1f1"
|
||||
integrity sha512-KgvdQHS4jXr79aU3wZOGBIZYYl9vCB7uDEuRFV4so2rYrfmiYMw3T8bTnlNEEGe4RUeAms1i4fdwwvQp9nR1Dw==
|
||||
"@tiptap/extension-code-block@2.10.4", "@tiptap/extension-code-block@^2.10.4":
|
||||
version "2.10.4"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-code-block/-/extension-code-block-2.10.4.tgz#a86a4477acc995c5185e32dd60188f4e173f9341"
|
||||
integrity sha512-qS4jnbJqghNMT2+B+GQ807ATgqkL9OQ//NlL+ZwVSe+DPDduNA9B6IB9SrWENDfOnzekpi7kcEcm+RenELARRQ==
|
||||
|
||||
"@tiptap/extension-code@^2.10.4":
|
||||
version "2.27.2"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-code/-/extension-code-2.27.2.tgz#bfbaf07f67232144c6865ffbea20896e02c6fe6f"
|
||||
integrity sha512-7X9AgwqiIGXoZX7uvdHQsGsjILnN/JaEVtqfXZnPECzKGaWHeK/Ao4sYvIIIffsyZJA8k5DC7ny2/0sAgr2TuA==
|
||||
"@tiptap/extension-code@2.10.4", "@tiptap/extension-code@^2.10.4":
|
||||
version "2.10.4"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-code/-/extension-code-2.10.4.tgz#d6d1fd9e7f1b457700b6c47e115fb1cc9b9039f8"
|
||||
integrity sha512-Vj/N0nbSQiV1o7X7pRySK9Fu72Dd266gm27TSlsts6IwJu5MklFvz7ezJUWoLjt2wmCV8/U/USmk/39ic9qjvg==
|
||||
|
||||
"@tiptap/extension-document@^2.10.4":
|
||||
version "2.27.2"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-document/-/extension-document-2.27.2.tgz#697ee04c03c7b37bc37d942d60fcc5fa304988b5"
|
||||
integrity sha512-CFhAYsPnyYnosDC4639sCJnBUnYH4Cat9qH5NZWHVvdgtDwu8GZgZn2eSzaKSYXWH1vJ9DSlCK+7UyC3SNXIBA==
|
||||
"@tiptap/extension-document@2.10.4", "@tiptap/extension-document@^2.10.4":
|
||||
version "2.10.4"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-document/-/extension-document-2.10.4.tgz#68575c122f6d71556dae6f9df6173903b7cdfdf9"
|
||||
integrity sha512-1Pqrl6Rr9bVEHJ3zO2dM7UUA0Qn/r70JQ9YLlestjW1sbMaMuY3Ifvu2uSyUE7SAGV3gvxwNVQCrv8f0VlVEaA==
|
||||
|
||||
"@tiptap/extension-dropcursor@^2.10.4":
|
||||
version "2.27.2"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-dropcursor/-/extension-dropcursor-2.27.2.tgz#c0f62e32a6c7bc7dc8cc6b6edd84d9173bc1db16"
|
||||
integrity sha512-oEu/OrktNoQXq1x29NnH/GOIzQZm8ieTQl3FK27nxfBPA89cNoH4mFEUmBL5/OFIENIjiYG3qWpg6voIqzswNw==
|
||||
"@tiptap/extension-dropcursor@2.10.4", "@tiptap/extension-dropcursor@^2.10.4":
|
||||
version "2.10.4"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-dropcursor/-/extension-dropcursor-2.10.4.tgz#6dea1c17878a8a0d68c7915f34b7b02980e91c90"
|
||||
integrity sha512-0XEM/yNLaMc/sZlYOau7XpHyYiHT9LwXUe7kmze/L8eowIa/iLvmRbcnUd3rtlZ7x7wooE6UO9c7OtlREg4ZBw==
|
||||
|
||||
"@tiptap/extension-floating-menu@2.10.4", "@tiptap/extension-floating-menu@^2.10.4":
|
||||
version "2.10.4"
|
||||
@@ -5054,25 +5131,25 @@
|
||||
dependencies:
|
||||
tippy.js "^6.3.7"
|
||||
|
||||
"@tiptap/extension-gapcursor@^2.10.4":
|
||||
version "2.27.2"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-gapcursor/-/extension-gapcursor-2.27.2.tgz#2e82dd87cb2dfcca90f0abb3b43f1f6748a54e2c"
|
||||
integrity sha512-/c9VF1HBxj+AP54XGVgCmD9bEGYc5w5OofYCFQgM7l7PB1J00A4vOke0oPkHJnqnOOyPlFaxO/7N6l3XwFcnKA==
|
||||
"@tiptap/extension-gapcursor@2.10.4", "@tiptap/extension-gapcursor@^2.10.4":
|
||||
version "2.10.4"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-gapcursor/-/extension-gapcursor-2.10.4.tgz#697f0f599c5fe90f584e3f94e4866959211c8688"
|
||||
integrity sha512-KbJfoaqTZePpkWAN+klpK5j0UVtELxN7H5B0J556/UCB/rnq+OsdEFHPks2Ss9TidqWzRUqcxUE50UZ7b8h7Ug==
|
||||
|
||||
"@tiptap/extension-hard-break@^2.10.4":
|
||||
version "2.27.2"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-hard-break/-/extension-hard-break-2.27.2.tgz#250200feb316cfb40ed8e9188ee6684c2811b475"
|
||||
integrity sha512-kSRVGKlCYK6AGR0h8xRkk0WOFGXHIIndod3GKgWU49APuIGDiXd8sziXsSlniUsWmqgDmDXcNnSzPcV7AQ8YNg==
|
||||
"@tiptap/extension-hard-break@2.10.4", "@tiptap/extension-hard-break@^2.10.4":
|
||||
version "2.10.4"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-hard-break/-/extension-hard-break-2.10.4.tgz#57767f35950405c457789faa7b793830860096cc"
|
||||
integrity sha512-nW9wubW1A/CO2Ssn9wNMP08tR9Oarg9VUGzJ5qNuz38DDNyntE1SyDS+XStkeMq5nKqJ3YKhukyAJH/PiRq4Mg==
|
||||
|
||||
"@tiptap/extension-heading@^2.10.4":
|
||||
version "2.27.2"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-heading/-/extension-heading-2.27.2.tgz#10afd812475c6a3f62a26bd1975998bfa94cb9fb"
|
||||
integrity sha512-iM3yeRWuuQR/IRQ1djwNooJGfn9Jts9zF43qZIUf+U2NY8IlvdNsk2wTOdBgh6E0CamrStPxYGuln3ZS4fuglw==
|
||||
"@tiptap/extension-heading@2.10.4", "@tiptap/extension-heading@^2.10.4":
|
||||
version "2.10.4"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-heading/-/extension-heading-2.10.4.tgz#6df708f74f2f2d0716f6e3d32100c29c8be3fe68"
|
||||
integrity sha512-7D0h0MIvE97Gx3Qwuo2xnPDK07WfCnyh4tpOPBOus4e1g6sgxVkwDwhbkYWiwvIrf4BUVJflnke/DEDCVp6/Eg==
|
||||
|
||||
"@tiptap/extension-history@^2.10.4":
|
||||
version "2.27.2"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-history/-/extension-history-2.27.2.tgz#43c6d976c521dc1cf2d4a0707df7d8328be0e9a9"
|
||||
integrity sha512-+hSyqERoFNTWPiZx4/FCyZ/0eFqB9fuMdTB4AC/q9iwu3RNWAQtlsJg5230bf/qmyO6bZxRUc0k8p4hrV6ybAw==
|
||||
"@tiptap/extension-history@2.10.4", "@tiptap/extension-history@^2.10.4":
|
||||
version "2.10.4"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-history/-/extension-history-2.10.4.tgz#58e1b819149354538526475896a9904e954d02a6"
|
||||
integrity sha512-fg6BNxbpMMtgKaiNI/GLcCzkxIQMwSYBhO9LA0CxLvmsWGU+My4r9W3DK6HwNoRJ9+6OleDPSLo1P73fbSTtEA==
|
||||
|
||||
"@tiptap/extension-horizontal-rule@2.10.4", "@tiptap/extension-horizontal-rule@^2.10.4":
|
||||
version "2.10.4"
|
||||
@@ -5084,10 +5161,10 @@
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-image/-/extension-image-2.10.4.tgz#577a0e9b92be845b91165d29136327c951bb967e"
|
||||
integrity sha512-fPdAqP4M1zwz5jyrQNIEL4OvvGeJso45svaaBLV342yRLOpbVIgAp/RsuWSGDQTUWoGhdkHdIrbH2bUGNEbMBg==
|
||||
|
||||
"@tiptap/extension-italic@^2.10.4":
|
||||
version "2.27.2"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-italic/-/extension-italic-2.27.2.tgz#91b6ded7b84ed218a8c07ed979332d0dbf923d2b"
|
||||
integrity sha512-1OFsw2SZqfaqx5Fa5v90iNlPRcqyt+lVSjBwTDzuPxTPFY4Q0mL89mKgkq2gVHYNCiaRkXvFLDxaSvBWbmthgg==
|
||||
"@tiptap/extension-italic@2.10.4", "@tiptap/extension-italic@^2.10.4":
|
||||
version "2.10.4"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-italic/-/extension-italic-2.10.4.tgz#000cd53a5d54efe1c8bfa51a2bd6003268dcd01d"
|
||||
integrity sha512-8MIQ+wsbyxNCZDCFTVTOXrS2AvFyOhtlBNgVU2+6r6xnJV4AcfEA3qclysqrjOlL117ped/nzDeoB0AeX0CI+Q==
|
||||
|
||||
"@tiptap/extension-link@2.10.4":
|
||||
version "2.10.4"
|
||||
@@ -5096,45 +5173,70 @@
|
||||
dependencies:
|
||||
linkifyjs "^4.1.0"
|
||||
|
||||
"@tiptap/extension-list-item@^2.10.4":
|
||||
version "2.27.2"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-list-item/-/extension-list-item-2.27.2.tgz#562a8a5f56ed7ac70cd4fab37d7fbcd29e9dc078"
|
||||
integrity sha512-eJNee7IEGXMnmygM5SdMGDC8m/lMWmwNGf9fPCK6xk0NxuQRgmZHL6uApKcdH6gyNcRPHCqvTTkhEP7pbny/fg==
|
||||
"@tiptap/extension-list-item@2.10.4", "@tiptap/extension-list-item@^2.10.4":
|
||||
version "2.10.4"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-list-item/-/extension-list-item-2.10.4.tgz#3ea8e597567d448b00ac4d7b0ef9d1b91bc1d306"
|
||||
integrity sha512-8K3WUD5fPyw2poQKnJGGm7zlfeIbpld92+SRF4M9wkp95EzvgexTlodvxlrL3i8zKXcQQVyExWA8kCcGPFb9bA==
|
||||
|
||||
"@tiptap/extension-mention@2.10.4":
|
||||
version "2.10.4"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-mention/-/extension-mention-2.10.4.tgz#a01856024f948daf2e1f9ed820059f1bdb15ff73"
|
||||
integrity sha512-pVouKWxSVQSy4zn6HrljPIP1AG826gkm/w18Asi8QnZvR0AMqGLh9q7qd9Kc0j8NKoCzlzK8hECGlYPEaBldow==
|
||||
|
||||
"@tiptap/extension-ordered-list@^2.10.4":
|
||||
version "2.27.2"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-ordered-list/-/extension-ordered-list-2.27.2.tgz#12f2c4309512429a0c21863e741db00356573a4b"
|
||||
integrity sha512-M7A4tLGJcLPYdLC4CI2Gwl8LOrENQW59u3cMVa+KkwG1hzSJyPsbDpa1DI6oXPC2WtYiTf22zrbq3gVvH+KA2w==
|
||||
"@tiptap/extension-ordered-list@2.10.4", "@tiptap/extension-ordered-list@^2.10.4":
|
||||
version "2.10.4"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-ordered-list/-/extension-ordered-list-2.10.4.tgz#3da20e5087329a9999837b5860954bd14b14ef60"
|
||||
integrity sha512-NaeEu+qFG2O0emc8WlwOM7DKNKOaqHWuNkuKrrmQzslgL+UQSEGlGMo6NEJ5sLLckPBDpIa0MuRm30407JE+cg==
|
||||
|
||||
"@tiptap/extension-paragraph@^2.10.4":
|
||||
version "2.27.2"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-paragraph/-/extension-paragraph-2.27.2.tgz#e6873c16993bf21b831ecac41bbd137dc5945eb4"
|
||||
integrity sha512-elYVn2wHJJ+zB9LESENWOAfI4TNT0jqEN34sMA/hCtA4im1ZG2DdLHwkHIshj/c4H0dzQhmsS/YmNC5Vbqab/A==
|
||||
"@tiptap/extension-paragraph@2.10.4", "@tiptap/extension-paragraph@^2.10.4":
|
||||
version "2.10.4"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-paragraph/-/extension-paragraph-2.10.4.tgz#e2c94eaee7c69b7c38f98abd50927a8a6811dc49"
|
||||
integrity sha512-SRNVhT8OXqjpZtcyuOtofbtOpXXFrQrjqqCc/yXebda//2SfUTOvB16Lss77vQOWi6xr7TF1mZuowJgSTkcczw==
|
||||
|
||||
"@tiptap/extension-placeholder@2.10.4":
|
||||
version "2.10.4"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-placeholder/-/extension-placeholder-2.10.4.tgz#706e30941e3232894db647ab4254e8ba65f54920"
|
||||
integrity sha512-leWG4xP7cvddR6alGZS7yojOh9941bxehgAeQDLlEisaJcNa2Od5Vbap2zipjc5sXMxZakQVChL27oH1wWhHkQ==
|
||||
|
||||
"@tiptap/extension-strike@^2.10.4":
|
||||
version "2.27.2"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-strike/-/extension-strike-2.27.2.tgz#9291f6dd9bcf00e1c2b7e043f9d9b18cf35f1db1"
|
||||
integrity sha512-HHIjhafLhS2lHgfAsCwC1okqMsQzR4/mkGDm4M583Yftyjri1TNA7lzhzXWRFWiiMfJxKtdjHjUAQaHuteRTZw==
|
||||
"@tiptap/extension-strike@2.10.4", "@tiptap/extension-strike@^2.10.4":
|
||||
version "2.10.4"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-strike/-/extension-strike-2.10.4.tgz#8c450d1cb26f6cb0b79403aed2bf0bd6d1d0cd98"
|
||||
integrity sha512-OibipsomFpOJWTPVX/z4Z53HgwDA93lE/loHGa+ONJfML1dO6Zd6UTwzaVO1/g8WOwRgwkYu/6JnhxLKRlP8Lg==
|
||||
|
||||
"@tiptap/extension-text-style@^2.10.4":
|
||||
version "2.27.2"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-text-style/-/extension-text-style-2.27.2.tgz#5f27d512e8421b5160be37aab17c47dde88a8bea"
|
||||
integrity sha512-Omk+uxjJLyEY69KStpCw5fA9asvV+MGcAX2HOxyISDFoLaL49TMrNjhGAuz09P1L1b0KGXo4ml7Q3v/Lfy4WPA==
|
||||
"@tiptap/extension-table-cell@2.10.4":
|
||||
version "2.10.4"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-table-cell/-/extension-table-cell-2.10.4.tgz#cdbcc86d54a0f3f3393a73a242098118f48517e9"
|
||||
integrity sha512-vYwRYt3xPaAU4hxoz3OMGPQzcAxaxEVri6VSRMWg4BN3x4DwWevBTAk59Ho9nkJpaRuXO6c5pIxcwWCZM0Aw0w==
|
||||
|
||||
"@tiptap/extension-text@^2.10.4":
|
||||
version "2.27.2"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-text/-/extension-text-2.27.2.tgz#8b387a95cef4adb112bfb1ed00a8bc50d9204476"
|
||||
integrity sha512-Xk7nYcigljAY0GO9hAQpZ65ZCxqOqaAlTPDFcKerXmlkQZP/8ndx95OgUb1Xf63kmPOh3xypurGS2is3v0MXSA==
|
||||
"@tiptap/extension-table-header@2.10.4":
|
||||
version "2.10.4"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-table-header/-/extension-table-header-2.10.4.tgz#a7b8177baeca97e075288724b446fb81bdd2bcc4"
|
||||
integrity sha512-NVi/KMBh9IAzpukjptCsH+gibZB3VxgCc+wuFk41QqI5ABnTPKWflnQ0wRo7IC6wC/tUi4YBahh20dL/wBJn3w==
|
||||
|
||||
"@tiptap/extension-table-row@2.10.4":
|
||||
version "2.10.4"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-table-row/-/extension-table-row-2.10.4.tgz#7106d407e8654eebfe884ffce2a1caf9877034d8"
|
||||
integrity sha512-kpQQSZQNYHhencIk+lzs+zWtgg6nUXHIVQKZUg5dVT0VP2JNO7wPM6d8HgnscvxOkJNRVF/Q5dYe0Cb4tROIKg==
|
||||
|
||||
"@tiptap/extension-table@2.10.4":
|
||||
version "2.10.4"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-table/-/extension-table-2.10.4.tgz#a60bb346c0a608f8b81cd6a3a182967d06f1e764"
|
||||
integrity sha512-ak1RT8n0WQFNnVsZ9e6QFLWlRQP0IjT+Yp/PTsx5fSmqkiiwQKGs1ILCJWlBB3H0hV7N69aaOtK3h/35lmqoEg==
|
||||
|
||||
"@tiptap/extension-text-style@2.10.4", "@tiptap/extension-text-style@^2.10.4":
|
||||
version "2.10.4"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-text-style/-/extension-text-style-2.10.4.tgz#07af922c266fef7da13cc83befbbd5da512b7402"
|
||||
integrity sha512-ibq7avkcwHyUSG53Hf+P31rrwsKVbbiqbWZM4kXC7M2X3iUwFrtvaa+SWzyWQfE1jl2cCrD1+rfSkj/alcOKGg==
|
||||
|
||||
"@tiptap/extension-text@2.10.4", "@tiptap/extension-text@^2.10.4":
|
||||
version "2.10.4"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-text/-/extension-text-2.10.4.tgz#ca5c7618ec122cfd72399b8c593023cf4a5829d5"
|
||||
integrity sha512-wPdVxCHrIS9S+8n08lgyyqRZPj9FBbyLlFt74/lV5yBC3LOorq1VKdjrTskmaj4jud7ImXoKDyBddAYTHdJ1xw==
|
||||
|
||||
"@tiptap/extension-underline@2.10.4":
|
||||
version "2.10.4"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-underline/-/extension-underline-2.10.4.tgz#5892a3dc7997818c0bbc0dde4e48b036d6786975"
|
||||
integrity sha512-KhlCndQFMe/Gsz+3qkVn9z1utDy8y1igvdePijMjA5B8PTu0hPs2Q1d6szfLTBdtoFNkCokknxzXhSY0OFJEyQ==
|
||||
|
||||
"@tiptap/html@2.10.4":
|
||||
version "2.10.4"
|
||||
@@ -5709,6 +5811,16 @@
|
||||
expect "^29.0.0"
|
||||
pretty-format "^29.0.0"
|
||||
|
||||
"@types/jsdom@28.0.1":
|
||||
version "28.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@types/jsdom/-/jsdom-28.0.1.tgz#2c014d8c0eca6135233519bff8c49f7aadfeda63"
|
||||
integrity sha512-GJq2QE4TAZ5ajSoCasn5DOFm8u1mI3tIFvM5tIq3W5U/RTB6gsHwc6Yhpl91X9VSDOUVblgXmG+2+sSvFQrdlw==
|
||||
dependencies:
|
||||
"@types/node" "*"
|
||||
"@types/tough-cookie" "*"
|
||||
parse5 "^7.0.0"
|
||||
undici-types "^7.21.0"
|
||||
|
||||
"@types/json-schema@^7.0.15", "@types/json-schema@^7.0.6":
|
||||
version "7.0.15"
|
||||
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841"
|
||||
@@ -6830,6 +6942,13 @@ before-after-hook@^2.2.0:
|
||||
resolved "https://registry.yarnpkg.com/before-after-hook/-/before-after-hook-2.2.3.tgz#c51e809c81a4e354084422b9b26bad88249c517c"
|
||||
integrity sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==
|
||||
|
||||
bidi-js@^1.0.3:
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/bidi-js/-/bidi-js-1.0.3.tgz#6f8bcf3c877c4d9220ddf49b9bb6930c88f877d2"
|
||||
integrity sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==
|
||||
dependencies:
|
||||
require-from-string "^2.0.2"
|
||||
|
||||
big-integer@1.6.x, big-integer@^1.6.48:
|
||||
version "1.6.52"
|
||||
resolved "https://registry.yarnpkg.com/big-integer/-/big-integer-1.6.52.tgz#60a887f3047614a8e1bffe5d7173490a97dc8c85"
|
||||
@@ -8032,6 +8151,14 @@ css-tree@^2.3.1:
|
||||
mdn-data "2.0.30"
|
||||
source-map-js "^1.0.1"
|
||||
|
||||
css-tree@^3.0.0, css-tree@^3.2.1:
|
||||
version "3.2.1"
|
||||
resolved "https://registry.yarnpkg.com/css-tree/-/css-tree-3.2.1.tgz#86cac7011561272b30e6b1e042ba6ce047aa7518"
|
||||
integrity sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==
|
||||
dependencies:
|
||||
mdn-data "2.27.1"
|
||||
source-map-js "^1.2.1"
|
||||
|
||||
css-tree@~2.2.0:
|
||||
version "2.2.1"
|
||||
resolved "https://registry.yarnpkg.com/css-tree/-/css-tree-2.2.1.tgz#36115d382d60afd271e377f9c5f67d02bd48c032"
|
||||
@@ -8163,6 +8290,14 @@ data-uri-to-buffer@^6.0.2:
|
||||
resolved "https://registry.yarnpkg.com/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz#8a58bb67384b261a38ef18bea1810cb01badd28b"
|
||||
integrity sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==
|
||||
|
||||
data-urls@^7.0.0:
|
||||
version "7.0.0"
|
||||
resolved "https://registry.yarnpkg.com/data-urls/-/data-urls-7.0.0.tgz#6dce8b63226a1ecfdd907ce18a8ccfb1eee506d3"
|
||||
integrity sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==
|
||||
dependencies:
|
||||
whatwg-mimetype "^5.0.0"
|
||||
whatwg-url "^16.0.0"
|
||||
|
||||
data-view-buffer@^1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/data-view-buffer/-/data-view-buffer-1.0.1.tgz#8ea6326efec17a2e42620696e671d7d5a8bc66b2"
|
||||
@@ -8299,6 +8434,11 @@ decimal.js-light@^2.5.1:
|
||||
resolved "https://registry.yarnpkg.com/decimal.js-light/-/decimal.js-light-2.5.1.tgz#134fd32508f19e208f4fb2f8dac0d2626a867934"
|
||||
integrity sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==
|
||||
|
||||
decimal.js@^10.6.0:
|
||||
version "10.6.0"
|
||||
resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.6.0.tgz#e649a43e3ab953a72192ff5983865e509f37ed9a"
|
||||
integrity sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==
|
||||
|
||||
decode-named-character-reference@^1.0.0:
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz#25c32ae6dd5e21889549d40f676030e9514cc0ed"
|
||||
@@ -8763,6 +8903,11 @@ entities@^5.0.0:
|
||||
resolved "https://registry.yarnpkg.com/entities/-/entities-5.0.0.tgz#b2ab51fe40d995817979ec79dd621154c3c0f62b"
|
||||
integrity sha512-BeJFvFRJddxobhvEdm5GqHzRV/X+ACeuw0/BuuxsCh1EUZcAIz8+kYmBp/LrQuloy6K1f3a0M7+IhmZ7QnkISA==
|
||||
|
||||
entities@^6.0.0:
|
||||
version "6.0.1"
|
||||
resolved "https://registry.yarnpkg.com/entities/-/entities-6.0.1.tgz#c28c34a43379ca7f61d074130b2f5f7020a30694"
|
||||
integrity sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==
|
||||
|
||||
env-paths@^2.2.0:
|
||||
version "2.2.1"
|
||||
resolved "https://registry.yarnpkg.com/env-paths/-/env-paths-2.2.1.tgz#420399d416ce1fbe9bc0a07c62fa68d67fd0f8f2"
|
||||
@@ -10911,6 +11056,13 @@ hosted-git-info@^7.0.0:
|
||||
dependencies:
|
||||
lru-cache "^10.0.1"
|
||||
|
||||
html-encoding-sniffer@^6.0.0:
|
||||
version "6.0.0"
|
||||
resolved "https://registry.yarnpkg.com/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz#f8d9390b3b348b50d4f61c16dd2ef5c05980a882"
|
||||
integrity sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==
|
||||
dependencies:
|
||||
"@exodus/bytes" "^1.6.0"
|
||||
|
||||
html-entities@^2.5.2:
|
||||
version "2.6.0"
|
||||
resolved "https://registry.yarnpkg.com/html-entities/-/html-entities-2.6.0.tgz#7c64f1ea3b36818ccae3d3fb48b6974208e984f8"
|
||||
@@ -11513,6 +11665,11 @@ is-plain-object@^5.0.0:
|
||||
resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-5.0.0.tgz#4427f50ab3429e9025ea7d52e9043a9ef4159344"
|
||||
integrity sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==
|
||||
|
||||
is-potential-custom-element-name@^1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz#171ed6f19e3ac554394edf78caa05784a45bebb5"
|
||||
integrity sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==
|
||||
|
||||
is-promise@^4.0.0:
|
||||
version "4.0.0"
|
||||
resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-4.0.0.tgz#42ff9f84206c1991d26debf520dd5c01042dd2f3"
|
||||
@@ -12294,6 +12451,33 @@ jsdoc@^4.0.0:
|
||||
strip-json-comments "^3.1.0"
|
||||
underscore "~1.13.2"
|
||||
|
||||
jsdom@29.0.1:
|
||||
version "29.0.1"
|
||||
resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-29.0.1.tgz#b2db17191533dd5ba1e0d4c61fe9fa2289e87be9"
|
||||
integrity sha512-z6JOK5gRO7aMybVq/y/MlIpKh8JIi68FBKMUtKkK2KH/wMSRlCxQ682d08LB9fYXplyY/UXG8P4XXTScmdjApg==
|
||||
dependencies:
|
||||
"@asamuzakjp/css-color" "^5.0.1"
|
||||
"@asamuzakjp/dom-selector" "^7.0.3"
|
||||
"@bramus/specificity" "^2.4.2"
|
||||
"@csstools/css-syntax-patches-for-csstree" "^1.1.1"
|
||||
"@exodus/bytes" "^1.15.0"
|
||||
css-tree "^3.2.1"
|
||||
data-urls "^7.0.0"
|
||||
decimal.js "^10.6.0"
|
||||
html-encoding-sniffer "^6.0.0"
|
||||
is-potential-custom-element-name "^1.0.1"
|
||||
lru-cache "^11.2.7"
|
||||
parse5 "^8.0.0"
|
||||
saxes "^6.0.0"
|
||||
symbol-tree "^3.2.4"
|
||||
tough-cookie "^6.0.1"
|
||||
undici "^7.24.5"
|
||||
w3c-xmlserializer "^5.0.0"
|
||||
webidl-conversions "^8.0.1"
|
||||
whatwg-mimetype "^5.0.0"
|
||||
whatwg-url "^16.0.1"
|
||||
xml-name-validator "^5.0.0"
|
||||
|
||||
jsesc@^2.5.1:
|
||||
version "2.5.2"
|
||||
resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.2.tgz#80564d2e483dacf6e8ef209650a67df3f0c283a4"
|
||||
@@ -12802,6 +12986,11 @@ lru-cache@^11.0.0:
|
||||
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-11.2.2.tgz#40fd37edffcfae4b2940379c0722dc6eeaa75f24"
|
||||
integrity sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==
|
||||
|
||||
lru-cache@^11.2.6, lru-cache@^11.2.7:
|
||||
version "11.2.7"
|
||||
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-11.2.7.tgz#9127402617f34cd6767b96daee98c28e74458d35"
|
||||
integrity sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==
|
||||
|
||||
lru-cache@^5.1.1:
|
||||
version "5.1.1"
|
||||
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920"
|
||||
@@ -12930,6 +13119,11 @@ marked-terminal@^7.0.0:
|
||||
node-emoji "^2.2.0"
|
||||
supports-hyperlinks "^3.1.0"
|
||||
|
||||
marked@17.0.5:
|
||||
version "17.0.5"
|
||||
resolved "https://registry.yarnpkg.com/marked/-/marked-17.0.5.tgz#8fc6878a439463a007b05d346d2ad50a87ec3f0e"
|
||||
integrity sha512-6hLvc0/JEbRjRgzI6wnT2P1XuM1/RrrDEX0kPt0N7jGm1133g6X7DlxFasUIx+72aKAr904GTxhSLDrd5DIlZg==
|
||||
|
||||
marked@^13.0.2:
|
||||
version "13.0.3"
|
||||
resolved "https://registry.yarnpkg.com/marked/-/marked-13.0.3.tgz#5c5b4a5d0198060c7c9bc6ef9420a7fed30f822d"
|
||||
@@ -13065,6 +13259,11 @@ mdn-data@2.0.30:
|
||||
resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.30.tgz#ce4df6f80af6cfbe218ecd5c552ba13c4dfa08cc"
|
||||
integrity sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==
|
||||
|
||||
mdn-data@2.27.1:
|
||||
version "2.27.1"
|
||||
resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.27.1.tgz#e37b9c50880b75366c4d40ac63d9bbcacdb61f0e"
|
||||
integrity sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==
|
||||
|
||||
mdurl@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/mdurl/-/mdurl-2.0.0.tgz#80676ec0433025dd3e17ee983d0fe8de5a2237e0"
|
||||
@@ -14376,6 +14575,20 @@ parse5@^6.0.1:
|
||||
resolved "https://registry.yarnpkg.com/parse5/-/parse5-6.0.1.tgz#e1a1c085c569b3dc08321184f19a39cc27f7c30b"
|
||||
integrity sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==
|
||||
|
||||
parse5@^7.0.0:
|
||||
version "7.3.0"
|
||||
resolved "https://registry.yarnpkg.com/parse5/-/parse5-7.3.0.tgz#d7e224fa72399c7a175099f45fc2ad024b05ec05"
|
||||
integrity sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==
|
||||
dependencies:
|
||||
entities "^6.0.0"
|
||||
|
||||
parse5@^8.0.0:
|
||||
version "8.0.0"
|
||||
resolved "https://registry.yarnpkg.com/parse5/-/parse5-8.0.0.tgz#aceb267f6b15f9b6e6ba9e35bfdd481fc2167b12"
|
||||
integrity sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==
|
||||
dependencies:
|
||||
entities "^6.0.0"
|
||||
|
||||
parseley@^0.12.0:
|
||||
version "0.12.1"
|
||||
resolved "https://registry.yarnpkg.com/parseley/-/parseley-0.12.1.tgz#4afd561d50215ebe259e3e7a853e62f600683aef"
|
||||
@@ -14492,6 +14705,11 @@ pg-connection-string@^2.11.0:
|
||||
resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.11.0.tgz#5dca53ff595df33ba9db812e181b19909866d10b"
|
||||
integrity sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==
|
||||
|
||||
pg-connection-string@^2.12.0:
|
||||
version "2.12.0"
|
||||
resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.12.0.tgz#4084f917902bb2daae3dc1376fe24ac7b4eaccf2"
|
||||
integrity sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==
|
||||
|
||||
pg-connection-string@^2.9.1:
|
||||
version "2.9.1"
|
||||
resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.9.1.tgz#bb1fd0011e2eb76ac17360dc8fa183b2d3465238"
|
||||
@@ -14527,10 +14745,10 @@ pg-pool@^3.11.0:
|
||||
resolved "https://registry.yarnpkg.com/pg-pool/-/pg-pool-3.11.0.tgz#adf9a6651a30c839f565a3cc400110949c473d69"
|
||||
integrity sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==
|
||||
|
||||
pg-pool@^3.12.0:
|
||||
version "3.12.0"
|
||||
resolved "https://registry.yarnpkg.com/pg-pool/-/pg-pool-3.12.0.tgz#798c84ec7d42ba03fff056ebe575daa6e14feab8"
|
||||
integrity sha512-eIJ0DES8BLaziFHW7VgJEBPi5hg3Nyng5iKpYtj3wbcAUV9A1wLgWiY7ajf/f/oO1wfxt83phXPY8Emztg7ITg==
|
||||
pg-pool@^3.13.0:
|
||||
version "3.13.0"
|
||||
resolved "https://registry.yarnpkg.com/pg-pool/-/pg-pool-3.13.0.tgz#416482e9700e8f80c685a6ae5681697a413c13a3"
|
||||
integrity sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==
|
||||
|
||||
pg-promise@12.6.1:
|
||||
version "12.6.1"
|
||||
@@ -14542,7 +14760,7 @@ pg-promise@12.6.1:
|
||||
pg-minify "1.8.0"
|
||||
spex "4.1.0"
|
||||
|
||||
pg-protocol@*, pg-protocol@^1.12.0:
|
||||
pg-protocol@*:
|
||||
version "1.12.0"
|
||||
resolved "https://registry.yarnpkg.com/pg-protocol/-/pg-protocol-1.12.0.tgz#e9827f3e1dae6cdcb78d009cba5bb699d88ae998"
|
||||
integrity sha512-uOANXNRACNdElMXJ0tPz6RBM0XQ61nONGAwlt8da5zs/iUOOCLBQOHSXnrC6fMsvtjxbOJrZZl5IScGv+7mpbg==
|
||||
@@ -14557,6 +14775,11 @@ pg-protocol@^1.11.0:
|
||||
resolved "https://registry.yarnpkg.com/pg-protocol/-/pg-protocol-1.11.0.tgz#2502908893edaa1e8c0feeba262dd7b40b317b53"
|
||||
integrity sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==
|
||||
|
||||
pg-protocol@^1.13.0:
|
||||
version "1.13.0"
|
||||
resolved "https://registry.yarnpkg.com/pg-protocol/-/pg-protocol-1.13.0.tgz#fdaf6d020bca590d58bb991b4b16fc448efe0511"
|
||||
integrity sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==
|
||||
|
||||
pg-query-stream@4.12.0:
|
||||
version "4.12.0"
|
||||
resolved "https://registry.yarnpkg.com/pg-query-stream/-/pg-query-stream-4.12.0.tgz#fd4496d75e5f7c7f233dee0c88e813e165b86b2f"
|
||||
@@ -14589,13 +14812,13 @@ pg@8.18.0:
|
||||
pg-cloudflare "^1.3.0"
|
||||
|
||||
pg@8.x:
|
||||
version "8.19.0"
|
||||
resolved "https://registry.yarnpkg.com/pg/-/pg-8.19.0.tgz#2cb45322471c1ed05786ee7ec09bd91abdfe3eeb"
|
||||
integrity sha512-QIcLGi508BAHkQ3pJNptsFz5WQMlpGbuBGBaIaXsWK8mel2kQ/rThYI+DbgjUvZrIr7MiuEuc9LcChJoEZK1xQ==
|
||||
version "8.20.0"
|
||||
resolved "https://registry.yarnpkg.com/pg/-/pg-8.20.0.tgz#1a274de944cb329fd6dd77a6d371a005ba6b136d"
|
||||
integrity sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==
|
||||
dependencies:
|
||||
pg-connection-string "^2.11.0"
|
||||
pg-pool "^3.12.0"
|
||||
pg-protocol "^1.12.0"
|
||||
pg-connection-string "^2.12.0"
|
||||
pg-pool "^3.13.0"
|
||||
pg-protocol "^1.13.0"
|
||||
pg-types "2.2.0"
|
||||
pgpass "1.0.5"
|
||||
optionalDependencies:
|
||||
@@ -15347,7 +15570,7 @@ punycode.js@^2.3.1:
|
||||
resolved "https://registry.yarnpkg.com/punycode.js/-/punycode.js-2.3.1.tgz#6b53e56ad75588234e79f4affa90972c7dd8cdb7"
|
||||
integrity sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==
|
||||
|
||||
punycode@^2.1.0:
|
||||
punycode@^2.1.0, punycode@^2.3.1:
|
||||
version "2.3.1"
|
||||
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5"
|
||||
integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
|
||||
@@ -16185,6 +16408,13 @@ sax@>=0.6.0, sax@^1.2.4:
|
||||
resolved "https://registry.yarnpkg.com/sax/-/sax-1.4.1.tgz#44cc8988377f126304d3b3fc1010c733b929ef0f"
|
||||
integrity sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==
|
||||
|
||||
saxes@^6.0.0:
|
||||
version "6.0.0"
|
||||
resolved "https://registry.yarnpkg.com/saxes/-/saxes-6.0.0.tgz#fe5b4a4768df4f14a201b1ba6a65c1f3d9988cc5"
|
||||
integrity sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==
|
||||
dependencies:
|
||||
xmlchars "^2.2.0"
|
||||
|
||||
scheduler@^0.27.0:
|
||||
version "0.27.0"
|
||||
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.27.0.tgz#0c4ef82d67d1e5c1e359e8fc76d3a87f045fe5bd"
|
||||
@@ -17154,6 +17384,11 @@ swagger-ui-express@5.0.1:
|
||||
dependencies:
|
||||
swagger-ui-dist ">=5.0.0"
|
||||
|
||||
symbol-tree@^3.2.4:
|
||||
version "3.2.4"
|
||||
resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2"
|
||||
integrity sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==
|
||||
|
||||
tabbable@^6.0.0:
|
||||
version "6.4.0"
|
||||
resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-6.4.0.tgz#36eb7a06d80b3924a22095daf45740dea3bf5581"
|
||||
@@ -17397,6 +17632,18 @@ tippy.js@^6.3.7:
|
||||
dependencies:
|
||||
"@popperjs/core" "^2.9.0"
|
||||
|
||||
tldts-core@^7.0.27:
|
||||
version "7.0.27"
|
||||
resolved "https://registry.yarnpkg.com/tldts-core/-/tldts-core-7.0.27.tgz#4be95bd03b318f2232ea4c1554c4ae9980c77f69"
|
||||
integrity sha512-YQ7uPjgWUibIK6DW5lrKujGwUKhLevU4hcGbP5O6TcIUb+oTjJYJVWPS4nZsIHrEEEG6myk/oqAJUEQmpZrHsg==
|
||||
|
||||
tldts@^7.0.5:
|
||||
version "7.0.27"
|
||||
resolved "https://registry.yarnpkg.com/tldts/-/tldts-7.0.27.tgz#43c3fc6123eb07a3e12ae1868a9f2d1a5889028c"
|
||||
integrity sha512-I4FZcVFcqCRuT0ph6dCDpPuO4Xgzvh+spkcTr1gK7peIvxWauoloVO0vuy1FQnijT63ss6AsHB6+OIM4aXHbPg==
|
||||
dependencies:
|
||||
tldts-core "^7.0.27"
|
||||
|
||||
tmp@^0.2.1:
|
||||
version "0.2.3"
|
||||
resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.2.3.tgz#eb783cc22bc1e8bebd0671476d46ea4eb32a79ae"
|
||||
@@ -17436,6 +17683,13 @@ touch@^3.1.0:
|
||||
dependencies:
|
||||
nopt "~1.0.10"
|
||||
|
||||
tough-cookie@^6.0.1:
|
||||
version "6.0.1"
|
||||
resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-6.0.1.tgz#a495f833836609ed983c19bc65639cfbceb54c76"
|
||||
integrity sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==
|
||||
dependencies:
|
||||
tldts "^7.0.5"
|
||||
|
||||
toxic@^1.0.0:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/toxic/-/toxic-1.0.1.tgz#8c2e2528da591100adc3883f2c0e56acfb1c7288"
|
||||
@@ -17443,6 +17697,13 @@ toxic@^1.0.0:
|
||||
dependencies:
|
||||
lodash "^4.17.10"
|
||||
|
||||
tr46@^6.0.0:
|
||||
version "6.0.0"
|
||||
resolved "https://registry.yarnpkg.com/tr46/-/tr46-6.0.0.tgz#f5a1ae546a0adb32a277a2278d0d17fa2f9093e6"
|
||||
integrity sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==
|
||||
dependencies:
|
||||
punycode "^2.3.1"
|
||||
|
||||
tr46@~0.0.3:
|
||||
version "0.0.3"
|
||||
resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a"
|
||||
@@ -17848,11 +18109,21 @@ underscore@~1.13.2:
|
||||
resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.13.6.tgz#04786a1f589dc6c09f761fc5f45b89e935136441"
|
||||
integrity sha512-+A5Sja4HP1M08MaXya7p5LvjuM7K6q/2EaC0+iovj/wOcMsTzMvDFbasi/oSapiwOlt252IqsKqPjCl7huKS0A==
|
||||
|
||||
undici-types@^7.21.0:
|
||||
version "7.24.6"
|
||||
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.24.6.tgz#61275b485d7fd4e9d269c7cf04ec2873c9cc0f91"
|
||||
integrity sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==
|
||||
|
||||
undici-types@~6.21.0:
|
||||
version "6.21.0"
|
||||
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.21.0.tgz#691d00af3909be93a7faa13be61b3a5b50ef12cb"
|
||||
integrity sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==
|
||||
|
||||
undici@^7.24.5:
|
||||
version "7.24.6"
|
||||
resolved "https://registry.yarnpkg.com/undici/-/undici-7.24.6.tgz#b7e977e1bab53d0aab3fa5722fc1efff093aa5e9"
|
||||
integrity sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA==
|
||||
|
||||
unicode-canonical-property-names-ecmascript@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz#301acdc525631670d39f6146e0e77ff6bbdebddc"
|
||||
@@ -18177,6 +18448,13 @@ w3c-keyname@^2.2.0:
|
||||
resolved "https://registry.yarnpkg.com/w3c-keyname/-/w3c-keyname-2.2.8.tgz#7b17c8c6883d4e8b86ac8aba79d39e880f8869c5"
|
||||
integrity sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==
|
||||
|
||||
w3c-xmlserializer@^5.0.0:
|
||||
version "5.0.0"
|
||||
resolved "https://registry.yarnpkg.com/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz#f925ba26855158594d907313cedd1476c5967f6c"
|
||||
integrity sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==
|
||||
dependencies:
|
||||
xml-name-validator "^5.0.0"
|
||||
|
||||
wait-on@9.0.4:
|
||||
version "9.0.4"
|
||||
resolved "https://registry.yarnpkg.com/wait-on/-/wait-on-9.0.4.tgz#ddf3a44ebd18f380621855d52973069ff2cb5b54"
|
||||
@@ -18228,6 +18506,11 @@ webidl-conversions@^3.0.0:
|
||||
resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871"
|
||||
integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==
|
||||
|
||||
webidl-conversions@^8.0.1:
|
||||
version "8.0.1"
|
||||
resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-8.0.1.tgz#0657e571fe6f06fcb15ca50ed1fdbcb495cd1686"
|
||||
integrity sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==
|
||||
|
||||
websocket-driver@>=0.5.1:
|
||||
version "0.7.4"
|
||||
resolved "https://registry.yarnpkg.com/websocket-driver/-/websocket-driver-0.7.4.tgz#89ad5295bbf64b480abcba31e4953aca706f5760"
|
||||
@@ -18247,6 +18530,20 @@ whatwg-fetch@^3.4.1:
|
||||
resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz#580ce6d791facec91d37c72890995a0b48d31c70"
|
||||
integrity sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==
|
||||
|
||||
whatwg-mimetype@^5.0.0:
|
||||
version "5.0.0"
|
||||
resolved "https://registry.yarnpkg.com/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz#d8232895dbd527ceaee74efd4162008fb8a8cf48"
|
||||
integrity sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==
|
||||
|
||||
whatwg-url@^16.0.0, whatwg-url@^16.0.1:
|
||||
version "16.0.1"
|
||||
resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-16.0.1.tgz#047f7f4bd36ef76b7198c172d1b1cebc66f764dd"
|
||||
integrity sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==
|
||||
dependencies:
|
||||
"@exodus/bytes" "^1.11.0"
|
||||
tr46 "^6.0.0"
|
||||
webidl-conversions "^8.0.1"
|
||||
|
||||
whatwg-url@^5.0.0:
|
||||
version "5.0.0"
|
||||
resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d"
|
||||
@@ -18494,6 +18791,11 @@ xml-js@^1.6.11:
|
||||
dependencies:
|
||||
sax "^1.2.4"
|
||||
|
||||
xml-name-validator@^5.0.0:
|
||||
version "5.0.0"
|
||||
resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-5.0.0.tgz#82be9b957f7afdacf961e5980f1bf227c0bf7673"
|
||||
integrity sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==
|
||||
|
||||
xml2js@^0.5.0:
|
||||
version "0.5.0"
|
||||
resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.5.0.tgz#d9440631fbb2ed800203fad106f2724f62c493b7"
|
||||
@@ -18520,6 +18822,11 @@ xmlbuilder@~11.0.0:
|
||||
resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-11.0.1.tgz#be9bae1c8a046e76b31127726347d0ad7002beb3"
|
||||
integrity sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==
|
||||
|
||||
xmlchars@^2.2.0:
|
||||
version "2.2.0"
|
||||
resolved "https://registry.yarnpkg.com/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb"
|
||||
integrity sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==
|
||||
|
||||
xmlcreate@^2.0.4:
|
||||
version "2.0.4"
|
||||
resolved "https://registry.yarnpkg.com/xmlcreate/-/xmlcreate-2.0.4.tgz#0c5ab0f99cdd02a81065fa9cd8f8ae87624889be"
|
||||
|
||||
Reference in New Issue
Block a user