diff --git a/backend/src/lib/cosmos-init.ts b/backend/src/lib/cosmos-init.ts index a599e29..ad4569b 100644 --- a/backend/src/lib/cosmos-init.ts +++ b/backend/src/lib/cosmos-init.ts @@ -17,6 +17,7 @@ const CONTAINER_DEFS: Record = { note_versions: { partitionKeyPath: '/workspaceId' }, note_intake_rules: { partitionKeyPath: '/userId' }, note_intake_jobs: { partitionKeyPath: '/userId' }, + note_collaborators: { partitionKeyPath: '/sharedWithUserId' }, }; export async function initCosmosIfNeeded(): Promise { diff --git a/backend/src/modules/note-collaborators/repository.ts b/backend/src/modules/note-collaborators/repository.ts new file mode 100644 index 0000000..05cb187 --- /dev/null +++ b/backend/src/modules/note-collaborators/repository.ts @@ -0,0 +1,41 @@ +import { getCollection } from '../../lib/datastore.js'; +import type { FilterMap } from '@bytelyst/datastore'; +import type { NoteCollaboratorDoc } from './types.js'; + +function collection() { + return getCollection('note_collaborators', '/sharedWithUserId'); +} + +export async function createCollaborator(doc: NoteCollaboratorDoc): Promise { + return collection().create(doc); +} + +export async function listCollaboratorsForNote( + noteId: string, + productId: string, +): Promise { + const filter: FilterMap = { noteId, productId }; + return collection().findMany({ filter, sort: { createdAt: -1 }, limit: 100, offset: 0 }); +} + +export async function listSharedWithMe( + sharedWithUserId: string, + productId: string, +): Promise { + const filter: FilterMap = { sharedWithUserId, productId }; + return collection().findMany({ filter, sort: { createdAt: -1 }, limit: 100, offset: 0 }); +} + +export async function findCollaborator( + noteId: string, + sharedWithUserId: string, + productId: string, +): Promise { + const filter: FilterMap = { noteId, sharedWithUserId, productId }; + const items = await collection().findMany({ filter, limit: 1, offset: 0 }); + return items[0] ?? null; +} + +export async function deleteCollaborator(id: string, sharedWithUserId: string): Promise { + await collection().delete(id, sharedWithUserId); +} diff --git a/backend/src/modules/note-collaborators/routes.test.ts b/backend/src/modules/note-collaborators/routes.test.ts new file mode 100644 index 0000000..86632cd --- /dev/null +++ b/backend/src/modules/note-collaborators/routes.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; + +vi.mock('../../lib/request-context.js', () => ({ + getUserId: vi.fn(() => 'user_1'), + getRequestProductId: vi.fn(() => 'notelett'), +})); +vi.mock('../../lib/feature-flags.js', () => ({ + isFeatureEnabled: vi.fn(() => true), +})); +vi.mock('../../lib/telemetry.js', () => ({ trackEvent: vi.fn() })); +vi.mock('../../lib/product-config.js', () => ({ PRODUCT_ID: 'notelett' })); +vi.mock('../../lib/config.js', () => ({ config: {} })); +vi.mock('../../lib/embeddings.js', () => ({ + stripHtmlForEmbedding: vi.fn((s: string) => s.replace(/<[^>]*>/g, ' ').trim()), +})); + +const getNoteMock = vi.fn(async () => null); +vi.mock('../notes/repository.js', () => ({ + getNote: (...args: unknown[]) => getNoteMock(...args), +})); + +const createCollaboratorMock = vi.fn(async (doc: Record) => doc); +const listCollaboratorsForNoteMock = vi.fn(async () => []); +const listSharedWithMeMock = vi.fn(async () => []); +const findCollaboratorMock = vi.fn(async () => null); +const deleteCollaboratorMock = vi.fn(async () => undefined); +vi.mock('./repository.js', () => ({ + createCollaborator: (...args: unknown[]) => createCollaboratorMock(...args as [Record]), + listCollaboratorsForNote: (...args: unknown[]) => listCollaboratorsForNoteMock(...args), + listSharedWithMe: (...args: unknown[]) => listSharedWithMeMock(...args), + findCollaborator: (...args: unknown[]) => findCollaboratorMock(...args), + deleteCollaborator: (...args: unknown[]) => deleteCollaboratorMock(...args), +})); + +import { buildTestApp } from '../../test-helpers.js'; +import { noteCollaboratorRoutes } from './routes.js'; + +async function buildApp() { + return buildTestApp(noteCollaboratorRoutes); +} + +describe('note-collaborators routes', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('POST /notes/:id/share-with-user', () => { + it('shares a note with another user', async () => { + getNoteMock.mockResolvedValueOnce({ id: 'n1', userId: 'user_1', productId: 'notelett' }); + findCollaboratorMock.mockResolvedValueOnce(null); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/notes/n1/share-with-user', + payload: { workspaceId: 'ws-1', sharedWithUserId: 'user_2', permission: 'view' }, + }); + + expect(res.statusCode).toBe(201); + expect(createCollaboratorMock).toHaveBeenCalledOnce(); + }); + + it('rejects sharing with yourself', async () => { + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/notes/n1/share-with-user', + payload: { workspaceId: 'ws-1', sharedWithUserId: 'user_1', permission: 'view' }, + }); + + expect(res.statusCode).toBe(400); + }); + + it('rejects duplicate share', async () => { + getNoteMock.mockResolvedValueOnce({ id: 'n1', userId: 'user_1', productId: 'notelett' }); + findCollaboratorMock.mockResolvedValueOnce({ id: 'existing' }); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/notes/n1/share-with-user', + payload: { workspaceId: 'ws-1', sharedWithUserId: 'user_2', permission: 'view' }, + }); + + expect(res.statusCode).toBe(400); + }); + }); + + describe('GET /notes/:id/collaborators', () => { + it('lists collaborators', async () => { + listCollaboratorsForNoteMock.mockResolvedValueOnce([ + { id: 'c1', sharedWithUserId: 'user_2', permission: 'view' }, + ]); + const app = await buildApp(); + const res = await app.inject({ method: 'GET', url: '/api/notes/n1/collaborators' }); + + expect(res.statusCode).toBe(200); + expect(res.json().items).toHaveLength(1); + }); + }); + + describe('GET /shared-with-me', () => { + it('lists notes shared with current user', async () => { + listSharedWithMeMock.mockResolvedValueOnce([ + { id: 'c1', noteId: 'n1', sharedWithUserId: 'user_1' }, + ]); + const app = await buildApp(); + const res = await app.inject({ method: 'GET', url: '/api/shared-with-me' }); + + expect(res.statusCode).toBe(200); + expect(res.json().items).toHaveLength(1); + }); + }); + + describe('DELETE /notes/:noteId/collaborators/:userId', () => { + it('removes a collaborator', async () => { + findCollaboratorMock.mockResolvedValueOnce({ + id: 'c1', sharedByUserId: 'user_1', sharedWithUserId: 'user_2', + }); + const app = await buildApp(); + const res = await app.inject({ + method: 'DELETE', + url: '/api/notes/n1/collaborators/user_2', + }); + + expect(res.statusCode).toBe(204); + expect(deleteCollaboratorMock).toHaveBeenCalledOnce(); + }); + + it('returns 404 for missing collaborator', async () => { + findCollaboratorMock.mockResolvedValueOnce(null); + const app = await buildApp(); + const res = await app.inject({ + method: 'DELETE', + url: '/api/notes/n1/collaborators/user_2', + }); + + expect(res.statusCode).toBe(404); + }); + }); + + describe('POST /notes/:id/export-text', () => { + it('exports note as text formats', async () => { + getNoteMock.mockResolvedValueOnce({ + id: 'n1', userId: 'user_1', productId: 'notelett', + title: 'Test Note', body: '

Hello world

', + }); + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/notes/n1/export-text', + payload: { workspaceId: 'ws-1' }, + }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.title).toBe('Test Note'); + expect(body.plaintext).toBeDefined(); + expect(body.markdown).toBeDefined(); + expect(body.html).toBe('

Hello world

'); + }); + + it('returns 404 for missing note', async () => { + getNoteMock.mockResolvedValueOnce(null); + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/notes/n1/export-text', + payload: { workspaceId: 'ws-1' }, + }); + + expect(res.statusCode).toBe(404); + }); + }); + + describe('GET /notes/:id/deep-link', () => { + it('returns deep link URLs', async () => { + getNoteMock.mockResolvedValueOnce({ id: 'n1', productId: 'notelett' }); + const app = await buildApp(); + const res = await app.inject({ + method: 'GET', + url: '/api/notes/n1/deep-link?workspaceId=ws-1', + }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.web).toContain('/notes/n1'); + expect(body.mobile).toBe('notelett://note/n1'); + }); + + it('requires workspaceId query param', async () => { + const app = await buildApp(); + const res = await app.inject({ method: 'GET', url: '/api/notes/n1/deep-link' }); + + expect(res.statusCode).toBe(400); + }); + }); +}); diff --git a/backend/src/modules/note-collaborators/routes.ts b/backend/src/modules/note-collaborators/routes.ts new file mode 100644 index 0000000..4c40a9d --- /dev/null +++ b/backend/src/modules/note-collaborators/routes.ts @@ -0,0 +1,154 @@ +/** + * Note collaborators + enhanced sharing routes. + */ + +import type { FastifyInstance } from 'fastify'; +import { randomUUID } from 'node:crypto'; +import { getUserId, getRequestProductId } from '../../lib/request-context.js'; +import { BadRequestError, NotFoundError } from '@bytelyst/errors'; +import { isFeatureEnabled } from '../../lib/feature-flags.js'; +import { trackEvent } from '../../lib/telemetry.js'; +import { PRODUCT_ID } from '../../lib/product-config.js'; +import { stripHtmlForEmbedding } from '../../lib/embeddings.js'; +import * as noteRepo from '../notes/repository.js'; +import * as collabRepo from './repository.js'; +import { ShareWithUserSchema, ExportTextSchema } from './types.js'; +import type { NoteCollaboratorDoc } from './types.js'; +import { config } from '../../lib/config.js'; + +export async function noteCollaboratorRoutes(app: FastifyInstance): Promise { + + // ── Share note with another user ────────────────────────────── + app.post('/notes/:id/share-with-user', async (req, reply) => { + const userId = getUserId(req); + const productId = getRequestProductId(req); + const { id: noteId } = req.params as { id: string }; + + if (!isFeatureEnabled('notelett_collaborative_sharing_enabled')) { + throw new BadRequestError('Collaborative sharing is not enabled'); + } + + const parsed = ShareWithUserSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map((i: { message: string }) => i.message).join('; ')); + } + const input = parsed.data; + + if (input.sharedWithUserId === userId) { + throw new BadRequestError('Cannot share a note with yourself'); + } + + // Verify note exists and belongs to user + const note = await noteRepo.getNote(noteId, input.workspaceId); + if (!note || note.userId !== userId || note.productId !== PRODUCT_ID) { + throw new NotFoundError('Note not found'); + } + + // Check if already shared + const existing = await collabRepo.findCollaborator(noteId, input.sharedWithUserId, productId); + if (existing) { + throw new BadRequestError('Note is already shared with this user'); + } + + const now = new Date().toISOString(); + const doc: NoteCollaboratorDoc = { + id: `collab_${randomUUID().replace(/-/g, '').slice(0, 12)}`, + productId, + noteId, + workspaceId: input.workspaceId, + sharedByUserId: userId, + sharedWithUserId: input.sharedWithUserId, + permission: input.permission, + createdAt: now, + }; + + const created = await collabRepo.createCollaborator(doc); + trackEvent('note.shared_with_user', userId, { noteId, permission: input.permission }); + reply.code(201); + return created; + }); + + // ── List collaborators on a note ────────────────────────────── + app.get('/notes/:id/collaborators', async (req) => { + const userId = getUserId(req); + const productId = getRequestProductId(req); + const { id: noteId } = req.params as { id: string }; + + const collaborators = await collabRepo.listCollaboratorsForNote(noteId, productId); + return { items: collaborators, total: collaborators.length }; + }); + + // ── List notes shared with me ───────────────────────────────── + app.get('/shared-with-me', async (req) => { + const userId = getUserId(req); + const productId = getRequestProductId(req); + + const shared = await collabRepo.listSharedWithMe(userId, productId); + return { items: shared, total: shared.length }; + }); + + // ── Revoke collaborator access ──────────────────────────────── + app.delete('/notes/:noteId/collaborators/:userId', async (req, reply) => { + const currentUserId = getUserId(req); + const productId = getRequestProductId(req); + const { noteId, userId: targetUserId } = req.params as { noteId: string; userId: string }; + + const collab = await collabRepo.findCollaborator(noteId, targetUserId, productId); + if (!collab) throw new NotFoundError('Collaborator not found'); + + // Only the note owner (sharer) can revoke + if (collab.sharedByUserId !== currentUserId && collab.sharedWithUserId !== currentUserId) { + throw new BadRequestError('Only the note owner or the collaborator can revoke access'); + } + + await collabRepo.deleteCollaborator(collab.id, collab.sharedWithUserId); + trackEvent('note.collaborator_removed', currentUserId, { noteId, removedUserId: targetUserId }); + reply.code(204); + }); + + // ── Export note as clean text ───────────────────────────────── + app.post('/notes/:id/export-text', async (req) => { + const userId = getUserId(req); + const { id: noteId } = req.params as { id: string }; + + const parsed = ExportTextSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map((i: { message: string }) => i.message).join('; ')); + } + + const note = await noteRepo.getNote(noteId, parsed.data.workspaceId); + if (!note || note.userId !== userId || note.productId !== PRODUCT_ID) { + throw new NotFoundError('Note not found'); + } + + const plaintext = stripHtmlForEmbedding(note.body); + const markdown = plaintext; // Basic — HTML is already stripped + const html = note.body; + + trackEvent('note.exported_text', userId, { noteId }); + return { markdown, plaintext, html, title: note.title }; + }); + + // ── Deep link helper ────────────────────────────────────────── + app.get('/notes/:id/deep-link', async (req) => { + const userId = getUserId(req); + const productId = getRequestProductId(req); + const { id: noteId } = req.params as { id: string }; + const workspaceId = (req.query as Record).workspaceId; + + if (!workspaceId) throw new BadRequestError('workspaceId query param required'); + + const note = await noteRepo.getNote(noteId, workspaceId); + if (!note || note.productId !== PRODUCT_ID) { + throw new NotFoundError('Note not found'); + } + + const webOrigin = process.env.NEXT_PUBLIC_WEB_APP_ORIGIN || `http://localhost:3045`; + + return { + web: `${webOrigin}/notes/${noteId}`, + mobile: `notelett://note/${noteId}`, + public: null, + }; + }); +} diff --git a/backend/src/modules/note-collaborators/types.ts b/backend/src/modules/note-collaborators/types.ts new file mode 100644 index 0000000..ddef72d --- /dev/null +++ b/backend/src/modules/note-collaborators/types.ts @@ -0,0 +1,29 @@ +import { z } from 'zod'; + +export const COLLABORATOR_PERMISSIONS = ['view', 'comment', 'edit'] as const; +export type CollaboratorPermission = (typeof COLLABORATOR_PERMISSIONS)[number]; + +export interface NoteCollaboratorDoc { + id: string; + productId: string; + noteId: string; + workspaceId: string; + sharedByUserId: string; + sharedWithUserId: string; + permission: CollaboratorPermission; + createdAt: string; + _ts?: number; + _etag?: string; +} + +export const ShareWithUserSchema = z.object({ + workspaceId: z.string().min(1).max(128), + sharedWithUserId: z.string().min(1).max(128), + permission: z.enum(COLLABORATOR_PERMISSIONS).default('view'), +}); + +export type ShareWithUserInput = z.infer; + +export const ExportTextSchema = z.object({ + workspaceId: z.string().min(1).max(128), +}); diff --git a/backend/src/server.test.ts b/backend/src/server.test.ts index eff7e97..b37080d 100644 --- a/backend/src/server.test.ts +++ b/backend/src/server.test.ts @@ -39,6 +39,7 @@ vi.mock('./modules/note-prompts/scheduler.js', () => ({ stopSchedulerLoop: vi.fn(), })); vi.mock('./modules/intake/routes.js', () => ({ intakeRoutes: vi.fn() })); +vi.mock('./modules/note-collaborators/routes.js', () => ({ noteCollaboratorRoutes: vi.fn() })); vi.mock('./lib/cosmos-init.js', () => ({ initCosmosIfNeeded: initCosmosIfNeededMock })); vi.mock('./lib/datastore.js', () => ({ initDatastore: initDatastoreMock })); vi.mock('./lib/config.js', () => ({ @@ -78,7 +79,7 @@ describe('server bootstrap', () => { expect(initDatastoreMock).toHaveBeenCalledOnce(); expect(createServiceAppMock).toHaveBeenCalledOnce(); expect(registerOptionalJwtContextMock).toHaveBeenCalledOnce(); - expect(appMock.register).toHaveBeenCalledTimes(12); + expect(appMock.register).toHaveBeenCalledTimes(13); expect(startServiceMock).toHaveBeenCalledWith(appMock, { port: 4016, host: '0.0.0.0' }); }); }); diff --git a/backend/src/server.ts b/backend/src/server.ts index 1fe86d6..165219a 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -12,6 +12,7 @@ import { workspaceRoutes } from './modules/workspaces/routes.js'; import { notePromptRoutes } from './modules/note-prompts/routes.js'; import { promptSchedulerRoutes, startSchedulerLoop, stopSchedulerLoop } from './modules/note-prompts/scheduler.js'; import { intakeRoutes } from './modules/intake/routes.js'; +import { noteCollaboratorRoutes } from './modules/note-collaborators/routes.js'; import { initCosmosIfNeeded } from './lib/cosmos-init.js'; import { initEncryption } from './lib/field-encrypt.js'; import { initDatastore } from './lib/datastore.js'; @@ -67,6 +68,7 @@ await registerApiPlugin(workspaceRoutes); await registerApiPlugin(notePromptRoutes); await registerApiPlugin(promptSchedulerRoutes); await registerApiPlugin(intakeRoutes); +await registerApiPlugin(noteCollaboratorRoutes); // ── Start scheduler loop (F25) ──────────────────────────────────── startSchedulerLoop(); diff --git a/mobile/src/api/intake.ts b/mobile/src/api/intake.ts new file mode 100644 index 0000000..eb0d688 --- /dev/null +++ b/mobile/src/api/intake.ts @@ -0,0 +1,114 @@ +import { getApiClient } from './client'; + +// ── Content Types ──────────────────────────────────────────────── + +export const INTAKE_CONTENT_TYPES = [ + 'youtube', 'article', 'pdf', 'tweet', 'reddit', 'github', 'generic', +] as const; +export type IntakeContentType = (typeof INTAKE_CONTENT_TYPES)[number]; + +export const INTAKE_JOB_STATUSES = [ + 'queued', 'extracting', 'processing', 'complete', 'failed', +] as const; +export type IntakeJobStatus = (typeof INTAKE_JOB_STATUSES)[number]; + +// ── Types ──────────────────────────────────────────────────────── + +export type IntakeSubmitResult = { + jobId: string; + noteId: string; + contentType: IntakeContentType; + ruleMatched: string | null; + templateSlug: string; + status: 'queued'; +}; + +export type IntakeJob = { + id: string; + productId: string; + userId: string; + workspaceId: string; + noteId: string; + ruleId: string; + url: string; + contentType: IntakeContentType; + templateSlug: string; + status: IntakeJobStatus; + extractedText?: string; + error?: string; + startedAt: string; + completedAt?: string; +}; + +export type IntakeRule = { + id: string; + productId: string; + userId: string; + workspaceId: string; + name: string; + urlPattern: string; + contentType: IntakeContentType; + templateId: string; + enabled: boolean; + priority: number; + createdAt: string; + updatedAt: string; +}; + +type IntakeJobListResponse = { + items: IntakeJob[]; + total: number; +}; + +type IntakeRuleListResponse = { + items: IntakeRule[]; + total: number; +}; + +export type ListIntakeJobsOptions = { + status?: string; + since?: string; + limit?: number; + offset?: number; +}; + +// ── API Functions ──────────────────────────────────────────────── + +export async function submitIntake( + url: string, + workspaceId?: string, + templateOverride?: string, +): Promise { + return getApiClient().fetch('/intake', { + method: 'POST', + body: JSON.stringify({ + url, + ...(workspaceId ? { workspaceId } : {}), + ...(templateOverride ? { templateOverride } : {}), + }), + }); +} + +export async function listIntakeJobs( + options?: ListIntakeJobsOptions, +): Promise { + const params = new URLSearchParams(); + if (options?.status) params.set('status', options.status); + if (options?.since) params.set('since', options.since); + if (options?.limit) params.set('limit', String(options.limit)); + if (options?.offset) params.set('offset', String(options.offset)); + + const qs = params.toString(); + const path = qs ? `/intake/jobs?${qs}` : '/intake/jobs'; + const res = await getApiClient().fetch(path); + return res.items; +} + +export async function getIntakeJob(id: string): Promise { + return getApiClient().fetch(`/intake/jobs/${encodeURIComponent(id)}`); +} + +export async function listIntakeRules(): Promise { + const res = await getApiClient().fetch('/intake-rules'); + return res.items; +} diff --git a/mobile/src/app/(tabs)/_layout.tsx b/mobile/src/app/(tabs)/_layout.tsx index f9f218b..824b2d7 100644 --- a/mobile/src/app/(tabs)/_layout.tsx +++ b/mobile/src/app/(tabs)/_layout.tsx @@ -1,9 +1,19 @@ import { Tabs } from 'expo-router'; +import { useIntakeStore, type IntakeState } from '../../store/intake-store'; export default function TabLayout() { + const activeJobCount = useIntakeStore((state: IntakeState) => state.activeJobs.length); + return ( - + 0 ? activeJobCount : undefined, + }} + /> diff --git a/mobile/src/app/(tabs)/capture.tsx b/mobile/src/app/(tabs)/capture.tsx index 02b4f20..f5299a0 100644 --- a/mobile/src/app/(tabs)/capture.tsx +++ b/mobile/src/app/(tabs)/capture.tsx @@ -4,7 +4,8 @@ import * as Clipboard from 'expo-clipboard'; import type { MobileWorkspace } from '../../api/workspaces'; import { useNotesStore, type NotesState } from '../../store/notes-store'; import { useWorkspaceStore, type WorkspaceState } from '../../store/workspace-store'; -import { extractFromUrl } from '../../api/note-prompts'; +import { useIntakeStore, type IntakeState } from '../../store/intake-store'; +import { submitIntake, type IntakeSubmitResult } from '../../api/intake'; import { colors } from '../../theme'; type CaptureMode = 'text' | 'photo' | 'voice' | 'url' | 'scan' | 'paste'; diff --git a/mobile/src/app/(tabs)/capture/url.tsx b/mobile/src/app/(tabs)/capture/url.tsx index 15dcfe4..ec13e74 100644 --- a/mobile/src/app/(tabs)/capture/url.tsx +++ b/mobile/src/app/(tabs)/capture/url.tsx @@ -1,61 +1,110 @@ import { useState } from 'react'; -import { View, Text, TextInput, TouchableOpacity, StyleSheet, ActivityIndicator } from 'react-native'; +import { View, Text, TextInput, Pressable, StyleSheet, ActivityIndicator } from 'react-native'; import { useRouter } from 'expo-router'; -import { extractFromUrl } from '../../../api/note-prompts'; +import { submitIntake, type IntakeSubmitResult } from '../../../api/intake'; +import { useWorkspaceStore, type WorkspaceState } from '../../../store/workspace-store'; +import { useIntakeStore, type IntakeState } from '../../../store/intake-store'; import { colors } from '../../../theme'; +function classifyUrlLocal(url: string): string { + if (/youtube\.com|youtu\.be/i.test(url)) return 'YouTube'; + if (/twitter\.com|x\.com/i.test(url)) return 'Tweet'; + if (/reddit\.com/i.test(url)) return 'Reddit'; + if (/github\.com/i.test(url)) return 'GitHub'; + if (/\.pdf(\?|$)/i.test(url)) return 'PDF'; + if (/^https?:\/\//i.test(url)) return 'Article'; + return 'Unknown'; +} + export default function UrlCaptureScreen() { const router = useRouter(); const [url, setUrl] = useState(''); const [busy, setBusy] = useState(false); - const [result, setResult] = useState<{ title: string; content: string } | null>(null); + const [result, setResult] = useState(null); const [error, setError] = useState(null); + const activeWorkspaceId = useWorkspaceStore((state: WorkspaceState) => state.activeWorkspaceId); + const waitForJob = useIntakeStore((state: IntakeState) => state.waitForJob); - async function handleExtract() { + const detectedType = url.trim() ? classifyUrlLocal(url.trim()) : null; + + async function handleProcess() { if (!url.trim()) return; setBusy(true); setError(null); try { - const res = await extractFromUrl(url.trim(), 'default'); - setResult({ title: res.title, content: res.content }); + const res = await submitIntake(url.trim(), activeWorkspaceId ?? undefined); + setResult(res); + waitForJob(res.jobId, (job) => { + if (job.status === 'complete') { + router.push(`/note/${job.noteId}`); + } + }); } catch (err) { - setError(err instanceof Error ? err.message : 'Extraction failed'); + setError(err instanceof Error ? err.message : 'Intake failed'); } finally { setBusy(false); } } + function handleAdvanced() { + router.push({ pathname: '/intake', params: { url: url.trim() } }); + } + return ( URL Capture - Paste a URL to extract and summarize its content. + Paste a URL to extract and process with the intake pipeline. { setUrl(text); setResult(null); setError(null); }} autoCapitalize="none" keyboardType="url" accessibilityLabel="URL input" /> - void handleExtract()} - accessibilityLabel="Extract content" - > - {busy ? : Extract} - + {detectedType && ( + + Detected: + + {detectedType} + + + )} {error && {error}} - {result && ( + {result ? ( - {result.title} - {result.content} + + Processing as {result.contentType}… + Job: {result.jobId} + + ) : ( + + void handleProcess()} + accessibilityLabel="Process URL with AI" + > + {busy ? ( + + ) : ( + Process with AI + )} + + + Options… + )} @@ -67,10 +116,18 @@ const styles = StyleSheet.create({ heading: { fontSize: 18, fontWeight: '700', color: colors.textPrimary }, hint: { fontSize: 13, color: colors.textSecondary }, input: { backgroundColor: colors.surfaceCard, borderRadius: 8, padding: 12, borderWidth: 1, borderColor: colors.borderDefault, color: colors.textPrimary, fontSize: 15 }, - btn: { backgroundColor: colors.accentPrimary, borderRadius: 8, paddingVertical: 10, alignItems: 'center' }, - btnText: { color: '#fff', fontWeight: '600', fontSize: 15 }, + typeRow: { flexDirection: 'row', alignItems: 'center', gap: 8 }, + typeLabel: { fontSize: 13, color: colors.textSecondary }, + typeBadge: { backgroundColor: colors.accentPrimary + '22', borderRadius: 999, paddingHorizontal: 10, paddingVertical: 3, borderWidth: 1, borderColor: colors.accentPrimary }, + typeBadgeText: { fontSize: 12, fontWeight: '700', color: colors.accentPrimary }, + buttonRow: { gap: 10 }, + btn: { backgroundColor: colors.accentPrimary, borderRadius: 8, paddingVertical: 12, alignItems: 'center' }, + btnDisabled: { opacity: 0.5 }, + btnText: { color: colors.textPrimary, fontWeight: '700', fontSize: 15 }, + secondaryBtn: { borderRadius: 8, paddingVertical: 10, alignItems: 'center', borderWidth: 1, borderColor: colors.borderDefault }, + secondaryBtnText: { color: colors.textSecondary, fontWeight: '600', fontSize: 14 }, error: { color: colors.danger, fontSize: 13 }, - resultCard: { backgroundColor: colors.surfaceCard, borderRadius: 8, padding: 12, borderWidth: 1, borderColor: colors.borderDefault, gap: 8 }, - resultTitle: { fontSize: 16, fontWeight: '600', color: colors.textPrimary }, - resultContent: { fontSize: 14, color: colors.textSecondary, lineHeight: 20 }, + resultCard: { backgroundColor: colors.surfaceCard, borderRadius: 8, padding: 12, borderWidth: 1, borderColor: colors.accentPrimary, gap: 8, alignItems: 'center' }, + resultText: { fontSize: 14, color: colors.textPrimary }, + resultMeta: { fontSize: 11, color: colors.textTertiary }, }); diff --git a/mobile/src/app/_layout.tsx b/mobile/src/app/_layout.tsx index 4f614d8..075fcfa 100644 --- a/mobile/src/app/_layout.tsx +++ b/mobile/src/app/_layout.tsx @@ -4,6 +4,7 @@ import { StatusBar } from 'expo-status-bar'; import { AppState, Modal, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native'; import { useAuthStore, type AuthState } from '../store/auth-store'; import { useInboxStore } from '../store/inbox-store'; +import { useIntakeStore } from '../store/intake-store'; import { useNotesStore } from '../store/notes-store'; import { useWorkspaceStore } from '../store/workspace-store'; import { checkKillSwitch, flushTelemetry, initPlatform } from '../lib/platform'; @@ -136,6 +137,7 @@ export default function RootLayout() { void flushQueuedNoteMutations(); void loadBroadcasts(); void loadSurvey(); + void useIntakeStore.getState().pollActiveJobs(); })(); const broadcastTimer = setInterval(() => { @@ -146,6 +148,10 @@ export default function RootLayout() { void loadSurvey(); }, 10 * 60_000); + const intakeTimer = setInterval(() => { + void useIntakeStore.getState().pollActiveJobs(); + }, 30_000); + const appStateSubscription = AppState.addEventListener('change', (nextState) => { if (nextState === 'active') { void flushQueuedNoteMutations(); @@ -156,6 +162,7 @@ export default function RootLayout() { cancelled = true; clearInterval(broadcastTimer); clearInterval(surveyTimer); + clearInterval(intakeTimer); appStateSubscription.remove(); }; }, [hasBootstrapped, isAuthenticated]); diff --git a/mobile/src/app/intake.tsx b/mobile/src/app/intake.tsx new file mode 100644 index 0000000..213f1cb --- /dev/null +++ b/mobile/src/app/intake.tsx @@ -0,0 +1,345 @@ +import { useEffect, useState } from 'react'; +import { ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { submitIntake, listIntakeRules, type IntakeRule, type IntakeSubmitResult } from '../api/intake'; +import { listPromptTemplates, type MobilePromptTemplate } from '../api/note-prompts'; +import { useWorkspaceStore, type WorkspaceState } from '../store/workspace-store'; +import { useIntakeStore, type IntakeState } from '../store/intake-store'; +import type { MobileWorkspace } from '../api/workspaces'; +import { colors } from '../theme'; + +type ContentTypeBadge = { label: string; color: string }; + +const CONTENT_TYPE_MAP: Record = { + youtube: { label: 'YouTube', color: '#FF0000' }, + article: { label: 'Article', color: colors.accentPrimary }, + pdf: { label: 'PDF', color: '#E44D26' }, + tweet: { label: 'Tweet', color: '#1DA1F2' }, + reddit: { label: 'Reddit', color: '#FF4500' }, + github: { label: 'GitHub', color: '#6e5494' }, + generic: { label: 'Web Page', color: colors.textSecondary }, +}; + +function getContentTypeBadge(type: string): ContentTypeBadge { + return CONTENT_TYPE_MAP[type] ?? { label: type, color: colors.textSecondary }; +} + +export default function IntakeScreen() { + const router = useRouter(); + const params = useLocalSearchParams<{ url?: string }>(); + const incomingUrl = params.url ?? ''; + + const workspaces = useWorkspaceStore((state: WorkspaceState) => state.workspaces); + const activeWorkspaceId = useWorkspaceStore((state: WorkspaceState) => state.activeWorkspaceId); + const waitForJob = useIntakeStore((state: IntakeState) => state.waitForJob); + + const [rules, setRules] = useState([]); + const [templates, setTemplates] = useState([]); + const [selectedTemplate, setSelectedTemplate] = useState(null); + const [matchedRule, setMatchedRule] = useState(null); + const [detectedType, setDetectedType] = useState('generic'); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [result, setResult] = useState(null); + + useEffect(() => { + void loadRulesAndTemplates(); + }, []); + + useEffect(() => { + if (incomingUrl && rules.length > 0) { + matchUrl(incomingUrl); + } + }, [incomingUrl, rules]); + + async function loadRulesAndTemplates(): Promise { + try { + const [fetchedRules, fetchedTemplates] = await Promise.all([ + listIntakeRules(), + listPromptTemplates(), + ]); + setRules(fetchedRules); + setTemplates(fetchedTemplates); + } catch { + // Non-fatal — user can still submit + } + } + + function matchUrl(url: string): void { + for (const rule of rules) { + if (!rule.enabled) continue; + try { + const regex = new RegExp(rule.urlPattern, 'i'); + if (regex.test(url)) { + setMatchedRule(rule); + setDetectedType(rule.contentType); + setSelectedTemplate(rule.templateId); + return; + } + } catch { + // Invalid regex — skip + } + } + + // Fallback heuristic classification + if (/youtube\.com|youtu\.be/i.test(url)) setDetectedType('youtube'); + else if (/twitter\.com|x\.com/i.test(url)) setDetectedType('tweet'); + else if (/reddit\.com/i.test(url)) setDetectedType('reddit'); + else if (/github\.com/i.test(url)) setDetectedType('github'); + else if (/\.pdf(\?|$)/i.test(url)) setDetectedType('pdf'); + else setDetectedType('article'); + } + + async function handleProcess(): Promise { + if (!incomingUrl.trim()) return; + setBusy(true); + setError(null); + try { + const submitResult = await submitIntake( + incomingUrl.trim(), + activeWorkspaceId ?? undefined, + selectedTemplate ?? undefined, + ); + setResult(submitResult); + + waitForJob(submitResult.jobId, (job) => { + if (job.status === 'complete') { + router.push(`/note/${job.noteId}`); + } + }); + } catch (err) { + setError(err instanceof Error ? err.message : 'Intake submission failed'); + } finally { + setBusy(false); + } + } + + const badge = getContentTypeBadge(detectedType); + const activeWorkspaceName = + workspaces.find((w: MobileWorkspace) => w.id === activeWorkspaceId)?.name ?? 'Default'; + + return ( + + Process URL + Intake pipeline will extract, classify, and create a note from this URL. + + {/* URL display */} + + URL + {incomingUrl || 'No URL provided'} + + + {/* Content type badge */} + + Detected type + + {badge.label} + + + + {/* Matched rule */} + {matchedRule && ( + + Matched rule + {matchedRule.name} + + )} + + {/* Workspace */} + + Workspace + {activeWorkspaceName} + + + {/* Template override */} + {templates.length > 0 && ( + + Template + + setSelectedTemplate(null)} + style={[styles.chip, selectedTemplate === null ? styles.chipActive : null]} + > + + Auto + + + {templates.slice(0, 8).map((t) => ( + setSelectedTemplate(t.slug)} + style={[styles.chip, selectedTemplate === t.slug ? styles.chipActive : null]} + > + + {t.name} + + + ))} + + + )} + + {/* Error */} + {error && {error}} + + {/* Result: processing */} + {result && ( + + + + Job queued — processing as {result.contentType}. You will be redirected when complete. + + Job ID: {result.jobId} + + )} + + {/* Process button */} + {!result && ( + void handleProcess()} + disabled={busy || !incomingUrl.trim()} + style={[styles.button, (busy || !incomingUrl.trim()) ? styles.buttonDisabled : null]} + > + {busy ? ( + + ) : ( + Process + )} + + )} + + ); +} + +const styles = StyleSheet.create({ + container: { + padding: 20, + gap: 16, + backgroundColor: colors.bgCanvas, + minHeight: '100%', + }, + heading: { + fontSize: 24, + fontWeight: '700', + color: colors.textPrimary, + }, + hint: { + fontSize: 14, + color: colors.textSecondary, + lineHeight: 20, + }, + urlCard: { + backgroundColor: colors.surfaceCard, + borderRadius: 12, + borderWidth: 1, + borderColor: colors.borderDefault, + padding: 14, + gap: 6, + }, + urlLabel: { + fontSize: 12, + fontWeight: '600', + color: colors.textTertiary, + textTransform: 'uppercase', + letterSpacing: 0.5, + }, + urlText: { + fontSize: 14, + color: colors.accentPrimary, + lineHeight: 20, + }, + row: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 4, + }, + label: { + fontSize: 14, + fontWeight: '600', + color: colors.textSecondary, + }, + value: { + fontSize: 14, + color: colors.textPrimary, + fontWeight: '500', + }, + badge: { + borderRadius: 999, + borderWidth: 1, + paddingHorizontal: 10, + paddingVertical: 4, + }, + badgeText: { + fontSize: 12, + fontWeight: '700', + }, + section: { + gap: 8, + }, + chipRow: { + gap: 8, + paddingVertical: 4, + }, + chip: { + borderRadius: 999, + borderWidth: 1, + borderColor: colors.borderDefault, + paddingHorizontal: 12, + paddingVertical: 8, + backgroundColor: colors.surfaceCard, + }, + chipActive: { + backgroundColor: colors.accentPrimary, + borderColor: colors.accentPrimary, + }, + chipText: { + color: colors.textSecondary, + fontSize: 13, + fontWeight: '600', + }, + chipTextActive: { + color: colors.textPrimary, + }, + error: { + color: colors.danger, + fontSize: 14, + fontWeight: '500', + }, + resultCard: { + backgroundColor: colors.surfaceCard, + borderRadius: 12, + borderWidth: 1, + borderColor: colors.accentPrimary, + padding: 14, + gap: 8, + alignItems: 'center', + }, + resultText: { + fontSize: 14, + color: colors.textPrimary, + textAlign: 'center', + lineHeight: 20, + }, + resultMeta: { + fontSize: 11, + color: colors.textTertiary, + }, + button: { + backgroundColor: colors.accentPrimary, + borderRadius: 12, + paddingVertical: 14, + alignItems: 'center', + }, + buttonText: { + color: colors.textPrimary, + fontWeight: '700', + fontSize: 16, + }, + buttonDisabled: { + opacity: 0.5, + }, +}); diff --git a/mobile/src/app/note/[id].tsx b/mobile/src/app/note/[id].tsx index 83a7a1e..b693bd4 100644 --- a/mobile/src/app/note/[id].tsx +++ b/mobile/src/app/note/[id].tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; import { useLocalSearchParams } from 'expo-router'; -import { ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native'; +import { ActivityIndicator, Pressable, ScrollView, Share, StyleSheet, Text, TextInput, View } from 'react-native'; import { useNotesStore, type NotesState } from '../../store/notes-store'; import { usePromptStore, type PromptState } from '../../store/prompt-store'; import { getReadingTime, suggestTags } from '../../api/note-prompts'; @@ -109,6 +109,39 @@ export default function NoteDetailScreen() { Last updated: {formattedUpdatedAt} + {/* Share */} + {selectedNote && !isLoading && ( + + Share + + { + void Share.share({ + message: `${selectedNote.title}\n\n${selectedNote.body}`, + title: selectedNote.title, + }); + }} + > + Share as text + + { + void Share.share({ + message: `notelett://note/${noteId}`, + title: `NoteLett: ${selectedNote.title}`, + }); + }} + > + Share link + + + + )} + {/* Smart Actions */} Promise; + waitForJob: (jobId: string, onComplete: (job: IntakeJob) => void) => void; + clearCompleted: (jobId: string) => void; +}; + +const POLL_STATUSES = 'queued,extracting,processing'; + +export const useIntakeStore = create((set, get) => ({ + activeJobs: [], + completedJobIds: [], + isPolling: false, + + async pollActiveJobs() { + if (get().isPolling) return; + set({ isPolling: true }); + try { + const jobs = await listIntakeJobs({ status: POLL_STATUSES, limit: 50 }); + set({ activeJobs: jobs, isPolling: false }); + } catch { + set({ isPolling: false }); + } + }, + + waitForJob(jobId: string, onComplete: (job: IntakeJob) => void) { + let attempts = 0; + const maxAttempts = 60; + const intervalMs = 3000; + + const timer = setInterval(async () => { + attempts += 1; + if (attempts > maxAttempts) { + clearInterval(timer); + return; + } + + try { + const job = await getIntakeJob(jobId); + if (job.status === 'complete' || job.status === 'failed') { + clearInterval(timer); + set((state) => ({ + activeJobs: state.activeJobs.filter((j) => j.id !== jobId), + completedJobIds: [...state.completedJobIds, jobId], + })); + onComplete(job); + } + } catch { + // Retry on next tick + } + }, intervalMs); + }, + + clearCompleted(jobId: string) { + set((state) => ({ + completedJobIds: state.completedJobIds.filter((id) => id !== jobId), + })); + }, +}));