feat(sharing): add collaborative shares, export-text, deep-link helper — note-collaborators module (11 new tests)
This commit is contained in:
parent
0e16714da1
commit
599d68e116
@ -17,6 +17,7 @@ const CONTAINER_DEFS: Record<string, ContainerConfig> = {
|
||||
note_versions: { partitionKeyPath: '/workspaceId' },
|
||||
note_intake_rules: { partitionKeyPath: '/userId' },
|
||||
note_intake_jobs: { partitionKeyPath: '/userId' },
|
||||
note_collaborators: { partitionKeyPath: '/sharedWithUserId' },
|
||||
};
|
||||
|
||||
export async function initCosmosIfNeeded(): Promise<void> {
|
||||
|
||||
41
backend/src/modules/note-collaborators/repository.ts
Normal file
41
backend/src/modules/note-collaborators/repository.ts
Normal file
@ -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<NoteCollaboratorDoc>('note_collaborators', '/sharedWithUserId');
|
||||
}
|
||||
|
||||
export async function createCollaborator(doc: NoteCollaboratorDoc): Promise<NoteCollaboratorDoc> {
|
||||
return collection().create(doc);
|
||||
}
|
||||
|
||||
export async function listCollaboratorsForNote(
|
||||
noteId: string,
|
||||
productId: string,
|
||||
): Promise<NoteCollaboratorDoc[]> {
|
||||
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<NoteCollaboratorDoc[]> {
|
||||
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<NoteCollaboratorDoc | null> {
|
||||
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<void> {
|
||||
await collection().delete(id, sharedWithUserId);
|
||||
}
|
||||
198
backend/src/modules/note-collaborators/routes.test.ts
Normal file
198
backend/src/modules/note-collaborators/routes.test.ts
Normal file
@ -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<string, unknown>) => 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<string, unknown>]),
|
||||
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: '<p>Hello world</p>',
|
||||
});
|
||||
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('<p>Hello world</p>');
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
154
backend/src/modules/note-collaborators/routes.ts
Normal file
154
backend/src/modules/note-collaborators/routes.ts
Normal file
@ -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<void> {
|
||||
|
||||
// ── 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<string, string>).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,
|
||||
};
|
||||
});
|
||||
}
|
||||
29
backend/src/modules/note-collaborators/types.ts
Normal file
29
backend/src/modules/note-collaborators/types.ts
Normal file
@ -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<typeof ShareWithUserSchema>;
|
||||
|
||||
export const ExportTextSchema = z.object({
|
||||
workspaceId: z.string().min(1).max(128),
|
||||
});
|
||||
@ -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' });
|
||||
});
|
||||
});
|
||||
|
||||
@ -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();
|
||||
|
||||
114
mobile/src/api/intake.ts
Normal file
114
mobile/src/api/intake.ts
Normal file
@ -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<IntakeSubmitResult> {
|
||||
return getApiClient().fetch<IntakeSubmitResult>('/intake', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
url,
|
||||
...(workspaceId ? { workspaceId } : {}),
|
||||
...(templateOverride ? { templateOverride } : {}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function listIntakeJobs(
|
||||
options?: ListIntakeJobsOptions,
|
||||
): Promise<IntakeJob[]> {
|
||||
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<IntakeJobListResponse>(path);
|
||||
return res.items;
|
||||
}
|
||||
|
||||
export async function getIntakeJob(id: string): Promise<IntakeJob> {
|
||||
return getApiClient().fetch<IntakeJob>(`/intake/jobs/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
export async function listIntakeRules(): Promise<IntakeRule[]> {
|
||||
const res = await getApiClient().fetch<IntakeRuleListResponse>('/intake-rules');
|
||||
return res.items;
|
||||
}
|
||||
@ -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 (
|
||||
<Tabs screenOptions={{ headerShown: false }}>
|
||||
<Tabs.Screen name="index" options={{ title: 'Home', tabBarAccessibilityLabel: 'Home tab' }} />
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: 'Home',
|
||||
tabBarAccessibilityLabel: 'Home tab',
|
||||
tabBarBadge: activeJobCount > 0 ? activeJobCount : undefined,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen name="search" options={{ title: 'Search', tabBarAccessibilityLabel: 'Search tab' }} />
|
||||
<Tabs.Screen name="capture" options={{ title: 'Capture', tabBarAccessibilityLabel: 'Capture tab' }} />
|
||||
<Tabs.Screen name="inbox" options={{ title: 'Inbox', tabBarAccessibilityLabel: 'Inbox tab' }} />
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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<IntakeSubmitResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.heading}>URL Capture</Text>
|
||||
<Text style={styles.hint}>Paste a URL to extract and summarize its content.</Text>
|
||||
<Text style={styles.hint}>Paste a URL to extract and process with the intake pipeline.</Text>
|
||||
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="https://example.com/article"
|
||||
placeholderTextColor={colors.textTertiary}
|
||||
value={url}
|
||||
onChangeText={setUrl}
|
||||
onChangeText={(text) => { setUrl(text); setResult(null); setError(null); }}
|
||||
autoCapitalize="none"
|
||||
keyboardType="url"
|
||||
accessibilityLabel="URL input"
|
||||
/>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.btn, busy && { opacity: 0.6 }]}
|
||||
disabled={busy || !url.trim()}
|
||||
onPress={() => void handleExtract()}
|
||||
accessibilityLabel="Extract content"
|
||||
>
|
||||
{busy ? <ActivityIndicator color="#fff" size="small" /> : <Text style={styles.btnText}>Extract</Text>}
|
||||
</TouchableOpacity>
|
||||
{detectedType && (
|
||||
<View style={styles.typeRow}>
|
||||
<Text style={styles.typeLabel}>Detected:</Text>
|
||||
<View style={styles.typeBadge}>
|
||||
<Text style={styles.typeBadgeText}>{detectedType}</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{error && <Text style={styles.error}>{error}</Text>}
|
||||
|
||||
{result && (
|
||||
{result ? (
|
||||
<View style={styles.resultCard}>
|
||||
<Text style={styles.resultTitle}>{result.title}</Text>
|
||||
<Text style={styles.resultContent} numberOfLines={20}>{result.content}</Text>
|
||||
<ActivityIndicator color={colors.accentPrimary} size="small" />
|
||||
<Text style={styles.resultText}>Processing as {result.contentType}…</Text>
|
||||
<Text style={styles.resultMeta}>Job: {result.jobId}</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.buttonRow}>
|
||||
<Pressable
|
||||
style={[styles.btn, (busy || !url.trim()) && styles.btnDisabled]}
|
||||
disabled={busy || !url.trim()}
|
||||
onPress={() => void handleProcess()}
|
||||
accessibilityLabel="Process URL with AI"
|
||||
>
|
||||
{busy ? (
|
||||
<ActivityIndicator color={colors.textPrimary} size="small" />
|
||||
) : (
|
||||
<Text style={styles.btnText}>Process with AI</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={styles.secondaryBtn}
|
||||
disabled={!url.trim()}
|
||||
onPress={handleAdvanced}
|
||||
accessibilityLabel="Advanced intake options"
|
||||
>
|
||||
<Text style={styles.secondaryBtnText}>Options…</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
@ -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 },
|
||||
});
|
||||
|
||||
@ -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]);
|
||||
|
||||
345
mobile/src/app/intake.tsx
Normal file
345
mobile/src/app/intake.tsx
Normal file
@ -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<string, ContentTypeBadge> = {
|
||||
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<IntakeRule[]>([]);
|
||||
const [templates, setTemplates] = useState<MobilePromptTemplate[]>([]);
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null);
|
||||
const [matchedRule, setMatchedRule] = useState<IntakeRule | null>(null);
|
||||
const [detectedType, setDetectedType] = useState<string>('generic');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<IntakeSubmitResult | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void loadRulesAndTemplates();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (incomingUrl && rules.length > 0) {
|
||||
matchUrl(incomingUrl);
|
||||
}
|
||||
}, [incomingUrl, rules]);
|
||||
|
||||
async function loadRulesAndTemplates(): Promise<void> {
|
||||
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<void> {
|
||||
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 (
|
||||
<ScrollView contentContainerStyle={styles.container}>
|
||||
<Text style={styles.heading}>Process URL</Text>
|
||||
<Text style={styles.hint}>Intake pipeline will extract, classify, and create a note from this URL.</Text>
|
||||
|
||||
{/* URL display */}
|
||||
<View style={styles.urlCard}>
|
||||
<Text style={styles.urlLabel}>URL</Text>
|
||||
<Text style={styles.urlText} numberOfLines={3}>{incomingUrl || 'No URL provided'}</Text>
|
||||
</View>
|
||||
|
||||
{/* Content type badge */}
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.label}>Detected type</Text>
|
||||
<View style={[styles.badge, { backgroundColor: badge.color + '22', borderColor: badge.color }]}>
|
||||
<Text style={[styles.badgeText, { color: badge.color }]}>{badge.label}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Matched rule */}
|
||||
{matchedRule && (
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.label}>Matched rule</Text>
|
||||
<Text style={styles.value}>{matchedRule.name}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Workspace */}
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.label}>Workspace</Text>
|
||||
<Text style={styles.value}>{activeWorkspaceName}</Text>
|
||||
</View>
|
||||
|
||||
{/* Template override */}
|
||||
{templates.length > 0 && (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.label}>Template</Text>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.chipRow}>
|
||||
<Pressable
|
||||
accessibilityLabel="Auto-select template"
|
||||
onPress={() => setSelectedTemplate(null)}
|
||||
style={[styles.chip, selectedTemplate === null ? styles.chipActive : null]}
|
||||
>
|
||||
<Text style={[styles.chipText, selectedTemplate === null ? styles.chipTextActive : null]}>
|
||||
Auto
|
||||
</Text>
|
||||
</Pressable>
|
||||
{templates.slice(0, 8).map((t) => (
|
||||
<Pressable
|
||||
key={t.id}
|
||||
accessibilityLabel={`Select template ${t.name}`}
|
||||
onPress={() => setSelectedTemplate(t.slug)}
|
||||
style={[styles.chip, selectedTemplate === t.slug ? styles.chipActive : null]}
|
||||
>
|
||||
<Text style={[styles.chipText, selectedTemplate === t.slug ? styles.chipTextActive : null]}>
|
||||
{t.name}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && <Text style={styles.error}>{error}</Text>}
|
||||
|
||||
{/* Result: processing */}
|
||||
{result && (
|
||||
<View style={styles.resultCard}>
|
||||
<ActivityIndicator color={colors.accentPrimary} size="small" />
|
||||
<Text style={styles.resultText}>
|
||||
Job queued — processing as {result.contentType}. You will be redirected when complete.
|
||||
</Text>
|
||||
<Text style={styles.resultMeta}>Job ID: {result.jobId}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Process button */}
|
||||
{!result && (
|
||||
<Pressable
|
||||
accessibilityLabel="Process URL with intake pipeline"
|
||||
onPress={() => void handleProcess()}
|
||||
disabled={busy || !incomingUrl.trim()}
|
||||
style={[styles.button, (busy || !incomingUrl.trim()) ? styles.buttonDisabled : null]}
|
||||
>
|
||||
{busy ? (
|
||||
<ActivityIndicator color={colors.textPrimary} size="small" />
|
||||
) : (
|
||||
<Text style={styles.buttonText}>Process</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
)}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
@ -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() {
|
||||
<Text style={styles.body}>Last updated: {formattedUpdatedAt}</Text>
|
||||
</View>
|
||||
|
||||
{/* Share */}
|
||||
{selectedNote && !isLoading && (
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.sectionTitle}>Share</Text>
|
||||
<View style={styles.actionRow}>
|
||||
<Pressable
|
||||
accessibilityLabel="Share note as text"
|
||||
style={styles.secondaryButton}
|
||||
onPress={() => {
|
||||
void Share.share({
|
||||
message: `${selectedNote.title}\n\n${selectedNote.body}`,
|
||||
title: selectedNote.title,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Text style={styles.secondaryButtonText}>Share as text</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
accessibilityLabel="Share deep link to note"
|
||||
style={styles.secondaryButton}
|
||||
onPress={() => {
|
||||
void Share.share({
|
||||
message: `notelett://note/${noteId}`,
|
||||
title: `NoteLett: ${selectedNote.title}`,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Text style={styles.secondaryButtonText}>Share link</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Smart Actions */}
|
||||
<View style={styles.card}>
|
||||
<Pressable
|
||||
|
||||
64
mobile/src/store/intake-store.ts
Normal file
64
mobile/src/store/intake-store.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import { create } from 'zustand';
|
||||
import { listIntakeJobs, getIntakeJob, type IntakeJob } from '../api/intake';
|
||||
|
||||
export type IntakeState = {
|
||||
activeJobs: IntakeJob[];
|
||||
completedJobIds: string[];
|
||||
isPolling: boolean;
|
||||
pollActiveJobs: () => Promise<void>;
|
||||
waitForJob: (jobId: string, onComplete: (job: IntakeJob) => void) => void;
|
||||
clearCompleted: (jobId: string) => void;
|
||||
};
|
||||
|
||||
const POLL_STATUSES = 'queued,extracting,processing';
|
||||
|
||||
export const useIntakeStore = create<IntakeState>((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),
|
||||
}));
|
||||
},
|
||||
}));
|
||||
Loading…
Reference in New Issue
Block a user