feat(release): prepare 1.1.0 candidate
This commit is contained in:
parent
5a34f66981
commit
5205dcdfa9
736 changed files with 115667 additions and 12203 deletions
2
apps/mobile-rn/src/features/memos/index.ts
Normal file
2
apps/mobile-rn/src/features/memos/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './types'
|
||||
export * from './memo-service'
|
||||
264
apps/mobile-rn/src/features/memos/memo-service.ts
Normal file
264
apps/mobile-rn/src/features/memos/memo-service.ts
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
import type { RealtimeChannel } from '@supabase/supabase-js'
|
||||
import type { HistoryEntry, MemoTag } from '@d3ro/api-client'
|
||||
import { supabase } from '../../lib/supabase'
|
||||
import type {
|
||||
MemoHistoryItem,
|
||||
MemoSearchResult,
|
||||
MemoTagSummary,
|
||||
} from './types'
|
||||
|
||||
export type MemoServiceErrorCode =
|
||||
| 'auth'
|
||||
| 'conflict'
|
||||
| 'network'
|
||||
| 'not-found'
|
||||
| 'server'
|
||||
| 'validation'
|
||||
|
||||
export class MemoServiceError extends Error {
|
||||
constructor(
|
||||
public readonly code: MemoServiceErrorCode,
|
||||
message: string,
|
||||
public readonly retryable = false,
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'MemoServiceError'
|
||||
}
|
||||
}
|
||||
export interface MemoSubscription {
|
||||
unsubscribe: () => Promise<void>
|
||||
}
|
||||
|
||||
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function requireUuid(value: string, label: string): void {
|
||||
if (!UUID_PATTERN.test(value)) throw new MemoServiceError('auth', `A valid ${label} is required`)
|
||||
}
|
||||
|
||||
export function normalizeMemoTag(value: string): { tag: string; normalizedTag: string } {
|
||||
const tag = value.trim().replace(/\s+/g, ' ')
|
||||
if (tag.length < 1 || tag.length > 80) {
|
||||
throw new MemoServiceError('validation', 'Memo tags must contain 1-80 characters')
|
||||
}
|
||||
return { tag, normalizedTag: tag.toLocaleLowerCase('en-US') }
|
||||
}
|
||||
|
||||
export function sanitizeMemoSearch(value: string): string {
|
||||
return value.trim().slice(0, 100).replace(/[\u0000-\u001f\u007f]/g, ' ')
|
||||
.replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
function toMemoError(error: unknown): MemoServiceError {
|
||||
if (error instanceof MemoServiceError) return error
|
||||
const candidate = error as { code?: unknown; message?: unknown }
|
||||
const code = typeof candidate?.code === 'string' ? candidate.code : ''
|
||||
const message = typeof candidate?.message === 'string' ? candidate.message : 'Memo request failed'
|
||||
const lower = message.toLowerCase()
|
||||
if (
|
||||
error instanceof TypeError
|
||||
|| lower.includes('network request failed')
|
||||
|| lower.includes('failed to fetch')
|
||||
|| lower.includes('networkerror')
|
||||
) return new MemoServiceError('network', message, true)
|
||||
if (code === '40001' || lower.includes('conflict')) {
|
||||
return new MemoServiceError('conflict', message, true)
|
||||
}
|
||||
if (code === 'P0002') return new MemoServiceError('not-found', message)
|
||||
if (code === '42501' || code === 'PGRST301' || lower.includes('jwt')) {
|
||||
return new MemoServiceError('auth', message)
|
||||
}
|
||||
if (code.startsWith('22')) return new MemoServiceError('validation', message)
|
||||
return new MemoServiceError('server', message, true)
|
||||
}
|
||||
|
||||
async function rpc<T>(name: string, params: Record<string, unknown> = {}): Promise<T> {
|
||||
const { data, error } = await supabase.rpc(name as never, params as never)
|
||||
if (error !== null) throw toMemoError(error)
|
||||
return data as T
|
||||
}
|
||||
|
||||
function assertHistory(value: unknown, userId: string): HistoryEntry {
|
||||
if (
|
||||
!isRecord(value)
|
||||
|| typeof value.id !== 'string'
|
||||
|| !UUID_PATTERN.test(value.id)
|
||||
|| value.user_id !== userId
|
||||
|| typeof value.original_text !== 'string'
|
||||
|| typeof value.created_at !== 'string'
|
||||
) throw new MemoServiceError('server', 'Server returned an invalid memo history row')
|
||||
return value as HistoryEntry
|
||||
}
|
||||
|
||||
function parseSearchRows(value: unknown, userId: string): MemoHistoryItem[] {
|
||||
if (!Array.isArray(value)) throw new MemoServiceError('server', 'Server returned an invalid memo result')
|
||||
return value.map((row) => {
|
||||
if (!isRecord(row) || !Array.isArray(row.tags) || !row.tags.every((tag) => typeof tag === 'string')) {
|
||||
throw new MemoServiceError('server', 'Server returned invalid memo tags')
|
||||
}
|
||||
const seen = new Set<string>()
|
||||
const tags = row.tags.filter((tag) => {
|
||||
const normalized = normalizeMemoTag(tag).normalizedTag
|
||||
if (seen.has(normalized)) return false
|
||||
seen.add(normalized)
|
||||
return true
|
||||
})
|
||||
return { history: assertHistory(row.history_row, userId), tags }
|
||||
})
|
||||
}
|
||||
|
||||
function parseTagSummaries(value: unknown): MemoTagSummary[] {
|
||||
if (!Array.isArray(value)) throw new MemoServiceError('server', 'Server returned invalid tag summaries')
|
||||
return value.map((row) => {
|
||||
if (
|
||||
!isRecord(row)
|
||||
|| typeof row.tag !== 'string'
|
||||
|| typeof row.normalized_tag !== 'string'
|
||||
|| !Number.isSafeInteger(Number(row.history_count))
|
||||
|| Number(row.history_count) < 0
|
||||
) throw new MemoServiceError('server', 'Server returned an invalid tag summary')
|
||||
const normalized = normalizeMemoTag(row.tag)
|
||||
if (normalized.normalizedTag !== row.normalized_tag) {
|
||||
throw new MemoServiceError('server', 'Server returned a non-canonical tag summary')
|
||||
}
|
||||
return {
|
||||
tag: normalized.tag,
|
||||
normalizedTag: normalized.normalizedTag,
|
||||
historyCount: Number(row.history_count),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function searchMemos(
|
||||
userId: string,
|
||||
queryValue = '',
|
||||
tagValue: string | null = null,
|
||||
limit = 50,
|
||||
offset = 0,
|
||||
): Promise<MemoSearchResult> {
|
||||
requireUuid(userId, 'authenticated user')
|
||||
const query = sanitizeMemoSearch(queryValue)
|
||||
const tag = tagValue === null ? null : normalizeMemoTag(tagValue).tag
|
||||
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100 || !Number.isSafeInteger(offset) || offset < 0) {
|
||||
throw new MemoServiceError('validation', 'Memo page bounds are invalid')
|
||||
}
|
||||
try {
|
||||
const [rows, summaries] = await Promise.all([
|
||||
rpc<unknown>('mobile_search_memos_v1', {
|
||||
p_query: query,
|
||||
p_tag: tag,
|
||||
p_limit: limit,
|
||||
p_offset: offset,
|
||||
}),
|
||||
rpc<unknown>('mobile_list_memo_tags_v1'),
|
||||
])
|
||||
return {
|
||||
items: parseSearchRows(rows, userId),
|
||||
tagSummaries: parseTagSummaries(summaries),
|
||||
}
|
||||
} catch (error) {
|
||||
throw toMemoError(error)
|
||||
}
|
||||
}
|
||||
|
||||
export async function addMemoTag(
|
||||
userId: string,
|
||||
historyId: string,
|
||||
tagValue: string,
|
||||
): Promise<MemoTag> {
|
||||
requireUuid(userId, 'authenticated user')
|
||||
requireUuid(historyId, 'history id')
|
||||
const tag = normalizeMemoTag(tagValue).tag
|
||||
try {
|
||||
const saved = await rpc<MemoTag>('mobile_add_memo_tag_v1', {
|
||||
p_history_id: historyId,
|
||||
p_tag: tag,
|
||||
})
|
||||
if (
|
||||
saved.user_id !== userId
|
||||
|| saved.history_id !== historyId
|
||||
|| normalizeMemoTag(saved.tag).normalizedTag !== normalizeMemoTag(tag).normalizedTag
|
||||
) throw new MemoServiceError('server', 'Server returned an invalid memo tag')
|
||||
return saved
|
||||
} catch (error) {
|
||||
throw toMemoError(error)
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeMemoTag(
|
||||
userId: string,
|
||||
historyId: string,
|
||||
tagValue: string,
|
||||
): Promise<void> {
|
||||
requireUuid(userId, 'authenticated user')
|
||||
requireUuid(historyId, 'history id')
|
||||
const tag = normalizeMemoTag(tagValue).tag
|
||||
try {
|
||||
const removed = await rpc<boolean>('mobile_remove_memo_tag_v1', {
|
||||
p_history_id: historyId,
|
||||
p_tag: tag,
|
||||
})
|
||||
if (removed !== true) throw new MemoServiceError('not-found', 'Memo tag was not found')
|
||||
} catch (error) {
|
||||
throw toMemoError(error)
|
||||
}
|
||||
}
|
||||
|
||||
export async function renameMemoTag(
|
||||
userId: string,
|
||||
oldTagValue: string,
|
||||
newTagValue: string,
|
||||
): Promise<number> {
|
||||
requireUuid(userId, 'authenticated user')
|
||||
const oldTag = normalizeMemoTag(oldTagValue).tag
|
||||
const newTag = normalizeMemoTag(newTagValue).tag
|
||||
try {
|
||||
const changed = await rpc<number>('mobile_rename_memo_tag_v1', {
|
||||
p_old_tag: oldTag,
|
||||
p_new_tag: newTag,
|
||||
})
|
||||
if (!Number.isSafeInteger(changed) || changed < 0) {
|
||||
throw new MemoServiceError('server', 'Server returned an invalid rename count')
|
||||
}
|
||||
if (changed === 0) throw new MemoServiceError('not-found', 'Memo tag was not found')
|
||||
return changed
|
||||
} catch (error) {
|
||||
throw toMemoError(error)
|
||||
}
|
||||
}
|
||||
|
||||
export function buildMemoShareText(item: MemoHistoryItem): string {
|
||||
const title = item.history.title?.trim() || 'D3RO Voice memo'
|
||||
const body = item.history.polished_text?.trim()
|
||||
|| item.history.summary_text?.trim()
|
||||
|| item.history.original_text.trim()
|
||||
const tags = item.tags.length > 0 ? `\n\n${item.tags.map((tag) => `#${tag}`).join(' ')}` : ''
|
||||
return `${title}\n\n${body}${tags}`.trim()
|
||||
}
|
||||
|
||||
export function subscribeToMemoTags(
|
||||
userId: string,
|
||||
onRemoteChange: () => void,
|
||||
onStatus: (status: string) => void,
|
||||
): MemoSubscription {
|
||||
requireUuid(userId, 'authenticated user')
|
||||
let channel: RealtimeChannel | null = supabase
|
||||
.channel(`mobile-memo-tags-${userId}`)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{ event: '*', schema: 'public', table: 'memo_tags', filter: `user_id=eq.${userId}` },
|
||||
onRemoteChange,
|
||||
)
|
||||
.subscribe(onStatus)
|
||||
return {
|
||||
unsubscribe: async (): Promise<void> => {
|
||||
if (channel === null) return
|
||||
const active = channel
|
||||
channel = null
|
||||
await supabase.removeChannel(active)
|
||||
},
|
||||
}
|
||||
}
|
||||
19
apps/mobile-rn/src/features/memos/types.ts
Normal file
19
apps/mobile-rn/src/features/memos/types.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import type { HistoryEntry, MemoTag } from '@d3ro/api-client'
|
||||
|
||||
export type { HistoryEntry, MemoTag }
|
||||
|
||||
export interface MemoTagSummary {
|
||||
tag: string
|
||||
normalizedTag: string
|
||||
historyCount: number
|
||||
}
|
||||
|
||||
export interface MemoHistoryItem {
|
||||
history: HistoryEntry
|
||||
tags: string[]
|
||||
}
|
||||
|
||||
export interface MemoSearchResult {
|
||||
items: MemoHistoryItem[]
|
||||
tagSummaries: MemoTagSummary[]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue