diff --git a/core/enex/import-shared.ts b/core/enex/import-shared.ts new file mode 100644 index 00000000000..ad80690ea43 --- /dev/null +++ b/core/enex/import-shared.ts @@ -0,0 +1,75 @@ +import type { SupabaseClient } from '@supabase/supabase-js' + +import type { Database } from '@/supabase/types' +import type { DuplicateStrategy, ImportSettings } from './types' + +export const DEFAULT_IMPORT_SETTINGS: ImportSettings = { + duplicateStrategy: 'prefix', + skipFileDuplicates: false, +} + +export const IMPORT_SETTINGS_COPY = { + title: 'Import Settings', + duplicateQuestion: 'What to do with duplicate notes?', + skipFileDuplicatesLabel: 'Skip duplicates inside imported file(s)', +} as const + +export const DUPLICATE_STRATEGY_OPTIONS: ReadonlyArray<{ + value: DuplicateStrategy + label: string +}> = [ + { value: 'prefix', label: 'Add [duplicate] prefix to title' }, + { value: 'skip', label: 'Skip duplicate notes' }, + { value: 'replace', label: 'Replace existing notes' }, +] + +export const DUPLICATE_LOOKUP_UNAVAILABLE_MESSAGE = + 'Could not verify existing notes. Try again, or switch duplicate handling to "Add [duplicate] prefix to title".' + +export function normalizeNoteTitle(title?: string | null): string { + const trimmed = typeof title === 'string' ? title.trim() : '' + return trimmed.length > 0 ? trimmed : 'Untitled' +} + +export async function fetchExistingTitles( + client: SupabaseClient, + userId: string +): Promise> { + const { data, error } = await client + .from('notes') + .select('id, title, created_at') + .eq('user_id', userId) + .order('created_at', { ascending: false }) + + if (error) { + throw new Error(`Failed to fetch existing titles: ${error.message}`) + } + + const map = new Map() + data?.forEach((row) => { + const title = normalizeNoteTitle(row?.title) + + if (!map.has(title)) { + map.set(title, row.id) + } + }) + + return map +} + +export async function resolveExistingTitlesForImport( + client: SupabaseClient, + userId: string, + duplicateStrategy: DuplicateStrategy +): Promise | null> { + try { + return await fetchExistingTitles(client, userId) + } catch (error) { + if (duplicateStrategy === 'prefix') { + console.error('Failed to fetch existing titles for prefix import:', error) + return null + } + + throw new Error(DUPLICATE_LOOKUP_UNAVAILABLE_MESSAGE) + } +} diff --git a/core/enex/note-creator.ts b/core/enex/note-creator.ts index 683971dee55..aabc89d0c4e 100644 --- a/core/enex/note-creator.ts +++ b/core/enex/note-creator.ts @@ -1,8 +1,16 @@ import type { SupabaseClient } from '@supabase/supabase-js' import type { Database, TablesInsert } from '@/supabase/types' +import { normalizeNoteTitle } from './import-shared' import type { DuplicateStrategy, ParsedNote } from './types' +type DuplicateContext = { + skipFileDuplicates: boolean + existingByTitle: Map | null + fallbackExistingByTitle: Map + seenTitlesInImport: Set +} + type DuplicateCheckResult = | { title: string; skip: true; replace: false; existingId?: string } | { title: string; skip: false; replace: true; existingId: string } @@ -19,13 +27,10 @@ export class NoteCreator { note: ParsedNote, userId: string, duplicateStrategy: DuplicateStrategy = 'prefix', - duplicateContext?: { - skipFileDuplicates: boolean - existingByTitle: Map - seenTitlesInImport: Set - } + duplicateContext?: DuplicateContext ): Promise { try { + const normalizedTitle = normalizeNoteTitle(note.title) const result = await this.handleDuplicate(note.title, userId, duplicateStrategy, duplicateContext) if (result.skip) { @@ -51,12 +56,13 @@ export class NoteCreator { .single() if (error) throw error + if (!data?.id) throw new Error('Updated note id was not returned') if (duplicateContext?.skipFileDuplicates) { - duplicateContext.seenTitlesInImport.add(note.title) + duplicateContext.seenTitlesInImport.add(normalizedTitle) } - return data?.id ?? null + return data.id } const { data, error } = await this.supabase @@ -66,10 +72,11 @@ export class NoteCreator { .single() if (error) throw error - const createdId = data?.id ?? null + if (!data?.id) throw new Error('Created note id was not returned') + const createdId = data.id if (duplicateContext?.skipFileDuplicates) { - duplicateContext.seenTitlesInImport.add(note.title) + duplicateContext.seenTitlesInImport.add(normalizedTitle) } return createdId @@ -89,64 +96,81 @@ export class NoteCreator { title: string, userId: string, strategy: DuplicateStrategy, - context?: { - skipFileDuplicates: boolean - existingByTitle: Map - seenTitlesInImport: Set - } + context?: DuplicateContext ): Promise { + const normalizedTitle = normalizeNoteTitle(title) + if (context) { const { skipFileDuplicates, existingByTitle, seenTitlesInImport } = context - if (skipFileDuplicates && seenTitlesInImport.has(title)) { - return { title, skip: true, replace: false } + if (skipFileDuplicates && seenTitlesInImport.has(normalizedTitle)) { + return { title: normalizedTitle, skip: true, replace: false } } - const existingId = existingByTitle.get(title) + const existingId = + existingByTitle === null + ? await this.lookupExistingDuplicateIdWithCache(normalizedTitle, userId, context) + : (existingByTitle.get(normalizedTitle) ?? null) - if (!existingId) { - if (skipFileDuplicates) { - seenTitlesInImport.add(title) - } - return { title, skip: false, replace: false } - } + return this.buildDuplicateResult(normalizedTitle, strategy, existingId) + } - switch (strategy) { - case 'skip': - return { title, skip: true, replace: false } - case 'replace': - return { title, skip: false, replace: true, existingId } - case 'prefix': - default: - return { title: `[duplicate] ${title}`, skip: false, replace: false } - } + const existingId = await this.lookupExistingDuplicateId(normalizedTitle, userId) + return this.buildDuplicateResult(normalizedTitle, strategy, existingId) + } + + private buildDuplicateResult( + title: string, + strategy: DuplicateStrategy, + existingId?: string | null + ): DuplicateCheckResult { + if (!existingId) { + return { title, skip: false, replace: false } + } + + switch (strategy) { + case 'skip': + return { title, skip: true, replace: false } + case 'replace': + return { title, skip: false, replace: true, existingId } + case 'prefix': + default: + return { title: `[duplicate] ${title}`, skip: false, replace: false } } + } + + private async lookupExistingDuplicateIdWithCache( + title: string, + userId: string, + context: DuplicateContext + ): Promise { + if (context.fallbackExistingByTitle.has(title)) { + return context.fallbackExistingByTitle.get(title) ?? null + } + + const existingId = await this.lookupExistingDuplicateId(title, userId) + context.fallbackExistingByTitle.set(title, existingId) + + return existingId + } + private async lookupExistingDuplicateId(title: string, userId: string): Promise { const { data, error } = await this.supabase .from('notes') - .select('id, title') + .select('id, created_at') .eq('user_id', userId) .eq('title', title) + .order('created_at', { ascending: false }) if (error) { console.error('Duplicate check failed:', error) - return { title, skip: false, replace: false } + throw error } if (!data || data.length === 0) { - return { title, skip: false, replace: false } + return null } - const existingNote = data[0] - - switch (strategy) { - case 'skip': - return { title, skip: true, replace: false } - case 'replace': - return { title, skip: false, replace: true, existingId: existingNote.id } - case 'prefix': - default: - return { title: `[duplicate] ${title}`, skip: false, replace: false } - } + return data[0]?.id ?? null } } diff --git a/core/enex/parser.ts b/core/enex/parser.ts index 9365d162e44..6352a14b0dc 100644 --- a/core/enex/parser.ts +++ b/core/enex/parser.ts @@ -1,4 +1,5 @@ import type { EnexResource, ParsedNote } from './types' +import { normalizeNoteTitle } from './import-shared' export class EnexParser { async parse(file: File): Promise { @@ -21,7 +22,7 @@ export class EnexParser { } private parseNote(noteElement: Element): ParsedNote { - const title = noteElement.querySelector('title')?.textContent || 'Untitled' + const title = normalizeNoteTitle(noteElement.querySelector('title')?.textContent) const contentCDATA = noteElement.querySelector('content')?.textContent || '' const content = this.extractContentFromCDATA(contentCDATA) const created = this.parseDate(noteElement.querySelector('created')?.textContent) diff --git a/core/tests/unit/core-enex-noteCreator.test.ts b/core/tests/unit/core-enex-noteCreator.test.ts new file mode 100644 index 00000000000..fa1fbcdd80d --- /dev/null +++ b/core/tests/unit/core-enex-noteCreator.test.ts @@ -0,0 +1,152 @@ +import type { SupabaseClient } from '@supabase/supabase-js' + +import { NoteCreator } from '@core/enex/note-creator' +import type { ParsedNote } from '@core/enex/types' + +function createParsedNote(title: string): ParsedNote { + return { + title, + content: '

Hello

', + created: new Date('2026-03-15T10:00:00.000Z'), + updated: new Date('2026-03-15T10:00:00.000Z'), + tags: [], + resources: [], + } +} + +describe('core/enex/note-creator', () => { + let creator: NoteCreator + let mockSupabase: jest.Mocked + let noteTable: { + select: jest.Mock + insert: jest.Mock + } + let lookupOrder: jest.Mock + let insertSingle: jest.Mock + + beforeEach(() => { + lookupOrder = jest.fn() + insertSingle = jest.fn() + + const lookupQuery = { + eq: jest.fn().mockReturnThis(), + order: lookupOrder, + } + + noteTable = { + select: jest.fn().mockReturnValue(lookupQuery), + insert: jest.fn().mockReturnValue({ + select: jest.fn().mockReturnValue({ + single: insertSingle, + }), + }), + } + + mockSupabase = { + from: jest.fn().mockReturnValue(noteTable), + } as unknown as jest.Mocked + + creator = new NoteCreator(mockSupabase) + }) + + it('reuses the first pre-write lookup result when snapshot lookup is unavailable and no pre-import duplicate exists', async () => { + lookupOrder.mockResolvedValueOnce({ data: [], error: null }) + insertSingle + .mockResolvedValueOnce({ data: { id: 'created-1' }, error: null }) + .mockResolvedValueOnce({ data: { id: 'created-2' }, error: null }) + + const duplicateContext = { + skipFileDuplicates: false, + existingByTitle: null, + fallbackExistingByTitle: new Map(), + seenTitlesInImport: new Set(), + } + + await expect( + creator.create(createParsedNote('Repeated title'), 'user-1', 'prefix', duplicateContext) + ).resolves.toBe('created-1') + await expect( + creator.create(createParsedNote('Repeated title'), 'user-1', 'prefix', duplicateContext) + ).resolves.toBe('created-2') + + expect(lookupOrder).toHaveBeenCalledTimes(1) + expect(noteTable.insert).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ title: 'Repeated title' }) + ) + expect(noteTable.insert).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ title: 'Repeated title' }) + ) + expect(duplicateContext.fallbackExistingByTitle.get('Repeated title')).toBeNull() + }) + + it('does not fall back to a live lookup when the pre-import snapshot is available and misses the title', async () => { + insertSingle.mockResolvedValueOnce({ data: { id: 'created-1' }, error: null }) + + const duplicateContext = { + skipFileDuplicates: false, + existingByTitle: new Map(), + fallbackExistingByTitle: new Map(), + seenTitlesInImport: new Set(), + } + + await expect( + creator.create(createParsedNote('Fresh title'), 'user-1', 'prefix', duplicateContext) + ).resolves.toBe('created-1') + + expect(lookupOrder).not.toHaveBeenCalled() + expect(noteTable.insert).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Fresh title' }) + ) + expect(duplicateContext.fallbackExistingByTitle.size).toBe(0) + }) + + it('keeps prefix behavior stable across repeated titles when a pre-import duplicate exists', async () => { + lookupOrder.mockResolvedValueOnce({ + data: [{ id: 'existing-1', created_at: '2026-03-14T10:00:00.000Z' }], + error: null, + }) + insertSingle + .mockResolvedValueOnce({ data: { id: 'created-1' }, error: null }) + .mockResolvedValueOnce({ data: { id: 'created-2' }, error: null }) + + const duplicateContext = { + skipFileDuplicates: false, + existingByTitle: null, + fallbackExistingByTitle: new Map(), + seenTitlesInImport: new Set(), + } + + await expect( + creator.create(createParsedNote('Repeated title'), 'user-1', 'prefix', duplicateContext) + ).resolves.toBe('created-1') + await expect( + creator.create(createParsedNote('Repeated title'), 'user-1', 'prefix', duplicateContext) + ).resolves.toBe('created-2') + + expect(lookupOrder).toHaveBeenCalledTimes(1) + expect(noteTable.insert).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ title: '[duplicate] Repeated title' }) + ) + expect(noteTable.insert).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ title: '[duplicate] Repeated title' }) + ) + expect(duplicateContext.fallbackExistingByTitle.get('Repeated title')).toBe('existing-1') + }) + + it('fails note creation when duplicate lookup errors instead of treating the note as new', async () => { + lookupOrder.mockResolvedValueOnce({ + data: null, + error: { message: 'lookup failed' }, + }) + + await expect(creator.create(createParsedNote('Broken lookup'), 'user-1', 'prefix')).rejects.toThrow( + 'Failed to create note: lookup failed' + ) + + expect(noteTable.insert).not.toHaveBeenCalled() + }) +}) diff --git a/cypress/component/lib/enex/note-creator.cy.ts b/cypress/component/lib/enex/note-creator.cy.ts index a26e626ea4f..022ba47e8dc 100644 --- a/cypress/component/lib/enex/note-creator.cy.ts +++ b/cypress/component/lib/enex/note-creator.cy.ts @@ -8,10 +8,10 @@ type SinonStub = any type SupabaseQueryBuilder = { select: SinonStub eq: SinonStub + order: SinonStub insert: SinonStub update: SinonStub single: SinonStub - then: (callback: (result: { data: unknown[]; error: unknown }) => void) => void } type SupabaseClientStub = { @@ -36,10 +36,10 @@ describe('NoteCreator', () => { mockQueryBuilder = { select: cy.stub().returnsThis(), eq: cy.stub().returnsThis(), + order: cy.stub().resolves({ data: [], error: null }), insert: cy.stub().returnsThis(), update: cy.stub().returnsThis(), single: cy.stub().resolves({ data: { id: 'new-note-id' }, error: null }), - then: (resolve) => resolve({ data: [], error: null }) // Default: no duplicates } mockSupabase = { @@ -64,7 +64,7 @@ describe('NoteCreator', () => { it('skips duplicate when strategy is skip', async () => { // Mock duplicate found - mockQueryBuilder.then = (resolve) => resolve({ data: [{ id: 'existing-id', title: 'Test Note' }], error: null }) + mockQueryBuilder.order.resolves({ data: [{ id: 'existing-id', created_at: '2026-03-14T10:00:00.000Z' }], error: null }) const id = await creator.create(mockNote, 'user1', 'skip') @@ -75,7 +75,7 @@ describe('NoteCreator', () => { it('replaces duplicate when strategy is replace', async () => { // Mock duplicate found - mockQueryBuilder.then = (resolve) => resolve({ data: [{ id: 'existing-id', title: 'Test Note' }], error: null }) + mockQueryBuilder.order.resolves({ data: [{ id: 'existing-id', created_at: '2026-03-14T10:00:00.000Z' }], error: null }) // Mock update response mockQueryBuilder.single.resolves({ data: { id: 'existing-id' }, error: null }) @@ -89,7 +89,7 @@ describe('NoteCreator', () => { it('prefixes duplicate when strategy is prefix', async () => { // Mock duplicate found - mockQueryBuilder.then = (resolve) => resolve({ data: [{ id: 'existing-id', title: 'Test Note' }], error: null }) + mockQueryBuilder.order.resolves({ data: [{ id: 'existing-id', created_at: '2026-03-14T10:00:00.000Z' }], error: null }) const id = await creator.create(mockNote, 'user1', 'prefix') @@ -116,6 +116,7 @@ describe('NoteCreator', () => { const context = { skipFileDuplicates: true, existingByTitle: new Map(), + fallbackExistingByTitle: new Map(), seenTitlesInImport: new Set(['Test Note']) } @@ -129,6 +130,7 @@ describe('NoteCreator', () => { const context = { skipFileDuplicates: true, existingByTitle: new Map(), + fallbackExistingByTitle: new Map(), seenTitlesInImport: new Set() } diff --git a/cypress/component/lib/enex/parser.cy.ts b/cypress/component/lib/enex/parser.cy.ts index b26b8845c7c..ceaacfe87a9 100644 --- a/cypress/component/lib/enex/parser.cy.ts +++ b/cypress/component/lib/enex/parser.cy.ts @@ -77,6 +77,22 @@ describe('EnexParser', () => { expect(notes[0].updated).to.be.instanceOf(Date) }) + it('normalizes whitespace-only titles to Untitled', async () => { + const xml = ` + + + + Content]]> + + + ` + const file = new File([xml], 'whitespace-title.enex', { type: 'application/xml' }) + const notes = await parser.parse(file) + + expect(notes).to.have.length(1) + expect(notes[0].title).to.equal('Untitled') + }) + it('extracts content without en-note wrapper if missing', async () => { const xml = ` diff --git a/cypress/component/ui/ImportButton.cy.tsx b/cypress/component/ui/ImportButton.cy.tsx index 3338995c07f..5bf0952764c 100644 --- a/cypress/component/ui/ImportButton.cy.tsx +++ b/cypress/component/ui/ImportButton.cy.tsx @@ -30,6 +30,7 @@ describe('ImportButton', () => { insert: SinonStub, update: SinonStub, eq: SinonStub, + order: SinonStub, single: SinonStub, then: (resolve: (value: unknown) => void) => void } @@ -71,6 +72,7 @@ describe('ImportButton', () => { insert: cy.stub(), update: cy.stub(), eq: cy.stub(), + order: cy.stub(), single: cy.stub(), then: (resolve: (value: unknown) => void) => resolve({ data: [], error: null }) // Default resolve for list (handleDuplicate) } @@ -80,6 +82,7 @@ describe('ImportButton', () => { mockQueryBuilder.insert.returns(mockQueryBuilder) mockQueryBuilder.update.returns(mockQueryBuilder) mockQueryBuilder.eq.returns(mockQueryBuilder) + mockQueryBuilder.order.resolves({ data: [], error: null }) mockQueryBuilder.single.resolves({ data: { id: 'new-note-id' }, error: null }) // Mock Supabase Client diff --git a/docs/ai/design/feature-mobile-settings-menu.md b/docs/ai/design/feature-mobile-settings-menu.md new file mode 100644 index 00000000000..2cf9cc4f14b --- /dev/null +++ b/docs/ai/design/feature-mobile-settings-menu.md @@ -0,0 +1,290 @@ +--- +phase: design +title: System Design & Architecture - Mobile Settings Menu +description: Technical design for the fully interactive mobile settings screen with horizontal tabs and native file actions. +--- + +# System Design & Architecture + +## Architecture Overview +**What is the high-level system structure?** + +```mermaid +graph TD + User["Mobile user"] --> Screen["ui/mobile/app/(tabs)/settings.tsx"] + Screen --> Tabs["Horizontal tab rail"] + Screen --> Panels["Settings content panels"] + + Panels --> Account["My Account panel"] + Panels --> Import["ENEX import panel"] + Panels --> Export["ENEX export panel"] + Panels --> Wordpress["WordPress settings panel"] + Panels --> ApiKeys["API keys panel"] + + Account --> Auth["useAuth / SupabaseProvider"] + Wordpress --> WPService["core/services/wordpressSettings.ts"] + ApiKeys --> KeyService["core/services/apiKeysSettings.ts"] + Import --> ImportService["ui/mobile/services/enexImport.ts"] + Export --> ExportService["ui/mobile/services/enexExport.ts"] + + ImportService --> Picker["Expo Document Picker"] + ImportService --> FileRead["Expo FileSystem"] + ImportService --> NotesWrite["core/enex/note-creator.ts"] + ExportService --> NotesRead["core/services/notes.ts"] + ExportService --> Builder["core/enex/enex-builder.ts"] + ExportService --> Share["Expo Sharing"] +``` + +- Key components and responsibilities + - `settings.tsx` + - Owns selected tab state and page-level styling. + - Hosts the horizontal scrollable tab bar and shared page shell. + - `My Account panel` + - Shows authenticated email. + - Handles sign out, theme mode, and destructive account deletion confirmation. + - Preserves existing settings responsibilities that currently live on the screen outside the requested tab redesign. + - `ENEX import panel` + - Launches native file picker. + - Reads `.enex` content from device storage. + - Parses notes into a RN-safe structure and writes them using existing note creation logic. + - Exposes the same duplicate-handling controls as the web import dialog, backed by shared `core/enex` metadata. + - `ENEX export panel` + - Loads notes for the signed-in user. + - Builds `.enex` XML using the shared builder. + - Persists to a temporary file and opens native share/save flow. + - `WordPress settings panel` + - Reuses `WordPressSettingsService`. + - Mirrors existing validation and success/error behavior from web. + - `API keys panel` + - Reuses `ApiKeysSettingsService`. + - Mirrors existing validation and success/error behavior from web. + +## Data Models +**What data do we need to manage?** + +- Core UI model + - `SettingsTabKey = "account" | "import" | "export" | "wordpress" | "apiKeys"` + - `SettingsTabDefinition` currently includes: + - `key` + - `label` + - Iconography, descriptions, and render/component wiring live outside `SettingsTabDefinition` in the current implementation. + +- Account panel state + - `email: string` + - `themeMode: "system" | "light" | "dark"` + - `confirmDelete: boolean` + - `isDeleting: boolean` + - `errorMessage?: string` + +- Import panel state + - `selectedFileName?: string` + - `duplicateStrategy: "prefix" | "skip" | "replace"` + - Default: `"prefix"` from shared `core/enex` metadata in `DEFAULT_IMPORT_SETTINGS`. + - Maps to shared `core/enex` duplicate-handling controls and `NoteCreator.create()` behavior: + - `"prefix"` keeps the incoming note and rewrites the imported title to `[duplicate] ${title}`. + - `"skip"` keeps the existing note and returns no new note when the imported title matches the existing-title snapshot. + - `"replace"` updates the existing note selected from the existing-title snapshot for that title. + - Duplicate criteria are currently title-based only: + - existing notes are correlated by exact `title` matches from `core/enex/import-shared.ts`; + - notes repeated inside the same import batch are correlated by exact repeated `title` values in `seenTitlesInImport`. + - Persistent GUIDs, timestamps, and content hashes are not currently used for duplicate detection. + - `skipFileDuplicates: boolean` + - Default: `false` from shared `core/enex` metadata in `DEFAULT_IMPORT_SETTINGS`. + - Despite the UI copy `Skip duplicates inside imported file(s)`, this flag currently applies to duplicate note titles seen within the current import batch, not attachments/resources and not a global skip-all mode. + - When enabled, `core/enex` duplicate handling records each imported note title in `seenTitlesInImport` and skips later notes in the same import session that repeat that title. + - `isImporting: boolean` + - `progress: ImportProgressState` + - `null` when idle + - `{ stage: "reading" }` while the selected `.enex` file is being loaded from disk + - `{ stage: "importing"; processed: number; total: number }` while parsed notes are being written + - `EnexImportPanel.tsx` uses the `stage` discriminator to decide whether to render a generic "Reading .enex file..." message or a note counter. + - `feedback?: { variant: "error" | "success" | "info"; message: string }` + - This runtime state replaces a separate `importSummary`/`errorMessage` split in the implementation. + +- Export panel state + - `isExporting: boolean` + - `lastExportFileName?: string` + - `exportedNotesCount?: number` + - `errorMessage?: string` + +- WordPress/API key panel state + - Existing status payloads from shared service classes. + - Local form input values. + - Loading/saving/error/success state. + +- ENEX mobile import structure + - `ParsedNote` + - `title: string` + - `content: string` + - `created: Date` + - `updated: Date` + - `tags: string[]` + - `resources: []` + - Resources are not expanded in the first mobile-native implementation path; raw note HTML is preserved where possible. + +- ENEX mobile export structure + - Shared `ExportNote` from `core/enex/export-types.ts`. + - Mobile export emits note title, HTML description, tags, timestamps, and an empty `resources` array. + +## API Design +**How do components communicate?** + +- Existing internal service interfaces + - `ApiKeysSettingsService.getStatus()` + - `ApiKeysSettingsService.upsert(geminiApiKey)` + - `WordPressSettingsService.getStatus()` + - `WordPressSettingsService.upsert({ siteUrl, wpUsername, applicationPassword, enabled })` + - `AuthService.deleteAccount()` via `useAuth()` + - `NoteService.getNotes()` / `getNotesByIds()` + - `NoteCreator.create()` + +- New mobile-only service interfaces + - `MobileEnexImportService.importAsset(asset, userId, settings, onProgress?): Promise` + - `onProgress?` receives `{ processed: number; total: number }` + - `MobileEnexImportService.importXml(xml, userId, settings, onProgress?): Promise` + - `onProgress?` receives `{ processed: number; total: number }` + - `MobileEnexExportService.exportAllNotes(userId, onProgress?): Promise<{ fileUri: string; fileName: string; noteCount: number }>` + - `onProgress?` receives staged export updates: + - `{ stage: "loading"; loaded: number; total: number }` + - `{ stage: "building"; noteCount: number }` + - `{ stage: "writing"; noteCount: number; fileName: string }` + +- Native integration contracts + - File import: + - `DocumentPicker.getDocumentAsync(...)` returns a picked `.enex` file URI. + - `FileSystem.readAsStringAsync(uri)` loads XML text. + - File export: + - `FileSystem.writeAsStringAsync(tempFileUri, xml)` + - `Sharing.shareAsync(tempFileUri, { mimeType: "application/xml" })` + +- Authentication/authorization approach + - All settings and note operations continue to use the authenticated Supabase session supplied by `SupabaseProvider`. + - Import writes and export reads operate only on the current user's notes. + +## Component Breakdown +**What are the major building blocks?** + +- Frontend components + - `ui/mobile/app/(tabs)/settings.tsx` + - `ui/mobile/components/settings/SettingsTabBar.tsx` + - `ui/mobile/components/settings/SettingsTabButton.tsx` + - `ui/mobile/components/settings/SettingsPanelCard.tsx` + - `ui/mobile/components/settings/AccountSettingsPanel.tsx` + - `ui/mobile/components/settings/EnexImportPanel.tsx` + - `ui/mobile/components/settings/EnexExportPanel.tsx` + - `ui/mobile/components/settings/WordPressSettingsPanel.tsx` + - Updated `GeminiApiKeySection` or replacement inline `ApiKeysSettingsPanel.tsx` + +- Services/modules + - `ui/mobile/services/enexImport.ts` + - `ui/mobile/services/enexExport.ts` + - Existing shared services for account/API/WordPress. + +- Third-party/native dependencies + - Expo document picker for choosing import files. + - Expo file system for reading and writing ENEX files. + - Expo sharing for handing export files to the native share sheet. + +## Design Decisions +**Why did we choose this approach?** + +- Decision: Replace modal-first settings interactions with inline tab panels. + - Pros: closer to the provided design, fewer context switches, easier to compare sections. + - Cons: more state lives on the screen instead of being isolated in modals. + +- Decision: Keep horizontal tabs scrollable instead of compressing labels. + - Pros: preserves readability of long labels like `Import .enex file`. + - Cons: requires careful spacing and active-state styling. + +- Decision: Reuse backend/service contracts for WordPress and API keys. + - Pros: consistent validation and data model between web and mobile. + - Cons: mobile UI must mirror service error handling carefully. + +- Decision: Build mobile-native ENEX import/export services instead of reusing browser-specific `core/enex` orchestrators. + - Pros: avoids DOM/File API mismatch in React Native. + - Cons: mobile service logic is partially duplicated around parsing/orchestration. + +- Decision: Keep duplicate-handling controls aligned with the web import dialog and centralize their definitions in shared `core/enex` metadata. + - The duplicate-handling controls are `duplicateStrategy` (`"prefix" | "skip" | "replace"`) plus `skipFileDuplicates`. + - The shared `core/enex` metadata currently lives in `core/enex/import-shared.ts` and includes: + - `DEFAULT_IMPORT_SETTINGS` for defaults; + - `DUPLICATE_STRATEGY_OPTIONS` and `IMPORT_SETTINGS_COPY` for the visible controls/copy; + - `fetchExistingTitles()` / `resolveExistingTitlesForImport()` for the existing-title snapshot used by both web and mobile orchestration. + - Why these three strategies were chosen: + - `prefix` is the safest default because it preserves imported content even when a duplicate exists or duplicate lookup is temporarily unavailable. + - `skip` matches the intent of users who trust the current library state and want deduplication without creating new notes. + - `replace` is the highest-risk option because it overwrites an existing note, but it is still needed for explicit parity with the web import flow and for users who intentionally want the imported version to win. + - Duplicate criteria used today: + - existing-note duplicates are determined by exact title match against the existing-title snapshot (`id`, `title`, `created_at`), with the newest `created_at` row winning when duplicate titles already exist in the database; + - in-file duplicates are determined by exact repeated titles tracked in `seenTitlesInImport`; + - persistent GUIDs, timestamps, and content hashes are not part of duplicate correlation in the current implementation. + - Why web/mobile alignment matters: + - users see the same duplicate-handling controls on both clients; + - imports remain predictable when users switch devices or compare results between mobile and web; + - shared `core/enex` metadata reduces copy drift and duplicate-strategy mismatches across clients. + - Duplicate-handling decision flow: + +```mermaid +flowchart TD + Start["Incoming parsed note"] --> Normalize["Normalize title via normalizeNoteTitle()"] + Normalize --> Seen{"skipFileDuplicates enabled AND title already seen in this import?"} + Seen -->|Yes| SkipInFile["Skip note as in-file duplicate"] + Seen -->|No| Snapshot{"existingByTitle snapshot available?"} + + Snapshot -->|Yes| SnapshotLookup["Read existing id from pre-import snapshot"] + Snapshot -->|No| Cache{"Fallback cache already has this title?"} + + Cache -->|Yes| CachedDecision["Reuse cached pre-write decision"] + Cache -->|No| LiveLookup["Query DB once before writing note"] + LiveLookup --> CacheStore["Store lookup result in fallback cache"] + CacheStore --> CachedDecision + + SnapshotLookup --> Existing{"Did pre-import view find an existing note?"} + CachedDecision --> Existing + + Existing -->|No| ImportAsIs["Import note as-is"] + Existing -->|Yes| Strategy{"duplicateStrategy"} + + Strategy -->|prefix| Prefix["Create new note titled [duplicate] ${title}"] + Strategy -->|skip| SkipExisting["Skip because title existed before import"] + Strategy -->|replace| Replace["Update the pre-import target note"] + + ImportAsIs --> Write["Write note"] + Prefix --> Write + Replace --> Write + + Write --> Track{"skipFileDuplicates enabled?"} + Track -->|Yes| MarkSeen["Add normalized title to seenTitlesInImport"] + Track -->|No| Done["Done"] + MarkSeen --> Done + SkipInFile --> Done + SkipExisting --> Done +``` + - The fallback cache path is intentionally stable for the entire import session: + - it records the first pre-write lookup result for each normalized title; + - it does not treat rows created later by the same import as newly "existing" duplicates; + - this keeps `duplicateStrategy` focused on notes that existed before the import, while `skipFileDuplicates` remains the only control that suppresses duplicates created by the imported file itself. + +- Alternatives considered + - Keep current sectioned list and only remove `Soon` badges. + - Open separate modal screens for each setting. + - Send import/export to an external web page instead of supporting native file actions. + +## Non-Functional Requirements +**How should the system perform?** + +- Performance targets + - Tab switching should feel immediate (<100ms local state updates). + - Settings status loads should show feedback without blocking the whole screen. + - Export should keep the UI responsive while the archive is generated. + - Existing sign-out and theme toggling should remain immediate local interactions. + +- Security requirements + - Do not expose stored API keys or WordPress passwords in plaintext. + - Preserve existing encrypted-storage behavior for remote credentials. + - Require explicit destructive confirmation before account deletion. + +- Reliability/availability needs + - Errors from remote services should render inline and preserve user input. + - Import/export failures should produce actionable feedback instead of silent failure. + - If native sharing is unavailable, the user should receive a clear error message. diff --git a/docs/ai/implementation/feature-mobile-settings-menu.md b/docs/ai/implementation/feature-mobile-settings-menu.md new file mode 100644 index 00000000000..e9239a7a080 --- /dev/null +++ b/docs/ai/implementation/feature-mobile-settings-menu.md @@ -0,0 +1,153 @@ +--- +phase: implementation +title: Implementation Guide - Mobile Settings Menu +description: Implementation notes for the redesigned mobile settings screen with native settings actions. +--- + +# Implementation Guide + +## Development Setup +**How do we get started?** + +- Prerequisites and dependencies + - Root dependencies installed with `npm ci`. + - Mobile dependencies installed in `ui/mobile/`. + - Working Supabase configuration for the mobile app. +- Environment setup steps + - Confirm mobile app auth/session is working through `SupabaseProvider`. + - Add Expo-native file dependencies before implementing ENEX actions. + - Use the existing mobile theme provider and typography tokens. + +## Code Structure +**How is the code organized?** + +- Screen entry + - `ui/mobile/app/(tabs)/settings.tsx` +- Settings UI components + - `ui/mobile/components/settings/*` +- Mobile ENEX services + - `ui/mobile/services/enexImport.ts` + - `ui/mobile/services/enexExport.ts` +- Shared services reused + - `core/services/apiKeysSettings.ts` + - `core/services/wordpressSettings.ts` + - `core/services/notes.ts` + - `core/enex/enex-builder.ts` + - `core/enex/note-creator.ts` + +## Implementation Notes +**Key technical details to remember:** + +### Delivered implementation +- `ui/mobile/app/(tabs)/settings.tsx` now renders a card-based settings shell with a horizontally scrollable tab rail. +- New tab panels were added for: + - `My Account` + - `Import .enex file` + - `Export .enex file` + - `WordPress settings` + - `API Keys` +- The old `ComingSoon`-driven mobile settings entries are no longer used by this screen. +- Mobile-native ENEX helpers/services were added in: + - `ui/mobile/lib/enexMobile.ts` + - `ui/mobile/services/enexImport.ts` + - `ui/mobile/services/enexExport.ts` +- Shared UI primitives were extended so the common mobile `Button` now exposes `accessibilityRole="button"` for better accessibility/testability. + +### Core Features +- Horizontal tabs + - Use a horizontally scrolling `ScrollView` for the tab rail. + - Keep labels readable; do not shrink text to fit the screen width. + - Active tab should render as a pill/card state similar to the screenshot. + +- Account panel + - Show current user email in a dedicated card. + - Keep theme mode switching accessible inside this panel. + - Replace the old modal-only delete flow with an inline destructive confirmation section that matches the screenshot. + +- API keys panel + - Reuse `ApiKeysSettingsService`. + - Keep current validation rules: + - empty key + unconfigured => error; + - empty key + configured => no-op/success message; + - non-empty key => save and clear field. + +- WordPress panel + - Reuse `WordPressSettingsService`. + - Mirror current web validation: + - site URL and username required; + - initial setup requires application password; + - trailing slash trimmed from site URL; + - stored password remains valid when the field is left empty. + +- ENEX import + - Pick a single `.enex` document from the device. + - On Android, use a broad picker MIME type and validate the chosen file in-app because `.enex` providers often do not advertise a stable XML MIME type. +- Reuse shared import settings metadata from `core/enex` so web and mobile stay aligned on duplicate strategy labels and defaults. +- Fail closed for duplicate strategies that require a snapshot of existing note titles: + - `skip` and `replace` now stop with a user-facing error if existing-title lookup is unavailable; + - `prefix` remains available when the shared snapshot is unavailable, but now uses a stable per-import fallback cache keyed by normalized title. + - The fallback cache stores the first pre-write lookup result for each title and reuses it for the rest of the batch, so notes created by the current import do not become new "existing duplicates" mid-run. + - If a per-title duplicate lookup fails during that fallback path, note creation now fails instead of silently treating the note as non-duplicate. +- Read file content as UTF-8 text. + - Parse note entries with a RN-safe XML parser. + - Preserve title, tags, timestamps, and note body HTML where possible. + - Create notes through `NoteCreator` using the user-selected duplicate strategy plus `skip duplicates inside imported file(s)`. + - Surface in-panel progress as `processed / total notes` while notes are being imported. + +- ENEX export + - Read user notes through `NoteService`. + - Convert notes into `ExportNote[]` and build XML through `EnexBuilder`. + - Write the result to a temporary file and trigger native share/save flow. + - Surface staged progress while exporting: loading notes, building archive, saving file, opening share sheet. + +### Patterns & Best Practices +- Keep panel state localized to each feature component. +- Prefer explicit `loading`, `saving`, `successMessage`, and `errorMessage` states over implicit status logic. +- Add small comments only where mobile/native behavior is non-obvious. +- Keep remote settings loading inside panel components so unopened tabs do not fetch on first render. + +## Integration Points +**How do pieces connect?** + +- `settings.tsx` owns selected tab state and composes all panels. +- Each remote settings panel uses the existing Supabase-backed service classes. +- ENEX services use the active mobile Supabase client and authenticated user from the provider layer. + +## Error Handling +**How do we handle failures?** + +- Render inline error banners inside each panel. +- Preserve user-entered form values after failed saves. +- Show a clear import/export error if: + - no file is selected; + - file parsing fails; + - native share/save is unavailable; + - note creation/export generation fails. + +## Implementation Validation +**What was verified during implementation?** + +- `npm --prefix ui/mobile run type-check` +- `npm --prefix ui/mobile run lint` +- `npx jest --runInBand tests/unit/mobileEnexHelpers.test.ts tests/integration/settingsScreen.test.tsx` in `ui/mobile/` +- `npx ai-devkit@latest lint --feature mobile-settings-menu` + +## Environment Notes +**What setup details mattered in this workspace?** + +- Root `npm ci` completed successfully. +- `ui/mobile` dependency bootstrap required a fallback from `npm ci` to `npm install` on Windows because the existing `node_modules` directory produced an `ENOTEMPTY` error during cleanup. + +## Performance Considerations +**How do we keep it fast?** + +- Do not eagerly load every remote panel on initial screen render. +- Load WordPress/API key status when their panel is shown or on explicit user action. +- Avoid re-rendering every panel on every input change by keeping panel logic isolated. + +## Security Notes +**What security measures are in place?** + +- Continue using existing encrypted backend storage for API keys and WordPress passwords. +- Do not echo stored secrets back into input fields. +- Guard destructive account deletion behind an explicit acknowledgement checkbox. diff --git a/docs/ai/planning/feature-mobile-settings-menu.md b/docs/ai/planning/feature-mobile-settings-menu.md new file mode 100644 index 00000000000..0e594298db5 --- /dev/null +++ b/docs/ai/planning/feature-mobile-settings-menu.md @@ -0,0 +1,74 @@ +--- +phase: planning +title: Project Planning & Task Breakdown - Mobile Settings Menu +description: Task breakdown for delivering the complete interactive mobile settings screen. +--- + +# Project Planning & Task Breakdown + +## Milestones +**What are the major checkpoints?** + +- [x] Milestone 1: Feature docs, scope review, and native file-flow design completed. +- [x] Milestone 2: Mobile settings screen redesigned with horizontal scroll tabs and working account/API/WordPress panels. +- [x] Milestone 3: ENEX import/export flows integrated and covered by tests. +- [x] Milestone 4: Validation, implementation review, and final regression pass completed. + +## Task Breakdown +**What specific work needs to be done?** + +### Phase 1: Foundation +- [x] Task 1.1: Audit current mobile settings screen, existing shared services, and mobile testing setup. +- [x] Task 1.2: Create feature documentation in `docs/ai/{requirements,design,planning,implementation,testing}/feature-mobile-settings-menu.md`. +- [x] Task 1.3: Add any missing Expo/native dependencies needed for file import/export. + +### Phase 2: Core Features +- [x] Task 2.1: Replace the current settings layout with a horizontally scrollable tab rail and card-based panel shell. +- [x] Task 2.2: Implement the `My Account` panel with email display, theme controls, sign out, and inline delete-account confirmation UI. +- [x] Task 2.3: Refactor API key settings into an inline tab panel with status, validation, error, and save behavior. +- [x] Task 2.4: Add a WordPress settings tab panel with load/save, enable toggle, and inline feedback. +- [x] Task 2.5: Implement RN-safe `.enex` import flow using native file picking and note creation services. +- [x] Task 2.6: Implement RN-safe `.enex` export flow using note reads, ENEX building, temporary file writing, and native sharing. + +### Phase 3: Integration & Polish +- [x] Task 3.1: Update or remove obsolete `ComingSoon`-driven settings pieces from mobile. +- [x] Task 3.2: Add or update integration tests for the settings screen tabs and success/error states. +- [x] Task 3.3: Add targeted unit tests for new mobile ENEX helpers/services. +- [x] Task 3.4: Run validation (`type-check`, `lint`, targeted tests) and fix regressions. + +## Dependencies +**What needs to happen in what order?** + +- Task 1.1 and Task 1.2 must finish before implementation to lock the feature scope. +- Task 1.3 must happen before ENEX import/export implementation. +- Task 2.1 is the UI foundation for Tasks 2.2-2.6. +- Task 2.5 and Task 2.6 depend on authenticated note access through the existing mobile provider layer. +- Task 3.2 and Task 3.3 depend on the final screen/component structure stabilizing. + +## Timeline & Estimates +**When will things be done?** + +- Total estimated effort: 1 focused implementation pass. +- Highest-effort items: + - ENEX import on mobile. + - ENEX export/share flow on mobile. + - Integration tests for the redesigned tabbed screen. + +## Risks & Mitigation +**What could go wrong?** + +- **Risk: Browser-only ENEX helpers do not work in React Native.** + - Mitigation: build mobile-specific orchestration while reusing shared builder/types/services where compatible. +- **Risk: Native file APIs behave differently on Android/iOS.** + - Mitigation: keep the first implementation on Expo-supported APIs and guard unsupported states with explicit errors. +- **Risk: Inline settings panels become visually crowded on small screens.** + - Mitigation: use a horizontal tab rail, compact cards, and scrollable body content per panel. +- **Risk: Existing settings tests only cover the old screen.** + - Mitigation: replace outdated assertions with tab-focused integration tests and add targeted service tests. + +## Resources Needed +**What do we need to succeed?** + +- Existing Supabase functions for API keys and WordPress settings. +- Expo native modules for document picking, file system access, and sharing. +- Mobile testing utilities already present in `ui/mobile/tests/`. diff --git a/docs/ai/requirements/feature-mobile-settings-menu.md b/docs/ai/requirements/feature-mobile-settings-menu.md new file mode 100644 index 00000000000..1219da5797f --- /dev/null +++ b/docs/ai/requirements/feature-mobile-settings-menu.md @@ -0,0 +1,88 @@ +--- +phase: requirements +title: Requirements & Problem Understanding - Mobile Settings Menu +description: Complete the mobile settings experience with fully interactive settings panels and horizontal scrolling tabs. +--- + +# Requirements & Problem Understanding + +## Problem Statement +**What problem are we solving?** + +- The React Native app already exposes a settings screen, but several entries are still marked as coming soon and the page is not aligned with the intended product design. +- Mobile users currently cannot complete important account and integration tasks in-app: + - review account email and delete the account from the settings page layout shown in design; + - import notes from `.enex`; + - export notes to `.enex`; + - configure WordPress publishing credentials; + - configure Gemini API credentials. +- The current settings screen is grouped by generic sections instead of the requested horizontally scrollable tab navigation shown in the screenshots. + +## Goals & Objectives +**What do we want to achieve?** + +- **Primary Goals** + - Replace the existing mobile settings page with a horizontally scrollable tab interface. + - Provide tabs for: + - `My Account` + - `Import .enex file` + - `Export .enex file` + - `WordPress settings` + - `API Keys` + - Make each tab interactive rather than showing `Soon` or `Coming soon`. + - Match the dark mobile card-based visual direction shown in the screenshots while preserving the existing app theme tokens. +- **Secondary Goals** + - Reuse existing backend/services for API keys and WordPress settings. + - Keep the screen accessible and friendly on narrow devices. + - Preserve theme switching support inside the redesigned screen. + - Preserve sign-out access inside the redesigned screen. +- **Non-goals** + - Rebuilding unrelated settings areas outside the requested mobile screen. + - Adding new backend endpoints for WordPress or API keys when existing ones already satisfy the need. + - Full media/resource fidelity for every ENEX edge case beyond the mobile-friendly import/export flow needed for this settings feature. + +## User Stories & Use Cases +**How will users interact with the solution?** + +- As a mobile user, I want to switch between settings categories using horizontal tabs so that the page remains compact and easy to scan. +- As a signed-in user, I want to see my account email and delete my account from the `My Account` tab so that account management is available without leaving the app. +- As a migrating user, I want to import an `.enex` file from my device so that I can bring notes into EverFreeNote on mobile. +- As a migrating user, I want to choose how duplicates are handled during mobile import so that mobile import matches web capabilities. +- As a backup-conscious user, I want to export my notes as `.enex` and share/save the file from my device so that I can keep a portable archive. +- As a publishing user, I want to configure WordPress credentials from mobile so that WordPress export access matches web capabilities. +- As an AI user, I want to store my Gemini API key from mobile so that AI-related features can be configured securely in-app. + +## Success Criteria +**How will we know when we're done?** + +- The settings screen renders five horizontally scrollable tabs matching the requested menu structure. +- None of the requested tabs display `Soon`/`Coming soon`. +- `My Account` shows the authenticated email and allows account deletion with explicit confirmation. +- `My Account` continues to expose theme controls and sign-out without regressing current behavior. +- `Import .enex file` allows choosing a file and importing notes into the signed-in account. +- `Import .enex file` exposes the same duplicate-handling options as web: `prefix`, `skip`, `replace`, and `skip duplicates inside imported file(s)`. +- `Export .enex file` allows generating an `.enex` archive and sharing/saving it from the device. +- `WordPress settings` loads current status, validates input, and saves through existing Supabase functions. +- `API Keys` loads current status, validates input, and saves through existing Supabase functions. +- The screen remains usable on narrow mobile widths without horizontal page overflow; only the tab rail itself scrolls horizontally. + +## Constraints & Assumptions +**What limitations do we need to work within?** + +- **Technical constraints** + - Work must fit the current Expo / React Native architecture in `ui/mobile/`. + - Existing services under `core/services/` should be reused where practical. + - ENEX browser-only helpers cannot be consumed directly in React Native without adaptation. +- **Design constraints** + - The requested visual direction is the provided screenshot set: dark cards, prominent section headings, rounded pills for tabs, and horizontal tab scrolling. + - The last screenshot is treated as the complete menu inventory; the earlier screenshots define layout and tone. +- **Assumptions** + - Feature name will be tracked as `mobile-settings-menu`. + - Import/export can be implemented as a native mobile flow using Expo file APIs and RN-safe parsing/building utilities. + - The signed-in user context is already available through `SupabaseProvider`. + +## Questions & Open Items +**What do we still need to clarify?** + +- No blocking product questions remain for implementation in this iteration. +- If ENEX media/resource fidelity needs to match the existing web import/export feature exactly, that should be handled as a follow-up enhancement after the mobile settings flow is delivered. diff --git a/docs/ai/testing/feature-mobile-settings-menu.md b/docs/ai/testing/feature-mobile-settings-menu.md new file mode 100644 index 00000000000..73f31b38c8f --- /dev/null +++ b/docs/ai/testing/feature-mobile-settings-menu.md @@ -0,0 +1,125 @@ +--- +phase: testing +title: Testing Strategy - Mobile Settings Menu +description: Testing plan for the redesigned mobile settings screen and its native settings actions. +--- + +# Testing Strategy + +## Test Coverage Goals +**What level of testing do we aim for?** + +- Unit test coverage target: 100% of new/changed mobile helper logic where practical. +- Integration test scope: redesigned settings screen, tab switching, and remote settings flows. +- End-to-end/manual scope: native file selection and sharing behavior on device/simulator. + +## Unit Tests +**What individual components need testing?** + +### Mobile ENEX import helpers +- [x] Parse title, timestamps, tags, and note body from ENEX XML. +- [x] Report import progress as `processed / total` while notes are created. +- [x] Respect duplicate strategy and `skip duplicates inside imported file(s)` during mobile import orchestration. +- [x] Stop `skip` / `replace` imports when existing-title lookup is unavailable, while allowing `prefix` to fall back safely. +- [x] Keep `prefix` fallback stable when snapshot lookup is unavailable, so duplicate decisions do not change mid-import after earlier writes. +- [x] Fail note creation when fallback duplicate lookup errors, instead of importing as if no duplicate existed. +- [ ] Handle malformed XML / empty note payloads with user-facing errors. +- [ ] Additional coverage: duplicate title handling path through import orchestration. + +### Mobile ENEX export helpers +- [x] Build export payload from note data with deterministic file naming. +- [x] Report staged export progress for loading, building, and writing. +- [ ] Handle empty note collections gracefully. +- [ ] Additional coverage: temporary file creation / share precondition failures. + +### Settings panel logic +- [x] API keys validation state transitions. +- [x] WordPress settings validation and URL normalization. +- [x] Import panel constrains selection to `.enex` flows and rejects other file names before import. +- [x] Import panel passes selected duplicate-handling settings into the mobile import service. +- [x] Android-safe picker configuration keeps `.enex` files selectable even when the provider does not advertise an XML MIME type. +- [x] Export panel copy and share hand-off stay aligned with the "export all notes" mobile behavior. +- [ ] Additional coverage: account deletion acknowledgement gating. + +## Integration Tests +**How do we test component interactions?** + +- [x] Settings screen renders the horizontal tab menu with all requested tabs. +- [x] Switching tabs updates the visible panel content. +- [ ] `My Account` panel shows email and prevents delete until acknowledgement is checked. +- [x] `API Keys` panel loads status, saves successfully, and renders inline errors on failure. +- [x] `WordPress settings` panel loads status, validates fields, and saves successfully. +- [x] `Import .enex file` panel handles picker success and failure states with mocked native modules. +- [x] `Export .enex file` panel handles export/share success and failure states with mocked native modules. + +## End-to-End Tests +**What user flows need validation?** + +- [ ] User flow 1: open settings -> switch across all tabs -> save API key and WordPress settings. +- [ ] User flow 2: import `.enex` from device storage and verify imported note appears in notes list. +- [ ] User flow 3: export notes to `.enex` and confirm native share sheet opens. +- [ ] Regression of adjacent features: theme switching and account sign-out still work. + +## Test Data +**What data do we use for testing?** + +- Mock authenticated user with email. +- Mock Supabase function responses for API key and WordPress endpoints. +- Small ENEX XML fixture with one and multiple notes. +- Note fixtures for export generation, including tags and HTML description. + +## Test Reporting & Coverage +**How do we verify and communicate test results?** + +- `npm --prefix ui/mobile test -- --runInBand` +- `npm --prefix ui/mobile test:coverage` +- `npm --prefix ui/mobile type-check` +- `npm --prefix ui/mobile lint` +- Record any remaining manual-only gaps in this doc after implementation. + +### Latest implementation run +- Passed: `npm --prefix ui/mobile run type-check` +- Passed: `npm --prefix ui/mobile run lint` +- Passed: `npx jest --runInBand tests/unit/importShared.test.ts tests/unit/mobileEnexHelpers.test.ts tests/unit/mobileEnexImportService.test.ts tests/unit/settingsPanels.test.tsx tests/unit/input.test.tsx tests/integration/settingsScreen.test.tsx` from `ui/mobile/` +- Passed: `npx jest --config jest.config.cjs --selectProjects unit-core --runInBand --runTestsByPath core/tests/unit/core-enex-noteCreator.test.ts` +- Passed: `npx cypress run --component --browser electron --spec "cypress/component/lib/enex/note-creator.cy.ts"` +- Passed: `npx cypress run --component --browser electron --spec "cypress/component/ui/ImportButton.cy.tsx"` +- Passed earlier in the feature workflow: `npx ai-devkit@latest lint --feature mobile-settings-menu` + +## Manual Testing +**What requires human validation?** + +- UI/UX testing checklist + - [ ] Horizontal tab rail scrolls smoothly on narrow devices. + - [ ] Active tab styling matches the intended design. + - [ ] Delete-account section is understandable and safely gated. + - [ ] Import/export actions feel native and understandable. +- Device compatibility + - [ ] Android dev build + - [ ] iOS simulator/device if available +- Smoke tests after deployment + - [ ] Saved API key persists. + - [ ] Saved WordPress config persists. + - [ ] Imported notes appear after returning to the notes list. + +## Performance Testing +**How do we validate performance?** + +- Ensure tab switching stays responsive on a typical device. +- Confirm export of a moderate notes set does not freeze the UI. +- Check that importing a small `.enex` file completes without app crash. + +## Bug Tracking +**How do we manage issues?** + +- Classify blocking issues: + - Critical: settings section unusable or destructive flow unsafe. + - High: import/export cannot complete. + - Medium: save states or tab UI regressions. + - Low: visual polish issues. + +## Remaining Gaps +**What still needs manual or follow-up coverage?** + +- Manual device validation for native document picker and share sheet is still pending. +- No automated test yet covers malformed ENEX input or the delete-account acknowledgement gating path. diff --git a/ui/mobile/app/(tabs)/settings.tsx b/ui/mobile/app/(tabs)/settings.tsx index 0ea906baa25..45cd0cb420a 100644 --- a/ui/mobile/app/(tabs)/settings.tsx +++ b/ui/mobile/app/(tabs)/settings.tsx @@ -1,211 +1,146 @@ -import { View, Text, StyleSheet, Pressable, Modal, ScrollView } from 'react-native' -import { useMemo, useState } from 'react' -import { useTheme, useAuth } from '@ui/mobile/providers' -import type { ThemeMode } from '@ui/mobile/lib/theme' -import { Square, CheckSquare } from 'lucide-react-native' -import { Button } from '@ui/mobile/components/ui' +import { useMemo, useState, type ReactNode } from 'react' +import { ScrollView, StyleSheet, Text, View } from 'react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' -import { SettingsSectionHeader } from '@ui/mobile/components/settings/SettingsSectionHeader' -import { SettingsRow } from '@ui/mobile/components/settings/SettingsRow' -import { ComingSoonBadge } from '@ui/mobile/components/settings/ComingSoonBadge' -import { GeminiApiKeySection } from '@ui/mobile/components/settings/GeminiApiKeySection' + +import { AccountSettingsPanel } from '@ui/mobile/components/settings/AccountSettingsPanel' +import { ApiKeysSettingsPanel } from '@ui/mobile/components/settings/ApiKeysSettingsPanel' +import { EnexExportPanel } from '@ui/mobile/components/settings/EnexExportPanel' +import { EnexImportPanel } from '@ui/mobile/components/settings/EnexImportPanel' +import { + SettingsTabBar, + type SettingsTabDefinition, + type SettingsTabKey, +} from '@ui/mobile/components/settings/SettingsTabBar' +import { WordPressSettingsPanel } from '@ui/mobile/components/settings/WordPressSettingsPanel' +import { useAuth, useSupabase, useTheme } from '@ui/mobile/providers' + +const tabs: SettingsTabDefinition[] = [ + { key: 'account', label: 'My Account' }, + { key: 'import', label: 'Import .enex file' }, + { key: 'export', label: 'Export .enex file' }, + { key: 'wordpress', label: 'WordPress settings' }, + { key: 'apiKeys', label: 'API Keys' }, +] + +const initialVisitedTabs: Record = { + account: true, + import: false, + export: false, + wordpress: false, + apiKeys: false, +} + +type PanelConfig = { + key: SettingsTabKey + isVisited: boolean + content: ReactNode +} + +const getPanelAccessibilityProps = (isActive: boolean) => ({ + accessibilityElementsHidden: !isActive, + importantForAccessibility: isActive + ? ('auto' as const) + : ('no-hide-descendants' as const), +}) + +function SettingsPanel({ + isActive, + content, + styles, +}: Readonly<{ + isActive: boolean + content: ReactNode + styles: ReturnType +}>) { + return ( + + {content} + + ) +} export default function SettingsScreen() { const { colors, mode, setMode, colorScheme } = useTheme() const { signOut, deleteAccount } = useAuth() + const { user } = useSupabase() const insets = useSafeAreaInsets() - const styles = useMemo(() => createStyles(colors, insets), [colors, insets]) + const styles = useMemo(() => createStyles(colors, insets.bottom ?? 0), [colors, insets.bottom]) + const [activeTab, setActiveTab] = useState('account') + const [visitedTabs, setVisitedTabs] = useState(initialVisitedTabs) - const [showDeleteModal, setShowDeleteModal] = useState(false) - const [understandDeletion, setUnderstandDeletion] = useState(false) - const [isDeleting, setIsDeleting] = useState(false) - const [error, setError] = useState(null) + const handleTabChange = (tab: SettingsTabKey) => { + setActiveTab(tab) + setVisitedTabs((current) => (current[tab] ? current : { ...current, [tab]: true })) + } - const themeOptions: { value: ThemeMode; label: string; description: string }[] = [ - { value: 'system', label: 'System', description: 'Follow device setting' }, - { value: 'light', label: 'Light', description: 'Always light theme' }, - { value: 'dark', label: 'Dark', description: 'Always dark theme' }, + const panelConfigs: PanelConfig[] = [ + { + key: 'account', + isVisited: visitedTabs.account, + content: ( + + ), + }, + { + key: 'import', + isVisited: visitedTabs.import, + content: , + }, + { + key: 'export', + isVisited: visitedTabs.export, + content: , + }, + { + key: 'wordpress', + isVisited: visitedTabs.wordpress, + content: , + }, + { + key: 'apiKeys', + isVisited: visitedTabs.apiKeys, + content: , + }, ] - const handleDeleteAccount = async () => { - if (!understandDeletion) return - setIsDeleting(true) - setError(null) - try { - await deleteAccount() - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to delete account') - setIsDeleting(false) - } - } - return ( + + + Settings + Everything for your account, imports, exports, and integrations. + - {/* ─── APPEARANCE ─────────────────────────────── */} - - Current: {mode} ({colorScheme}) - - {themeOptions.map((option, index) => { - const isSelected = mode === option.value - return ( - [ - styles.option, - index === 0 && styles.optionFirst, - index === themeOptions.length - 1 && styles.optionLast, - index < themeOptions.length - 1 && styles.optionBorder, - pressed && styles.optionPressed, - isSelected && styles.optionSelected, - ]} - onPress={() => setMode(option.value)} - > - - {option.label} - {option.description} - - - {isSelected && } - - - ) - })} - - - {/* ─── INTEGRATIONS ────────────────────────────── */} - - - - } - disabled - showChevron={false} - isFirst={false} - isLast - /> - - - {/* ─── DATA ────────────────────────────────────── */} - - - } - disabled - showChevron={false} - isFirst - isLast={false} - /> - } - disabled - showChevron={false} - isFirst={false} - isLast - /> - - - {/* ─── ACCOUNT ─────────────────────────────────── */} - - - [ - styles.signOutButton, - pressed && styles.signOutButtonPressed, - ]} - onPress={() => void signOut()} - > - Sign Out - - - [ - styles.deleteLink, - pressed && styles.deleteLinkPressed, - ]} - onPress={() => setShowDeleteModal(true)} - > - Delete account - - - {/* ─── Delete account modal ────────────────────── */} - !isDeleting && setShowDeleteModal(false)} - > - - - Delete my account - - - This will permanently delete your account and all notes. Make sure you have any backups you need before continuing. - - - - - Note: Export is coming soon and is not available yet. - - - - setUnderstandDeletion(!understandDeletion)} - disabled={isDeleting} - > - {understandDeletion ? ( - - ) : ( - - )} - - I understand that my account and all notes will be permanently deleted. - - - - {error && {error}} - - - - - - + + + + {panelConfigs.map((panel) => + panel.isVisited ? ( + + ) : null + )} - + ) } -const createStyles = (colors: ReturnType['colors'], insets: { bottom?: number }) => +const createStyles = (colors: ReturnType['colors'], bottomInset: number) => StyleSheet.create({ container: { flex: 1, @@ -213,182 +148,41 @@ const createStyles = (colors: ReturnType['colors'], insets: { b }, contentContainer: { paddingHorizontal: 16, - paddingBottom: (insets.bottom ?? 0) + 32, - }, - sectionSubtitle: { - fontSize: 12, - fontFamily: 'Inter_400Regular', - color: colors.mutedForeground, - marginBottom: 8, - paddingHorizontal: 4, + paddingTop: 12, + paddingBottom: bottomInset + 24, }, - card: { - borderRadius: 12, + shell: { + borderRadius: 28, borderWidth: 1, borderColor: colors.border, - overflow: 'hidden', - }, - // Theme options - option: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - paddingHorizontal: 16, - paddingVertical: 12, - backgroundColor: colors.card, - }, - optionFirst: { - borderTopLeftRadius: 12, - borderTopRightRadius: 12, - }, - optionLast: { - borderBottomLeftRadius: 12, - borderBottomRightRadius: 12, - }, - optionBorder: { - borderBottomWidth: 1, - borderBottomColor: colors.border, - }, - optionSelected: { - backgroundColor: colors.accent, - }, - optionPressed: { - backgroundColor: colors.muted, + backgroundColor: colors.background, + padding: 14, + gap: 16, }, - optionText: { - flex: 1, - marginRight: 12, + header: { + gap: 6, + paddingHorizontal: 2, }, - optionLabel: { - fontSize: 16, - fontFamily: 'Inter_600SemiBold', + title: { color: colors.foreground, - marginBottom: 2, - }, - optionDescription: { - fontSize: 12, - fontFamily: 'Inter_400Regular', - color: colors.mutedForeground, - }, - radio: { - width: 20, - height: 20, - borderRadius: 10, - borderWidth: 2, - borderColor: colors.border, - alignItems: 'center', - justifyContent: 'center', - }, - radioSelected: { - borderColor: colors.primary, - }, - radioDot: { - width: 10, - height: 10, - borderRadius: 5, - backgroundColor: colors.primary, - }, - // Account - signOutButton: { - marginTop: 8, - backgroundColor: colors.destructive, - borderRadius: 12, - paddingVertical: 14, - alignItems: 'center', - }, - signOutButtonPressed: { - opacity: 0.8, - }, - signOutText: { - fontSize: 16, - fontFamily: 'Inter_600SemiBold', - color: colors.destructiveForeground, - }, - deleteLink: { - marginTop: 16, - alignItems: 'center', - paddingVertical: 8, - }, - deleteLinkPressed: { - opacity: 0.7, - }, - deleteLinkText: { - fontSize: 14, - fontFamily: 'Inter_500Medium', - color: colors.destructive, - textDecorationLine: 'underline', - }, - // Delete modal - modalOverlay: { - flex: 1, - backgroundColor: 'rgba(0, 0, 0, 0.5)', - justifyContent: 'center', - alignItems: 'center', - padding: 24, - }, - modalContent: { - width: '100%', - backgroundColor: colors.card, - borderRadius: 16, - padding: 24, - borderWidth: 1, - borderColor: colors.border, - shadowColor: '#000', - shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.3, - shadowRadius: 10, - elevation: 8, - }, - modalTitle: { - fontSize: 20, fontFamily: 'Inter_700Bold', - color: colors.foreground, - marginBottom: 16, + fontSize: 34, + lineHeight: 40, }, - modalDescription: { - fontSize: 14, - lineHeight: 20, - fontFamily: 'Inter_400Regular', + subtitle: { color: colors.mutedForeground, - marginBottom: 16, - }, - tipContainer: { - padding: 12, - backgroundColor: colors.muted, - borderRadius: 8, - marginBottom: 20, - }, - tipText: { - fontSize: 13, fontFamily: 'Inter_400Regular', - color: colors.mutedForeground, - lineHeight: 18, - }, - checkboxContainer: { - flexDirection: 'row', - alignItems: 'flex-start', - marginBottom: 24, - gap: 12, - }, - checkboxLabel: { - flex: 1, fontSize: 14, - fontFamily: 'Inter_400Regular', - color: colors.foreground, lineHeight: 20, + maxWidth: 320, }, - modalButtons: { - flexDirection: 'row', - justifyContent: 'flex-end', - gap: 12, + panelWrap: { + gap: 14, }, - modalButton: { - flex: 1, + panel: { + width: '100%', }, - errorText: { - color: colors.destructive, - fontSize: 12, - marginBottom: 16, - textAlign: 'center', + panelHidden: { + display: 'none', }, }) diff --git a/ui/mobile/components/settings/AccountSettingsPanel.tsx b/ui/mobile/components/settings/AccountSettingsPanel.tsx new file mode 100644 index 00000000000..adddc965c9b --- /dev/null +++ b/ui/mobile/components/settings/AccountSettingsPanel.tsx @@ -0,0 +1,275 @@ +import { useMemo, useState } from 'react' +import { Pressable, StyleSheet, Text, View } from 'react-native' +import { CheckSquare, LogOut, Monitor, Moon, Square, Sun, User } from 'lucide-react-native' + +import { Button } from '@ui/mobile/components/ui' +import { useTheme } from '@ui/mobile/providers' +import type { ColorScheme, ThemeMode } from '@ui/mobile/lib/theme' +import { SettingsPanelCard } from './SettingsPanelCard' +import { SettingsStatusMessage } from './SettingsStatusMessage' + +type AccountSettingsPanelProps = Readonly<{ + email: string + mode: ThemeMode + colorScheme: ColorScheme + onModeChange: (mode: ThemeMode) => void + onSignOut: () => Promise | void + onDeleteAccount: () => Promise +}> + +const themeOptions: Array<{ + value: ThemeMode + label: string + icon: typeof Monitor +}> = [ + { value: 'system', label: 'System', icon: Monitor }, + { value: 'light', label: 'Light', icon: Sun }, + { value: 'dark', label: 'Dark', icon: Moon }, +] + +export function AccountSettingsPanel({ + email, + mode, + colorScheme, + onModeChange, + onSignOut, + onDeleteAccount, +}: AccountSettingsPanelProps) { + const { colors } = useTheme() + const styles = useMemo(() => createStyles(colors), [colors]) + const [understandDeletion, setUnderstandDeletion] = useState(false) + const [isDeleting, setIsDeleting] = useState(false) + const [errorMessage, setErrorMessage] = useState(null) + + const handleDelete = async () => { + if (!understandDeletion || isDeleting) return + + setIsDeleting(true) + setErrorMessage(null) + + try { + await onDeleteAccount() + } catch (error) { + setErrorMessage(error instanceof Error ? error.message : 'Failed to delete account') + } finally { + setIsDeleting(false) + } + } + + return ( + } + title="My Account" + subtitle="Email and account management." + > + + EMAIL + {email} + + + + + Appearance + {`Current: ${mode} (${colorScheme})`} + + + + {themeOptions.map((option) => { + const Icon = option.icon + const isSelected = option.value === mode + + return ( + onModeChange(option.value)} + style={({ pressed }) => [ + styles.themeOption, + isSelected && styles.themeOptionSelected, + pressed && styles.themeOptionPressed, + ]} + > + + + {option.label} + + + ) + })} + + + + + Permanent action + + This will permanently delete your account and all notes. Export your notes before + deleting the account if you need a copy. + + + + setUnderstandDeletion((value) => !value)} + style={({ pressed }) => [styles.checkboxRow, pressed && styles.checkboxRowPressed]} + > + {understandDeletion ? ( + + ) : ( + + )} + + I understand that my account and all notes will be permanently deleted. + + + + {errorMessage ? ( + + ) : null} + + + + + + + + ) +} + +const createStyles = (colors: ReturnType['colors']) => + StyleSheet.create({ + section: { + gap: 8, + }, + eyebrow: { + color: colors.mutedForeground, + fontFamily: 'Inter_600SemiBold', + fontSize: 12, + letterSpacing: 1.6, + }, + email: { + color: colors.foreground, + fontFamily: 'Inter_700Bold', + fontSize: 16, + }, + themeHeader: { + gap: 4, + }, + sectionTitle: { + color: colors.foreground, + fontFamily: 'Inter_600SemiBold', + fontSize: 15, + }, + caption: { + color: colors.mutedForeground, + fontFamily: 'Inter_400Regular', + fontSize: 12, + }, + themeOptions: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 10, + }, + themeOption: { + minWidth: 92, + borderRadius: 12, + borderWidth: 1, + borderColor: colors.border, + backgroundColor: colors.background, + paddingHorizontal: 12, + paddingVertical: 10, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 8, + }, + themeOptionSelected: { + backgroundColor: colors.foreground, + borderColor: colors.foreground, + }, + themeOptionPressed: { + opacity: 0.85, + }, + themeLabel: { + color: colors.foreground, + fontFamily: 'Inter_600SemiBold', + fontSize: 14, + }, + themeLabelSelected: { + color: colors.background, + }, + warningBlock: { + borderRadius: 16, + borderWidth: 1, + borderColor: colors.destructive + '55', + backgroundColor: colors.destructive + '12', + padding: 14, + gap: 8, + }, + warningTitle: { + color: colors.destructive, + fontFamily: 'Inter_700Bold', + fontSize: 16, + }, + warningBody: { + color: colors.mutedForeground, + fontFamily: 'Inter_400Regular', + fontSize: 14, + lineHeight: 22, + }, + checkboxRow: { + borderRadius: 16, + borderWidth: 1, + borderColor: colors.border, + backgroundColor: colors.background, + padding: 14, + flexDirection: 'row', + alignItems: 'flex-start', + gap: 10, + }, + checkboxRowPressed: { + opacity: 0.85, + }, + checkboxLabel: { + flex: 1, + color: colors.mutedForeground, + fontFamily: 'Inter_400Regular', + fontSize: 14, + lineHeight: 22, + }, + actions: { + gap: 10, + alignItems: 'stretch', + }, + actionButton: { + width: '100%', + }, + buttonContent: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 8, + width: '100%', + }, + outlineButtonText: { + color: colors.foreground, + fontFamily: 'Inter_600SemiBold', + fontSize: 14, + }, + }) diff --git a/ui/mobile/components/settings/ApiKeysSettingsPanel.tsx b/ui/mobile/components/settings/ApiKeysSettingsPanel.tsx new file mode 100644 index 00000000000..b9cbf9e197f --- /dev/null +++ b/ui/mobile/components/settings/ApiKeysSettingsPanel.tsx @@ -0,0 +1,135 @@ +import { useEffect, useMemo, useState } from 'react' +import { ActivityIndicator, StyleSheet, Text, View } from 'react-native' +import { KeyRound } from 'lucide-react-native' + +import { ApiKeysSettingsService } from '@core/services/apiKeysSettings' +import { Button, Input } from '@ui/mobile/components/ui' +import { useSupabase, useTheme } from '@ui/mobile/providers' +import { SettingsPanelCard } from './SettingsPanelCard' +import { SettingsStatusMessage } from './SettingsStatusMessage' + +type FeedbackState = + | { variant: 'error' | 'success' | 'info'; message: string } + | null + +export function ApiKeysSettingsPanel() { + const { client } = useSupabase() + const { colors } = useTheme() + const styles = useMemo(() => createStyles(colors), [colors]) + const [value, setValue] = useState('') + const [configured, setConfigured] = useState(false) + const [isLoading, setIsLoading] = useState(true) + const [isSaving, setIsSaving] = useState(false) + const [feedback, setFeedback] = useState(null) + + useEffect(() => { + let isMounted = true + const service = new ApiKeysSettingsService(client) + + void (async () => { + try { + const status = await service.getStatus() + if (!isMounted) return + setConfigured(status.gemini.configured) + setFeedback( + status.gemini.configured + ? { variant: 'info', message: 'A Gemini API key is already stored securely.' } + : null + ) + } catch (error) { + if (!isMounted) return + setFeedback({ + variant: 'error', + message: error instanceof Error ? error.message : 'Failed to load API key settings', + }) + } finally { + if (isMounted) { + setIsLoading(false) + } + } + })() + + return () => { + isMounted = false + } + }, [client]) + + const handleSave = async () => { + const trimmed = value.trim() + if (!trimmed) { + setFeedback({ + variant: configured ? 'info' : 'error', + message: configured ? 'No changes to save.' : 'Enter a Gemini API key to continue.', + }) + return + } + + setIsSaving(true) + setFeedback(null) + + try { + const status = await new ApiKeysSettingsService(client).upsert(trimmed) + setConfigured(status.gemini.configured) + setValue('') + setFeedback({ variant: 'success', message: 'Gemini API key saved successfully.' }) + } catch (error) { + setFeedback({ + variant: 'error', + message: error instanceof Error ? error.message : 'Failed to save API key', + }) + } finally { + setIsSaving(false) + } + } + + return ( + } + title="API Keys" + subtitle="External model credentials and secure storage." + > + {isLoading ? ( + + + Loading API key status... + + ) : null} + + { + setValue(next) + if (feedback?.variant === 'error') { + setFeedback(null) + } + }} + autoCapitalize="none" + autoCorrect={false} + placeholder="AIzaSy..." + secureTextEntry + disabled={isLoading || isSaving} + /> + + {feedback ? : null} + + + + ) +} + +const createStyles = (colors: ReturnType['colors']) => + StyleSheet.create({ + loader: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + loaderText: { + color: colors.mutedForeground, + fontFamily: 'Inter_400Regular', + fontSize: 13, + }, + }) diff --git a/ui/mobile/components/settings/EnexExportPanel.tsx b/ui/mobile/components/settings/EnexExportPanel.tsx new file mode 100644 index 00000000000..ed50957aca7 --- /dev/null +++ b/ui/mobile/components/settings/EnexExportPanel.tsx @@ -0,0 +1,126 @@ +import { useMemo, useState } from 'react' +import { ActivityIndicator, StyleSheet, Text, View } from 'react-native' +import { Download } from 'lucide-react-native' +import * as Sharing from 'expo-sharing' + +import { Button } from '@ui/mobile/components/ui' +import { useSupabase, useTheme } from '@ui/mobile/providers' +import { MobileEnexExportService } from '@ui/mobile/services/enexExport' +import { SettingsPanelCard } from './SettingsPanelCard' +import { SettingsStatusMessage } from './SettingsStatusMessage' + +type FeedbackState = + | { variant: 'error' | 'success' | 'info'; message: string } + | null + +type ExportProgressState = + | { stage: 'loading'; loaded: number; total: number } + | { stage: 'building'; noteCount: number } + | { stage: 'writing'; noteCount: number; fileName: string } + | { stage: 'sharing' } + | null + +const getExportProgressLabel = (progress: ExportProgressState) => { + if (!progress) { + return 'Preparing your archive...' + } + + switch (progress.stage) { + case 'loading': + return `Loading notes ${progress.loaded} / ${progress.total}...` + case 'building': + return `Building archive for ${progress.noteCount} note(s)...` + case 'writing': + return `Saving ${progress.fileName}...` + case 'sharing': + return 'Opening share sheet...' + } +} + +export function EnexExportPanel() { + const { client, user } = useSupabase() + const { colors } = useTheme() + const styles = useMemo(() => createStyles(colors), [colors]) + const [isExporting, setIsExporting] = useState(false) + const [progress, setProgress] = useState(null) + const [feedback, setFeedback] = useState({ + variant: 'info', + message: 'Export all notes to a portable .enex archive and hand it off to the native share sheet.', + }) + + const handleExport = async () => { + if (!user) { + setFeedback({ variant: 'error', message: 'You need to be signed in to export notes.' }) + return + } + + setIsExporting(true) + setProgress({ stage: 'loading', loaded: 0, total: 0 }) + setFeedback(null) + + try { + const shareAvailable = await Sharing.isAvailableAsync() + if (!shareAvailable) { + throw new Error('Sharing is unavailable on this device') + } + + const result = await new MobileEnexExportService(client).exportAllNotes(user.id, (next) => { + setProgress(next) + }) + setProgress({ stage: 'sharing' }) + await Sharing.shareAsync(result.fileUri, { + mimeType: 'application/xml', + UTI: 'public.xml', + dialogTitle: 'Export .enex file', + }) + + setFeedback({ + variant: 'success', + message: `Exported ${result.noteCount} note(s) to ${result.fileName}.`, + }) + } catch (error) { + setFeedback({ + variant: 'error', + message: error instanceof Error ? error.message : 'Failed to export .enex file', + }) + } finally { + setIsExporting(false) + setProgress(null) + } + } + + return ( + } + title="Export .enex file" + subtitle="Download your notes as an archive." + > + {isExporting ? ( + + + {getExportProgressLabel(progress)} + + ) : null} + + {feedback ? : null} + + + + ) +} + +const createStyles = (colors: ReturnType['colors']) => + StyleSheet.create({ + loader: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + loaderText: { + color: colors.mutedForeground, + fontFamily: 'Inter_400Regular', + fontSize: 13, + }, + }) diff --git a/ui/mobile/components/settings/EnexImportPanel.tsx b/ui/mobile/components/settings/EnexImportPanel.tsx new file mode 100644 index 00000000000..e7d48021092 --- /dev/null +++ b/ui/mobile/components/settings/EnexImportPanel.tsx @@ -0,0 +1,276 @@ +import { useMemo, useState } from 'react' +import { ActivityIndicator, Platform, StyleSheet, Text, View } from 'react-native' +import { CheckCircle2, CheckSquare, Circle, Square, Upload } from 'lucide-react-native' +import * as DocumentPicker from 'expo-document-picker' + +import { + DEFAULT_IMPORT_SETTINGS, + DUPLICATE_STRATEGY_OPTIONS, + IMPORT_SETTINGS_COPY, +} from '@core/enex/import-shared' +import type { ImportSettings } from '@core/enex/types' +import { Button } from '@ui/mobile/components/ui' +import { useSupabase, useTheme } from '@ui/mobile/providers' +import { MobileEnexImportService } from '@ui/mobile/services/enexImport' +import { SettingsPanelCard } from './SettingsPanelCard' +import { SettingsStatusMessage } from './SettingsStatusMessage' + +type FeedbackState = + | { variant: 'error' | 'success' | 'info'; message: string } + | null + +type ImportProgressState = + | { stage: 'reading' } + | { stage: 'importing'; processed: number; total: number } + | null + +const enexPickerType = Platform.OS === 'android' ? '*/*' : ['application/xml', 'text/xml'] + +const isEnexFileName = (fileName: string) => /\.enex$/i.test(fileName.trim()) + +export function EnexImportPanel() { + const { client, user } = useSupabase() + const { colors } = useTheme() + const styles = useMemo(() => createStyles(colors), [colors]) + const [isImporting, setIsImporting] = useState(false) + const [selectedFileName, setSelectedFileName] = useState(null) + const [importSettings, setImportSettings] = useState(DEFAULT_IMPORT_SETTINGS) + const [progress, setProgress] = useState(null) + const [feedback, setFeedback] = useState({ + variant: 'info', + message: 'Choose an .enex export from your device to import notes into EverFreeNote.', + }) + + const handleImport = async () => { + if (!user) { + setFeedback({ variant: 'error', message: 'You need to be signed in to import notes.' }) + return + } + + try { + // Android document providers often do not expose `.enex` exports with a stable XML MIME type. + // Filtering by MIME there can show the file but ignore the tap, so we pick broadly and validate ourselves. + const result = await DocumentPicker.getDocumentAsync({ + type: enexPickerType, + copyToCacheDirectory: true, + multiple: false, + }) + + if (result.canceled || !result.assets?.length) { + return + } + + const asset = result.assets[0] + setSelectedFileName(asset.name) + + if (!isEnexFileName(asset.name)) { + setFeedback({ + variant: 'error', + message: 'Only Evernote .enex export files are supported.', + }) + return + } + + setIsImporting(true) + setProgress({ stage: 'reading' }) + setFeedback(null) + + const summary = await new MobileEnexImportService(client).importAsset( + asset, + user.id, + importSettings, + (next) => { + setProgress({ + stage: 'importing', + processed: next.processed, + total: next.total, + }) + } + ) + setFeedback({ variant: summary.errors > 0 ? 'error' : 'success', message: summary.message }) + } catch (error) { + setFeedback({ + variant: 'error', + message: error instanceof Error ? error.message : 'Failed to import .enex file', + }) + } finally { + setIsImporting(false) + setProgress(null) + } + } + + return ( + } + title="Import .enex file" + subtitle="Bring notes in from Evernote exports." + > + + Selected file + {selectedFileName ?? 'No file selected yet'} + + + + {IMPORT_SETTINGS_COPY.title} + + + {IMPORT_SETTINGS_COPY.duplicateQuestion} + + {DUPLICATE_STRATEGY_OPTIONS.map((option) => { + const checked = importSettings.duplicateStrategy === option.value + + return ( + + ) + })} + + + + + + + {isImporting ? ( + + + + {progress?.stage === 'importing' + ? `Importing notes ${progress.processed} / ${progress.total}...` + : 'Reading .enex file...'} + + + ) : null} + + {feedback ? : null} + + + + ) +} + +const createStyles = (colors: ReturnType['colors']) => + StyleSheet.create({ + meta: { + borderRadius: 16, + borderWidth: 1, + borderColor: colors.border, + backgroundColor: colors.background, + padding: 14, + gap: 6, + }, + metaLabel: { + color: colors.mutedForeground, + fontFamily: 'Inter_600SemiBold', + fontSize: 12, + letterSpacing: 1.1, + }, + metaValue: { + color: colors.foreground, + fontFamily: 'Inter_500Medium', + fontSize: 14, + }, + settingsGroup: { + gap: 10, + }, + settingsTitle: { + color: colors.foreground, + fontFamily: 'Inter_600SemiBold', + fontSize: 14, + }, + settingsSection: { + gap: 8, + }, + settingsQuestion: { + color: colors.mutedForeground, + fontFamily: 'Inter_400Regular', + fontSize: 13, + lineHeight: 18, + }, + optionsList: { + gap: 8, + }, + optionButton: { + width: '100%', + borderRadius: 14, + borderWidth: 1, + borderColor: colors.border, + backgroundColor: colors.background, + justifyContent: 'flex-start', + paddingHorizontal: 12, + paddingVertical: 12, + }, + optionButtonText: { + textAlign: 'left', + }, + optionContent: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + width: '100%', + flexShrink: 1, + }, + optionLabel: { + color: colors.foreground, + fontFamily: 'Inter_400Regular', + fontSize: 14, + flexShrink: 1, + }, + loader: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + loaderText: { + color: colors.mutedForeground, + fontFamily: 'Inter_400Regular', + fontSize: 13, + }, + }) diff --git a/ui/mobile/components/settings/SettingsPanelCard.tsx b/ui/mobile/components/settings/SettingsPanelCard.tsx new file mode 100644 index 00000000000..14f1f395267 --- /dev/null +++ b/ui/mobile/components/settings/SettingsPanelCard.tsx @@ -0,0 +1,86 @@ +import type { ReactNode } from 'react' +import { StyleSheet, Text, View } from 'react-native' +import { useMemo } from 'react' + +import { useTheme } from '@ui/mobile/providers' + +type SettingsPanelCardProps = Readonly<{ + icon: ReactNode + title: string + subtitle: string + children: ReactNode +}> + +export function SettingsPanelCard({ + icon, + title, + subtitle, + children, +}: SettingsPanelCardProps) { + const { colors } = useTheme() + const styles = useMemo(() => createStyles(colors), [colors]) + + return ( + + + {icon} + + {title} + {subtitle} + + + + {children} + + ) +} + +const createStyles = (colors: ReturnType['colors']) => + StyleSheet.create({ + card: { + backgroundColor: colors.background, + borderRadius: 24, + borderWidth: 1, + borderColor: colors.border, + padding: 16, + gap: 16, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + }, + iconWrap: { + width: 44, + height: 44, + borderRadius: 14, + borderWidth: 1, + borderColor: colors.border, + backgroundColor: colors.card, + alignItems: 'center', + justifyContent: 'center', + }, + copy: { + flex: 1, + gap: 2, + }, + title: { + color: colors.foreground, + fontFamily: 'Inter_700Bold', + fontSize: 16, + }, + subtitle: { + color: colors.mutedForeground, + fontFamily: 'Inter_400Regular', + fontSize: 14, + lineHeight: 20, + }, + body: { + backgroundColor: colors.card, + borderRadius: 20, + borderWidth: 1, + borderColor: colors.border, + padding: 14, + gap: 14, + }, + }) diff --git a/ui/mobile/components/settings/SettingsStatusMessage.tsx b/ui/mobile/components/settings/SettingsStatusMessage.tsx new file mode 100644 index 00000000000..e80eef44f63 --- /dev/null +++ b/ui/mobile/components/settings/SettingsStatusMessage.tsx @@ -0,0 +1,75 @@ +import { StyleSheet, Text, View } from 'react-native' +import { useMemo } from 'react' +import { CircleAlert, CircleCheckBig, Info } from 'lucide-react-native' + +import { useTheme } from '@ui/mobile/providers' + +type SettingsStatusMessageProps = Readonly<{ + message: string + variant: 'error' | 'success' | 'info' +}> + +export function SettingsStatusMessage({ message, variant }: SettingsStatusMessageProps) { + const { colors } = useTheme() + const styles = useMemo(() => createStyles(colors), [colors]) + + const palette = { + error: { + backgroundColor: colors.destructive + '22', + borderColor: colors.destructive + '55', + color: colors.destructive, + icon: CircleAlert, + }, + success: { + backgroundColor: colors.primary + '22', + borderColor: colors.primary + '55', + color: colors.primary, + icon: CircleCheckBig, + }, + info: { + backgroundColor: colors.secondary, + borderColor: colors.border, + color: colors.foreground, + icon: Info, + }, + }[variant] + + const Icon = palette.icon + + return ( + + + {message} + + ) +} + +const createStyles = (colors: ReturnType['colors']) => + StyleSheet.create({ + container: { + borderRadius: 12, + borderWidth: 1, + paddingHorizontal: 12, + paddingVertical: 10, + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + message: { + flex: 1, + fontFamily: 'Inter_500Medium', + fontSize: 13, + lineHeight: 18, + color: colors.foreground, + }, + }) diff --git a/ui/mobile/components/settings/SettingsTabBar.tsx b/ui/mobile/components/settings/SettingsTabBar.tsx new file mode 100644 index 00000000000..5d47dc9c082 --- /dev/null +++ b/ui/mobile/components/settings/SettingsTabBar.tsx @@ -0,0 +1,90 @@ +import { ScrollView, StyleSheet, Text, Pressable, View } from 'react-native' +import { useMemo } from 'react' + +import { useTheme } from '@ui/mobile/providers' + +export type SettingsTabKey = 'account' | 'import' | 'export' | 'wordpress' | 'apiKeys' + +export type SettingsTabDefinition = Readonly<{ + key: SettingsTabKey + label: string +}> + +type SettingsTabBarProps = Readonly<{ + tabs: SettingsTabDefinition[] + activeTab: SettingsTabKey + onChange: (tab: SettingsTabKey) => void +}> + +export function SettingsTabBar({ tabs, activeTab, onChange }: SettingsTabBarProps) { + const { colors } = useTheme() + const styles = useMemo(() => createStyles(colors), [colors]) + + return ( + + + {tabs.map((tab) => { + const isActive = tab.key === activeTab + + return ( + onChange(tab.key)} + style={({ pressed }) => [ + styles.tab, + isActive && styles.tabActive, + pressed && styles.tabPressed, + ]} + > + {tab.label} + + ) + })} + + + ) +} + +const createStyles = (colors: ReturnType['colors']) => + StyleSheet.create({ + wrap: { + paddingBottom: 4, + }, + content: { + gap: 10, + paddingRight: 8, + }, + tab: { + minHeight: 42, + paddingHorizontal: 16, + paddingVertical: 10, + borderRadius: 14, + backgroundColor: colors.card, + borderWidth: 1, + borderColor: colors.border, + justifyContent: 'center', + }, + tabActive: { + backgroundColor: colors.foreground, + borderColor: colors.foreground, + }, + tabPressed: { + opacity: 0.85, + }, + label: { + color: colors.mutedForeground, + fontFamily: 'Inter_600SemiBold', + fontSize: 14, + }, + labelActive: { + color: colors.background, + }, + }) diff --git a/ui/mobile/components/settings/WordPressSettingsPanel.tsx b/ui/mobile/components/settings/WordPressSettingsPanel.tsx new file mode 100644 index 00000000000..13518858860 --- /dev/null +++ b/ui/mobile/components/settings/WordPressSettingsPanel.tsx @@ -0,0 +1,237 @@ +import { useEffect, useMemo, useState } from 'react' +import { ActivityIndicator, Pressable, StyleSheet, Text, View } from 'react-native' +import { CheckSquare, Globe, Square } from 'lucide-react-native' + +import { WordPressSettingsService } from '@core/services/wordpressSettings' +import { Button, Input } from '@ui/mobile/components/ui' +import { useSupabase, useTheme } from '@ui/mobile/providers' +import { SettingsPanelCard } from './SettingsPanelCard' +import { SettingsStatusMessage } from './SettingsStatusMessage' + +type FeedbackState = + | { variant: 'error' | 'success' | 'info'; message: string } + | null + +const normalizeSiteUrl = (value: string) => value.trim().replace(/\/+$/, '') + +export function WordPressSettingsPanel() { + const { client } = useSupabase() + const { colors } = useTheme() + const styles = useMemo(() => createStyles(colors), [colors]) + const [siteUrl, setSiteUrl] = useState('') + const [wpUsername, setWpUsername] = useState('') + const [applicationPassword, setApplicationPassword] = useState('') + const [enabled, setEnabled] = useState(true) + const [hasPassword, setHasPassword] = useState(false) + const [isLoading, setIsLoading] = useState(true) + const [isSaving, setIsSaving] = useState(false) + const [feedback, setFeedback] = useState(null) + const isBusy = isLoading || isSaving + + useEffect(() => { + let isMounted = true + const service = new WordPressSettingsService(client) + + void (async () => { + try { + const status = await service.getStatus() + if (!isMounted) return + + setSiteUrl(status.integration?.siteUrl ?? '') + setWpUsername(status.integration?.wpUsername ?? '') + setEnabled(status.integration?.enabled ?? true) + setHasPassword(status.integration?.hasPassword ?? false) + setFeedback( + status.integration + ? { + variant: 'info', + message: status.integration.hasPassword + ? 'Saved WordPress credentials are available.' + : 'Enter an application password to complete setup.', + } + : null + ) + } catch (error) { + if (!isMounted) return + setFeedback({ + variant: 'error', + message: error instanceof Error ? error.message : 'Failed to load WordPress settings', + }) + } finally { + if (isMounted) { + setIsLoading(false) + } + } + })() + + return () => { + isMounted = false + } + }, [client]) + + const handleSave = async () => { + const normalizedUrl = normalizeSiteUrl(siteUrl) + const trimmedUsername = wpUsername.trim() + const trimmedPassword = applicationPassword.trim() + + if (!normalizedUrl) { + setFeedback({ variant: 'error', message: 'Site URL is required.' }) + return + } + + if (!trimmedUsername) { + setFeedback({ variant: 'error', message: 'WordPress username is required.' }) + return + } + + if (!hasPassword && !trimmedPassword) { + setFeedback({ + variant: 'error', + message: 'Application password is required for the initial setup.', + }) + return + } + + setIsSaving(true) + setFeedback(null) + + try { + const status = await new WordPressSettingsService(client).upsert({ + siteUrl: normalizedUrl, + wpUsername: trimmedUsername, + applicationPassword: trimmedPassword || undefined, + enabled, + }) + + setSiteUrl(status.integration?.siteUrl ?? normalizedUrl) + setWpUsername(status.integration?.wpUsername ?? trimmedUsername) + setEnabled(status.integration?.enabled ?? enabled) + setHasPassword(status.integration?.hasPassword ?? (hasPassword || Boolean(trimmedPassword))) + setApplicationPassword('') + setFeedback({ variant: 'success', message: 'WordPress settings saved successfully.' }) + } catch (error) { + setFeedback({ + variant: 'error', + message: error instanceof Error ? error.message : 'Failed to save WordPress settings', + }) + } finally { + setIsSaving(false) + } + } + + return ( + } + title="WordPress settings" + subtitle="Site URL, account, and publishing access." + > + {isLoading ? ( + + + Loading WordPress settings... + + ) : null} + + + + + + + + setEnabled((value) => !value)} + style={({ pressed }) => [styles.checkboxRow, pressed && styles.checkboxRowPressed]} + > + {enabled ? ( + + ) : ( + + )} + + Enable WordPress publishing + + When enabled, your saved credentials can be used for publishing actions. + + + + + {feedback ? : null} + + + + ) +} + +const createStyles = (colors: ReturnType['colors']) => + StyleSheet.create({ + loader: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + loaderText: { + color: colors.mutedForeground, + fontFamily: 'Inter_400Regular', + fontSize: 13, + }, + checkboxRow: { + borderRadius: 16, + borderWidth: 1, + borderColor: colors.border, + backgroundColor: colors.background, + padding: 14, + flexDirection: 'row', + alignItems: 'flex-start', + gap: 10, + }, + checkboxRowPressed: { + opacity: 0.85, + }, + checkboxCopy: { + flex: 1, + gap: 4, + }, + checkboxTitle: { + color: colors.foreground, + fontFamily: 'Inter_600SemiBold', + fontSize: 14, + }, + checkboxText: { + color: colors.mutedForeground, + fontFamily: 'Inter_400Regular', + fontSize: 13, + lineHeight: 20, + }, + }) diff --git a/ui/mobile/components/ui/Button.tsx b/ui/mobile/components/ui/Button.tsx index 4a7edf1cf21..0671cd5ddde 100644 --- a/ui/mobile/components/ui/Button.tsx +++ b/ui/mobile/components/ui/Button.tsx @@ -92,6 +92,7 @@ export function Button({ return ( [ { diff --git a/ui/mobile/components/ui/Input.tsx b/ui/mobile/components/ui/Input.tsx index 16fc589ee67..df216cc017b 100644 --- a/ui/mobile/components/ui/Input.tsx +++ b/ui/mobile/components/ui/Input.tsx @@ -12,12 +12,14 @@ interface InputProps extends TextInputProps { label?: string error?: string containerStyle?: ViewStyle + disabled?: boolean } export const Input = forwardRef( - ({ label, error, containerStyle, style, ...props }, ref) => { + ({ label, error, containerStyle, style, disabled, editable, ...props }, ref) => { const { colors } = useTheme() const hasError = Boolean(error) + const isEditable = disabled ? false : editable return ( @@ -38,18 +40,23 @@ export const Input = forwardRef( placeholderTextColor={colors.mutedForeground} style={[ { - height: 40, + minHeight: 48, borderWidth: 1, borderColor: hasError ? colors.destructive : colors.border, borderRadius: 8, paddingHorizontal: 12, + paddingVertical: 10, fontSize: 14, + lineHeight: 20, fontFamily: 'Inter_400Regular', color: colors.foreground, backgroundColor: colors.background, + textAlignVertical: 'center', + opacity: isEditable === false ? 0.65 : 1, }, style, ]} + editable={isEditable} {...props} /> {error && ( diff --git a/ui/mobile/lib/enexMobile.ts b/ui/mobile/lib/enexMobile.ts new file mode 100644 index 00000000000..69155f2742a --- /dev/null +++ b/ui/mobile/lib/enexMobile.ts @@ -0,0 +1,115 @@ +import type { ParsedNote } from '@core/enex/types' +import type { Tables } from '@/supabase/types' +import type { ExportNote } from '@core/enex/export-types' +import { normalizeNoteTitle } from '@core/enex/import-shared' +import { XMLParser } from 'fast-xml-parser' + +type NoteRow = Tables<'notes'> + +type ParsedEnexDocument = { + 'en-export'?: { + note?: ParsedEnexNote | ParsedEnexNote[] + } +} + +type ParsedEnexNote = { + title?: string + content?: string | { '#text'?: string } + created?: string + updated?: string + tag?: string | string[] +} + +const parser = new XMLParser({ + ignoreAttributes: false, + parseTagValue: false, + trimValues: false, +}) +const evernoteDatePattern = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/ +const enNoteContentPattern = /]*>([\s\S]*)<\/en-note>/i +const enMediaSelfClosingPattern = /]*\/>/gi +const enMediaOpenClosePattern = /]*>[\s\S]*?<\/en-media>/gi + +const asArray = (value: T | T[] | undefined): T[] => { + if (!value) return [] + return Array.isArray(value) ? value : [value] +} + +const readParsedNoteContent = (content: ParsedEnexNote['content']): string => { + if (typeof content === 'string') { + return content + } + + if (typeof content?.['#text'] === 'string') { + return content['#text'] + } + + return '' +} + +export const parseEnexDate = (value?: string | null): Date => { + if (!value) return new Date() + + const trimmed = value.trim() + if (!trimmed) return new Date() + + const match = evernoteDatePattern.exec(trimmed) + if (!match) { + const parsed = new Date(trimmed) + return Number.isNaN(parsed.getTime()) ? new Date() : parsed + } + + const [, year, month, day, hour, minute, second] = match + return new Date(`${year}-${month}-${day}T${hour}:${minute}:${second}Z`) +} + +export const extractEnexContent = (value: string): string => { + const match = enNoteContentPattern.exec(value) + const content = match ? match[1] : value + + return content + .replaceAll(enMediaSelfClosingPattern, '

[Imported media placeholder]

') + .replaceAll(enMediaOpenClosePattern, '

[Imported media placeholder]

') + .trim() +} + +export const parseEnexXml = (xml: string): ParsedNote[] => { + const parsed = parser.parse(xml) as ParsedEnexDocument + const notes = asArray(parsed['en-export']?.note) + + return notes.map((note) => { + const rawContent = readParsedNoteContent(note.content) + + return { + title: normalizeNoteTitle(note.title), + content: extractEnexContent(rawContent), + created: parseEnexDate(note.created), + updated: parseEnexDate(note.updated ?? note.created), + tags: asArray(note.tag) + .map((tag) => tag.trim()) + .filter(Boolean), + resources: [], + } + }) +} + +export const buildEnexExportFileName = (date: Date = new Date()) => { + const year = date.getUTCFullYear() + const month = String(date.getUTCMonth() + 1).padStart(2, '0') + const day = String(date.getUTCDate()).padStart(2, '0') + const hours = String(date.getUTCHours()).padStart(2, '0') + const minutes = String(date.getUTCMinutes()).padStart(2, '0') + const seconds = String(date.getUTCSeconds()).padStart(2, '0') + + return `everfreenote-export-${year}${month}${day}-${hours}${minutes}${seconds}.enex` +} + +export const toEnexExportNotes = (notes: NoteRow[]): ExportNote[] => + notes.map((note) => ({ + title: note.title ?? 'Untitled', + content: note.description ?? '', + created: new Date(note.created_at), + updated: new Date(note.updated_at ?? note.created_at), + tags: note.tags ?? [], + resources: [], + })) diff --git a/ui/mobile/package-lock.json b/ui/mobile/package-lock.json index f9b7ae09540..ae148152dc5 100644 --- a/ui/mobile/package-lock.json +++ b/ui/mobile/package-lock.json @@ -19,16 +19,20 @@ "expo": "~54.0.29", "expo-asset": "~12.0.12", "expo-constants": "~18.0.12", + "expo-document-picker": "~14.0.8", + "expo-file-system": "~19.0.21", "expo-font": "^14.0.10", "expo-haptics": "~15.0.8", "expo-linking": "^8.0.10", "expo-router": "^6.0.19", "expo-secure-store": "^15.0.8", + "expo-sharing": "~14.0.8", "expo-splash-screen": "~31.0.13", "expo-sqlite": "^16.0.10", "expo-status-bar": "~3.0.9", "expo-system-ui": "^6.0.9", "expo-web-browser": "^15.0.10", + "fast-xml-parser": "^5.5.5", "lucide-react-native": "^0.561.0", "nativewind": "^4.2.1", "react": "19.1.0", @@ -6554,6 +6558,25 @@ "react-native": "*" } }, + "node_modules/expo-document-picker": { + "version": "14.0.8", + "resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-14.0.8.tgz", + "integrity": "sha512-3tyQKpPqWWFlI8p9RiMX1+T1Zge5mEKeBuXWp1h8PEItFMUDSiOJbQ112sfdC6Hxt8wSxreV9bCRl/NgBdt+fA==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-file-system": { + "version": "19.0.21", + "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.21.tgz", + "integrity": "sha512-s3DlrDdiscBHtab/6W1osrjGL+C2bvoInPJD7sOwmxfJ5Woynv2oc+Fz1/xVXaE/V7HE/+xrHC/H45tu6lZzzg==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, "node_modules/expo-font": { "version": "14.0.10", "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-14.0.10.tgz", @@ -6891,6 +6914,15 @@ "node": ">=20.16.0" } }, + "node_modules/expo-sharing": { + "version": "14.0.8", + "resolved": "https://registry.npmjs.org/expo-sharing/-/expo-sharing-14.0.8.tgz", + "integrity": "sha512-A1pPr2iBrxypFDCWVAESk532HK+db7MFXbvO2sCV9ienaFXAk7lIBm6bkqgE6vzRd9O3RGdEGzYx80cYlc089Q==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-splash-screen": { "version": "31.0.13", "resolved": "https://registry.npmjs.org/expo-splash-screen/-/expo-splash-screen-31.0.13.tgz", @@ -7133,16 +7165,6 @@ "node": ">=8" } }, - "node_modules/expo/node_modules/expo-file-system": { - "version": "19.0.21", - "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.21.tgz", - "integrity": "sha512-s3DlrDdiscBHtab/6W1osrjGL+C2bvoInPJD7sOwmxfJ5Woynv2oc+Fz1/xVXaE/V7HE/+xrHC/H45tu6lZzzg==", - "license": "MIT", - "peerDependencies": { - "expo": "*", - "react-native": "*" - } - }, "node_modules/expo/node_modules/expo-keep-awake": { "version": "15.0.8", "resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-15.0.8.tgz", @@ -7490,6 +7512,41 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-xml-builder": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.3.tgz", + "integrity": "sha512-1o60KoFw2+LWKQu3IdcfcFlGTW4dpqEWmjhYec6H82AYZU2TVBXep6tMl8Z1Y+wM+ZrzCwe3BZ9Vyd9N2rIvmg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.1.3" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.5.5", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.5.tgz", + "integrity": "sha512-NLY+V5NNbdmiEszx9n14mZBseJTC50bRq1VHsaxOmR72JDuZt+5J1Co+dC/4JPnyq+WrIHNM69r0sqf7BMb3Mg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "fast-xml-builder": "^1.1.3", + "path-expression-matcher": "^1.1.3", + "strnum": "^2.1.2" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/fastq": { "version": "1.19.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", @@ -11333,6 +11390,21 @@ "node": ">=8" } }, + "node_modules/path-expression-matcher": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.1.3.tgz", + "integrity": "sha512-qdVgY8KXmVdJZRSS1JdEPOKPdTiEK/pi0RkcT2sw1RhXxohdujUlJFPuS1TSkevZ9vzd3ZlL7ULl1MHGTApKzQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -13315,6 +13387,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strnum": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.0.tgz", + "integrity": "sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/structured-headers": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/structured-headers/-/structured-headers-0.4.1.tgz", diff --git a/ui/mobile/package.json b/ui/mobile/package.json index 38aa5c40608..04f1b57cd3a 100644 --- a/ui/mobile/package.json +++ b/ui/mobile/package.json @@ -18,8 +18,8 @@ "android:stage:release": "cross-env APP_VARIANT=stage EXPO_PUBLIC_APP_VARIANT=stage expo run:android --variant stageRelease --app-id com.everfreenote.app.stage", "android:prod": "cross-env APP_VARIANT=prod EXPO_PUBLIC_APP_VARIANT=prod expo run:android --variant prodDebug --app-id com.everfreenote.app", "android:prod:release": "cross-env APP_VARIANT=prod EXPO_PUBLIC_APP_VARIANT=prod expo run:android --variant prodRelease --app-id com.everfreenote.app", - "adb:connect": "adb connect 192.168.0.17:37779", - "adb:pair": "adb pair 192.168.0.17:45619", + "adb:connect": "adb connect 192.168.0.17:41393", + "adb:pair": "adb pair 192.168.0.17:37753", "adb:kill": "adb kill-server", "adb:start": "adb start-server", "adb:restart": "adb kill-server && adb start-server", @@ -42,16 +42,20 @@ "expo": "~54.0.29", "expo-asset": "~12.0.12", "expo-constants": "~18.0.12", + "expo-document-picker": "~14.0.8", + "expo-file-system": "~19.0.21", "expo-font": "^14.0.10", "expo-haptics": "~15.0.8", "expo-linking": "^8.0.10", "expo-router": "^6.0.19", "expo-secure-store": "^15.0.8", + "expo-sharing": "~14.0.8", "expo-splash-screen": "~31.0.13", "expo-sqlite": "^16.0.10", "expo-status-bar": "~3.0.9", "expo-system-ui": "^6.0.9", "expo-web-browser": "^15.0.10", + "fast-xml-parser": "^5.5.5", "lucide-react-native": "^0.561.0", "nativewind": "^4.2.1", "react": "19.1.0", @@ -83,4 +87,4 @@ "typescript": "~5.9.2" }, "private": true -} \ No newline at end of file +} diff --git a/ui/mobile/services/enexExport.ts b/ui/mobile/services/enexExport.ts new file mode 100644 index 00000000000..e7bf381afe5 --- /dev/null +++ b/ui/mobile/services/enexExport.ts @@ -0,0 +1,69 @@ +import * as FileSystem from 'expo-file-system/legacy' +import type { SupabaseClient } from '@supabase/supabase-js' + +import type { Database, Tables } from '@/supabase/types' +import { EnexBuilder } from '@core/enex/enex-builder' +import { NoteService } from '@core/services/notes' +import { buildEnexExportFileName, toEnexExportNotes } from '@ui/mobile/lib/enexMobile' + +type NoteRow = Tables<'notes'> + +export type ExportProgress = + | { stage: 'loading'; loaded: number; total: number } + | { stage: 'building'; noteCount: number } + | { stage: 'writing'; noteCount: number; fileName: string } + +export class MobileEnexExportService { + private readonly noteService: NoteService + private readonly enexBuilder: EnexBuilder + + constructor(supabase: SupabaseClient) { + this.noteService = new NoteService(supabase) + this.enexBuilder = new EnexBuilder() + } + + async exportAllNotes( + userId: string, + onProgress?: (progress: ExportProgress) => void + ): Promise<{ fileUri: string; fileName: string; noteCount: number }> { + const allNotes: NoteRow[] = [] + const pageSize = 200 + let page = 0 + let totalCount = 0 + + while (true) { + const result = await this.noteService.getNotes(userId, { page, pageSize }) + totalCount = result.totalCount + allNotes.push(...result.notes) + onProgress?.({ stage: 'loading', loaded: allNotes.length, total: totalCount }) + + if (!result.hasMore) { + break + } + + page += 1 + } + + const fileName = buildEnexExportFileName() + const baseDirectory = FileSystem.documentDirectory ?? FileSystem.cacheDirectory + + if (!baseDirectory) { + throw new Error('Export directory is unavailable on this device') + } + + const fileUri = `${baseDirectory}${fileName}` + onProgress?.({ stage: 'building', noteCount: allNotes.length }) + const xml = this.enexBuilder.build(toEnexExportNotes(allNotes)) + + onProgress?.({ stage: 'writing', noteCount: allNotes.length, fileName }) + await FileSystem.writeAsStringAsync(fileUri, xml, { + encoding: FileSystem.EncodingType.UTF8, + }) + + return { + fileUri, + fileName, + noteCount: allNotes.length, + } + } +} diff --git a/ui/mobile/services/enexImport.ts b/ui/mobile/services/enexImport.ts new file mode 100644 index 00000000000..356cbc9ed63 --- /dev/null +++ b/ui/mobile/services/enexImport.ts @@ -0,0 +1,123 @@ +import type { DocumentPickerAsset } from 'expo-document-picker' +import * as FileSystem from 'expo-file-system/legacy' +import type { SupabaseClient } from '@supabase/supabase-js' + +import type { Database } from '@/supabase/types' +import { + DEFAULT_IMPORT_SETTINGS, + resolveExistingTitlesForImport, +} from '@core/enex/import-shared' +import { NoteCreator } from '@core/enex/note-creator' +import type { ImportResult, ImportSettings } from '@core/enex/types' +import { parseEnexXml } from '@ui/mobile/lib/enexMobile' + +export const MAX_ENEX_SIZE_BYTES = 100 * 1024 * 1024 + +export type ImportProgress = { + processed: number + total: number +} + +export class MobileEnexImportService { + private readonly supabase: SupabaseClient + private readonly noteCreator: NoteCreator + + constructor(supabase: SupabaseClient) { + this.supabase = supabase + this.noteCreator = new NoteCreator(supabase) + } + + async importAsset( + asset: DocumentPickerAsset, + userId: string, + settings: ImportSettings = DEFAULT_IMPORT_SETTINGS, + onProgress?: (progress: ImportProgress) => void + ): Promise { + if (typeof asset.size === 'number' && asset.size > MAX_ENEX_SIZE_BYTES) { + throw new Error( + `Selected .enex file is too large to import. Maximum supported size is ${Math.round( + MAX_ENEX_SIZE_BYTES / 1024 / 1024 + )} MB.` + ) + } + + const xml = await FileSystem.readAsStringAsync(asset.uri, { + encoding: FileSystem.EncodingType.UTF8, + }) + + return this.importXml(xml, userId, settings, onProgress) + } + + async importXml( + xml: string, + userId: string, + settings: ImportSettings = DEFAULT_IMPORT_SETTINGS, + onProgress?: (progress: ImportProgress) => void + ): Promise { + const parsedNotes = parseEnexXml(xml) + + if (parsedNotes.length === 0) { + throw new Error('No importable notes were found in the selected .enex file') + } + + const existingByTitle = await resolveExistingTitlesForImport( + this.supabase, + userId, + settings.duplicateStrategy + ) + const fallbackExistingByTitle = new Map() + const seenTitlesInImport = new Set() + + onProgress?.({ processed: 0, total: parsedNotes.length }) + + let success = 0 + let skipped = 0 + let errors = 0 + const failedNotes: ImportResult['failedNotes'] = [] + + for (const note of parsedNotes) { + try { + const created = await this.noteCreator.create( + note, + userId, + settings.duplicateStrategy, + { + skipFileDuplicates: settings.skipFileDuplicates, + existingByTitle, + fallbackExistingByTitle, + seenTitlesInImport, + } + ) + + if (created) { + success += 1 + } else { + skipped += 1 + } + } catch (error) { + errors += 1 + failedNotes.push({ + title: note.title, + error: error instanceof Error ? error.message : 'Failed to import note', + }) + } finally { + onProgress?.({ processed: success + skipped + errors, total: parsedNotes.length }) + } + } + + const messageParts = [`Imported ${success} note(s)`] + if (skipped > 0) { + messageParts.push(`skipped ${skipped} duplicate note(s)`) + } + if (errors > 0) { + messageParts.push(`with ${errors} error(s)`) + } + + return { + success, + errors, + failedNotes, + message: `${messageParts.join(', ')}.`, + } + } +} diff --git a/ui/mobile/tests/integration/settingsScreen.test.tsx b/ui/mobile/tests/integration/settingsScreen.test.tsx index 3867636ecb9..a5fc41f795d 100644 --- a/ui/mobile/tests/integration/settingsScreen.test.tsx +++ b/ui/mobile/tests/integration/settingsScreen.test.tsx @@ -1,37 +1,150 @@ import React from 'react' -import { fireEvent, screen, waitFor } from '@testing-library/react-native' +import { fireEvent, render, screen, waitFor } from '@testing-library/react-native' import AsyncStorage from '@react-native-async-storage/async-storage' -import { renderWithProviders } from '../utils/renderWithProviders' +import { Platform } from 'react-native' +import { SafeAreaProvider } from 'react-native-safe-area-context' + +import { ThemeProvider } from '@ui/mobile/providers/ThemeProvider' + +const mockSignOut = jest.fn() +const mockDeleteAccount = jest.fn() +const mockUseSupabase = jest.fn() +const mockUseAuth = jest.fn() +const mockApiKeysGetStatus = jest.fn() +const mockApiKeysUpsert = jest.fn() +const mockWordPressGetStatus = jest.fn() +const mockWordPressUpsert = jest.fn() +const mockImportAsset = jest.fn() +const mockExportAllNotes = jest.fn() +const mockGetDocumentAsync = jest.fn() +const mockIsAvailableAsync = jest.fn() +const mockShareAsync = jest.fn() + +jest.mock('expo-document-picker', () => ({ + getDocumentAsync: (...args: unknown[]) => mockGetDocumentAsync(...args), +})) + +jest.mock('expo-sharing', () => ({ + isAvailableAsync: (...args: unknown[]) => mockIsAvailableAsync(...args), + shareAsync: (...args: unknown[]) => mockShareAsync(...args), +})) + +jest.mock('@core/services/apiKeysSettings', () => ({ + ApiKeysSettingsService: jest.fn().mockImplementation(() => ({ + getStatus: mockApiKeysGetStatus, + upsert: mockApiKeysUpsert, + })), +})) + +jest.mock('@core/services/wordpressSettings', () => ({ + WordPressSettingsService: jest.fn().mockImplementation(() => ({ + getStatus: mockWordPressGetStatus, + upsert: mockWordPressUpsert, + })), +})) + +jest.mock('@ui/mobile/services/enexImport', () => ({ + MobileEnexImportService: jest.fn().mockImplementation(() => ({ + importAsset: mockImportAsset, + })), +})) + +jest.mock('@ui/mobile/services/enexExport', () => ({ + MobileEnexExportService: jest.fn().mockImplementation(() => ({ + exportAllNotes: mockExportAllNotes, + })), +})) -// Мок должен быть объявлен до импорта SettingsScreen (jest hoisting) jest.mock('@ui/mobile/providers/SupabaseProvider', () => ({ - useSupabase: () => ({ - client: {}, - user: null, - session: null, - loading: false, - signOut: jest.fn(), - }), - useAuth: () => ({ - user: null, - session: null, - loading: false, - isAuthenticated: false, - signOut: jest.fn(), - }), + useSupabase: () => mockUseSupabase(), + useAuth: () => mockUseAuth(), SupabaseProvider: ({ children }: { children: React.ReactNode }) => children, })) import SettingsScreen from '@ui/mobile/app/(tabs)/settings' +const renderScreen = () => + render( + + + + + + ) + describe('SettingsScreen', () => { - it('updates the current mode when an option is selected', async () => { + const expectedPickerType = Platform.OS === 'android' ? '*/*' : ['application/xml', 'text/xml'] + + beforeEach(() => { + jest.clearAllMocks() + const mockAsyncStorage = AsyncStorage as jest.Mocked - mockAsyncStorage.getItem.mockResolvedValueOnce('light') + mockAsyncStorage.getItem.mockResolvedValue('light') + + mockUseSupabase.mockReturnValue({ + client: {}, + user: { id: 'user-1', email: 'test@example.com' }, + session: null, + loading: false, + signOut: mockSignOut, + deleteAccount: mockDeleteAccount, + }) - renderWithProviders() + mockUseAuth.mockReturnValue({ + user: { id: 'user-1', email: 'test@example.com' }, + session: null, + loading: false, + isAuthenticated: true, + signOut: mockSignOut, + deleteAccount: mockDeleteAccount, + }) + + mockApiKeysGetStatus.mockResolvedValue({ gemini: { configured: false } }) + mockApiKeysUpsert.mockResolvedValue({ gemini: { configured: true } }) + mockWordPressGetStatus.mockResolvedValue({ + configured: false, + integration: null, + }) + mockWordPressUpsert.mockResolvedValue({ + configured: true, + integration: { + siteUrl: 'https://example.com', + wpUsername: 'editor-user', + enabled: true, + hasPassword: true, + }, + }) + mockImportAsset.mockResolvedValue({ + success: 2, + errors: 0, + failedNotes: [], + message: 'Imported 2 note(s).', + }) + mockExportAllNotes.mockResolvedValue({ + fileUri: 'file:///tmp/everfreenote-export-20260314-083000.enex', + fileName: 'everfreenote-export-20260314-083000.enex', + noteCount: 3, + }) + mockGetDocumentAsync.mockResolvedValue({ + canceled: false, + assets: [ + { + uri: 'file:///tmp/import.enex', + name: 'import.enex', + lastModified: 0, + }, + ], + }) + mockIsAvailableAsync.mockResolvedValue(true) + mockShareAsync.mockResolvedValue(undefined) + }) + + it('switches tabs and updates theme inside My Account', async () => { + renderScreen() await waitFor(() => { + expect(screen.getByText('Settings')).toBeTruthy() + expect(screen.getByText('test@example.com')).toBeTruthy() expect(screen.getByText('Current: light (light)')).toBeTruthy() }) @@ -40,5 +153,308 @@ describe('SettingsScreen', () => { await waitFor(() => { expect(screen.getByText('Current: dark (dark)')).toBeTruthy() }) + + fireEvent.press(screen.getByRole('tab', { name: 'API Keys' })) + + await waitFor(() => { + expect(screen.getByText('External model credentials and secure storage.')).toBeTruthy() + }) + + fireEvent.changeText(screen.getByPlaceholderText('AIzaSy...'), 'AIza-test') + fireEvent.press(screen.getByRole('tab', { name: 'My Account' })) + fireEvent.press(screen.getByRole('tab', { name: 'API Keys' })) + + await waitFor(() => { + expect(screen.getByDisplayValue('AIza-test')).toBeTruthy() + expect(mockApiKeysGetStatus).toHaveBeenCalledTimes(1) + }) + + fireEvent.press(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => { + expect(mockApiKeysUpsert).toHaveBeenCalledWith('AIza-test') + expect(screen.getByText('Gemini API key saved successfully.')).toBeTruthy() + }) + }) + + it('handles import, export, and WordPress save flows', async () => { + const wordpressAppFixture = 'fixture-identifier' + + renderScreen() + + fireEvent.press(screen.getByRole('tab', { name: 'Import .enex file' })) + + await waitFor(() => { + expect(screen.getByText('Bring notes in from Evernote exports.')).toBeTruthy() + }) + + fireEvent.press(screen.getByRole('button', { name: 'Choose and import .enex' })) + + await waitFor(() => { + expect(mockGetDocumentAsync).toHaveBeenCalledWith( + expect.objectContaining({ + type: expectedPickerType, + }) + ) + expect(mockImportAsset).toHaveBeenCalledWith( + expect.objectContaining({ name: 'import.enex' }), + 'user-1', + { + duplicateStrategy: 'prefix', + skipFileDuplicates: false, + }, + expect.any(Function) + ) + expect(screen.getByText('Imported 2 note(s).')).toBeTruthy() + }) + + fireEvent.press(screen.getByRole('tab', { name: 'Export .enex file' })) + fireEvent.press(screen.getByRole('button', { name: 'Export all notes' })) + + await waitFor(() => { + expect(mockExportAllNotes).toHaveBeenCalledWith('user-1', expect.any(Function)) + expect(mockShareAsync).toHaveBeenCalledWith( + 'file:///tmp/everfreenote-export-20260314-083000.enex', + expect.objectContaining({ + mimeType: 'application/xml', + dialogTitle: 'Export .enex file', + }) + ) + expect( + screen.getByText('Exported 3 note(s) to everfreenote-export-20260314-083000.enex.') + ).toBeTruthy() + }) + + fireEvent.press(screen.getByRole('tab', { name: 'WordPress settings' })) + + await waitFor(() => { + expect(screen.getByText('Site URL, account, and publishing access.')).toBeTruthy() + }) + + fireEvent.changeText(screen.getByPlaceholderText('https://example.com'), 'https://example.com/') + fireEvent.changeText(screen.getByPlaceholderText('editor-user'), 'editor-user') + fireEvent.changeText(screen.getByPlaceholderText('xxxx xxxx xxxx xxxx'), wordpressAppFixture) + fireEvent.press(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => { + expect(mockWordPressUpsert).toHaveBeenCalledWith({ + siteUrl: 'https://example.com', + wpUsername: 'editor-user', + applicationPassword: wordpressAppFixture, + enabled: true, + }) + expect(screen.getByText('WordPress settings saved successfully.')).toBeTruthy() + }) + }) + + it('blocks import and export actions when no user is available', async () => { + mockUseSupabase.mockReturnValue({ + client: {}, + user: null, + session: null, + loading: false, + signOut: mockSignOut, + deleteAccount: mockDeleteAccount, + }) + + mockUseAuth.mockReturnValue({ + user: null, + session: null, + loading: false, + isAuthenticated: false, + signOut: mockSignOut, + deleteAccount: mockDeleteAccount, + }) + + renderScreen() + + fireEvent.press(screen.getByRole('tab', { name: 'Import .enex file' })) + fireEvent.press(screen.getByRole('button', { name: 'Choose and import .enex' })) + + await waitFor(() => { + expect(mockGetDocumentAsync).not.toHaveBeenCalled() + expect(screen.getByText('You need to be signed in to import notes.')).toBeTruthy() + }) + + fireEvent.press(screen.getByRole('tab', { name: 'Export .enex file' })) + fireEvent.press(screen.getByRole('button', { name: 'Export all notes' })) + + await waitFor(() => { + expect(mockExportAllNotes).not.toHaveBeenCalled() + expect(screen.getByText('You need to be signed in to export notes.')).toBeTruthy() + }) + }) + + it('shows import and export unhappy-path feedback', async () => { + mockGetDocumentAsync.mockRejectedValueOnce(new Error('Picker unavailable')) + mockImportAsset.mockResolvedValueOnce({ + success: 1, + errors: 1, + failedNotes: [{ title: 'Broken note', error: 'Import failed' }], + message: 'Imported 1 note(s) with 1 error(s).', + }) + + renderScreen() + + fireEvent.press(screen.getByRole('tab', { name: 'Import .enex file' })) + fireEvent.press(screen.getByRole('button', { name: 'Choose and import .enex' })) + + await waitFor(() => { + expect(screen.getByText('Picker unavailable')).toBeTruthy() + expect(mockImportAsset).not.toHaveBeenCalled() + }) + + fireEvent.press(screen.getByRole('button', { name: 'Choose and import .enex' })) + + await waitFor(() => { + expect(screen.getByText('Imported 1 note(s) with 1 error(s).')).toBeTruthy() + expect(mockImportAsset).toHaveBeenCalledTimes(1) + }) + + mockIsAvailableAsync.mockResolvedValueOnce(false) + + fireEvent.press(screen.getByRole('tab', { name: 'Export .enex file' })) + fireEvent.press(screen.getByRole('button', { name: 'Export all notes' })) + + await waitFor(() => { + expect(screen.getByText('Sharing is unavailable on this device')).toBeTruthy() + expect(mockExportAllNotes).not.toHaveBeenCalled() + expect(mockShareAsync).not.toHaveBeenCalled() + }) + + mockIsAvailableAsync.mockResolvedValueOnce(true) + mockExportAllNotes.mockRejectedValueOnce(new Error('Export failed')) + + fireEvent.press(screen.getByRole('button', { name: 'Export all notes' })) + + await waitFor(() => { + expect(screen.getByText('Export failed')).toBeTruthy() + expect(mockShareAsync).not.toHaveBeenCalled() + }) + }) + + it('renders import and export progress states while work is in flight', async () => { + let resolveImport!: () => void + const importCompleted = new Promise((resolve) => { + resolveImport = resolve + }) + let resolveExport!: () => void + const exportCompleted = new Promise((resolve) => { + resolveExport = resolve + }) + + mockImportAsset.mockImplementationOnce(async (_asset, _userId, _settings, onProgress) => { + onProgress?.({ processed: 2, total: 5 }) + await importCompleted + return { + success: 5, + errors: 0, + failedNotes: [], + message: 'Imported 5 note(s).', + } + }) + + mockExportAllNotes.mockImplementationOnce(async (_userId, onProgress) => { + onProgress?.({ stage: 'loading', loaded: 3, total: 5 }) + onProgress?.({ stage: 'building', noteCount: 5 }) + await exportCompleted + return { + fileUri: 'file:///tmp/everfreenote-export-20260314-083000.enex', + fileName: 'everfreenote-export-20260314-083000.enex', + noteCount: 5, + } + }) + + renderScreen() + + fireEvent.press(screen.getByRole('tab', { name: 'Import .enex file' })) + fireEvent.press(screen.getByRole('button', { name: 'Choose and import .enex' })) + + await waitFor(() => { + expect(screen.getByText('Importing notes 2 / 5...')).toBeTruthy() + }) + + resolveImport() + + await waitFor(() => { + expect(screen.getByText('Imported 5 note(s).')).toBeTruthy() + }) + + fireEvent.press(screen.getByRole('tab', { name: 'Export .enex file' })) + fireEvent.press(screen.getByRole('button', { name: 'Export all notes' })) + + await waitFor(() => { + expect(screen.getByText('Building archive for 5 note(s)...')).toBeTruthy() + }) + + resolveExport() + + await waitFor(() => { + expect(screen.getByText('Exported 5 note(s) to everfreenote-export-20260314-083000.enex.')).toBeTruthy() + }) + }) + + it('rejects picked files that are not .enex exports', async () => { + mockGetDocumentAsync.mockResolvedValueOnce({ + canceled: false, + assets: [ + { + uri: 'file:///tmp/import.pdf', + name: 'import.pdf', + lastModified: 0, + }, + ], + }) + + renderScreen() + + fireEvent.press(screen.getByRole('tab', { name: 'Import .enex file' })) + fireEvent.press(screen.getByRole('button', { name: 'Choose and import .enex' })) + + await waitFor(() => { + expect(screen.getByText('Only Evernote .enex export files are supported.')).toBeTruthy() + expect(mockImportAsset).not.toHaveBeenCalled() + }) + }) + + it('shows WordPress load, validation, and save errors', async () => { + const wordpressAppFixture = 'fixture-identifier' + mockWordPressGetStatus.mockRejectedValueOnce(new Error('Load failed')) + mockWordPressUpsert.mockRejectedValueOnce(new Error('Save failed')) + + renderScreen() + + fireEvent.press(screen.getByRole('tab', { name: 'WordPress settings' })) + + await waitFor(() => { + expect(screen.getByText('Load failed')).toBeTruthy() + }) + + fireEvent.press(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => { + expect(screen.getByText('Site URL is required.')).toBeTruthy() + }) + + fireEvent.changeText(screen.getByPlaceholderText('https://example.com'), 'https://example.com/') + fireEvent.changeText(screen.getByPlaceholderText('editor-user'), 'editor-user') + fireEvent.press(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => { + expect(screen.getByText('Application password is required for the initial setup.')).toBeTruthy() + }) + + fireEvent.changeText(screen.getByPlaceholderText('xxxx xxxx xxxx xxxx'), wordpressAppFixture) + fireEvent.press(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => { + expect(mockWordPressUpsert).toHaveBeenCalledWith({ + siteUrl: 'https://example.com', + wpUsername: 'editor-user', + applicationPassword: wordpressAppFixture, + enabled: true, + }) + expect(screen.getByText('Save failed')).toBeTruthy() + }) }) }) diff --git a/ui/mobile/tests/unit/importShared.test.ts b/ui/mobile/tests/unit/importShared.test.ts new file mode 100644 index 00000000000..deac1524b29 --- /dev/null +++ b/ui/mobile/tests/unit/importShared.test.ts @@ -0,0 +1,99 @@ +import { + DUPLICATE_LOOKUP_UNAVAILABLE_MESSAGE, + fetchExistingTitles, + normalizeNoteTitle, + resolveExistingTitlesForImport, +} from '@core/enex/import-shared' + +type QueryResult = { + data: Array<{ id: string; title: string | null | undefined; created_at?: string }> | null + error: { message: string } | null +} + +function createSupabaseClient(result: QueryResult) { + const order = jest.fn(async () => result) + + return { + from: jest.fn(() => ({ + select: jest.fn(() => ({ + eq: jest.fn(() => ({ + order, + })), + })), + })), + } as never +} + +describe('import-shared helpers', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('throws when existing-title lookup fails', async () => { + const client = createSupabaseClient({ + data: null, + error: { message: 'lookup failed' }, + }) + + await expect(fetchExistingTitles(client, 'user-1')).rejects.toThrow( + 'Failed to fetch existing titles: lookup failed' + ) + }) + + it('falls back to a per-note lookup marker for prefix imports when lookup fails', async () => { + const client = createSupabaseClient({ + data: null, + error: { message: 'lookup failed' }, + }) + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined) + + const existingByTitle = await resolveExistingTitlesForImport(client, 'user-1', 'prefix') + + expect(existingByTitle).toBeNull() + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Failed to fetch existing titles for prefix import:', + expect.any(Error) + ) + + consoleErrorSpy.mockRestore() + }) + + it('fails closed for skip imports when lookup is unavailable', async () => { + const client = createSupabaseClient({ + data: null, + error: { message: 'lookup failed' }, + }) + + await expect(resolveExistingTitlesForImport(client, 'user-1', 'skip')).rejects.toThrow( + DUPLICATE_LOOKUP_UNAVAILABLE_MESSAGE + ) + }) + + it('skips rows with missing or blank titles when building the duplicate map', async () => { + const client = createSupabaseClient({ + data: [ + { id: 'note-1', title: 'First note' }, + { id: 'note-2', title: '' }, + { id: 'note-3', title: ' ' }, + { id: 'note-4', title: null }, + { id: 'note-5', title: undefined }, + { id: 'note-6', title: 'First note' }, + ], + error: null, + }) + + await expect(fetchExistingTitles(client, 'user-1')).resolves.toEqual( + new Map([ + ['First note', 'note-1'], + ['Untitled', 'note-2'], + ]) + ) + }) + + it('normalizes blank or whitespace titles to Untitled', () => { + expect(normalizeNoteTitle(' Focus note ')).toBe('Focus note') + expect(normalizeNoteTitle(' ')).toBe('Untitled') + expect(normalizeNoteTitle('')).toBe('Untitled') + expect(normalizeNoteTitle(null)).toBe('Untitled') + }) +}) diff --git a/ui/mobile/tests/unit/input.test.tsx b/ui/mobile/tests/unit/input.test.tsx new file mode 100644 index 00000000000..b1b60168680 --- /dev/null +++ b/ui/mobile/tests/unit/input.test.tsx @@ -0,0 +1,79 @@ +import React from 'react' +import { render, screen } from '@testing-library/react-native' +import { StyleSheet } from 'react-native' +import { getThemeColors } from '@ui/mobile/lib/theme' + +jest.mock('@ui/mobile/providers', () => { + const { getThemeColors } = jest.requireActual('@ui/mobile/lib/theme') + + return { + __esModule: true, + ThemeProvider: ({ children }: { children: React.ReactNode }) => children, + useTheme: () => ({ + colors: getThemeColors('light'), + mode: 'light', + setMode: jest.fn(), + colorScheme: 'light', + isHydrated: true, + }), + } +}) + +import { Input } from '@ui/mobile/components/ui/Input' + +describe('Input', () => { + it('uses Android-safe text metrics for the shared settings fields', () => { + render() + + const input = screen.getByPlaceholderText('Site URL') + const style = StyleSheet.flatten(input.props.style) + + expect(style).toEqual( + expect.objectContaining({ + minHeight: 48, + paddingVertical: 10, + lineHeight: 20, + textAlignVertical: 'center', + }) + ) + }) + + it('maps disabled to a non-editable text input', () => { + render() + + const input = screen.getByPlaceholderText('Gemini API Key') + const style = StyleSheet.flatten(input.props.style) + + expect(input.props.editable).toBe(false) + expect(style).toEqual(expect.objectContaining({ opacity: 0.65 })) + }) + + it('renders labels and error state styling', () => { + render( + + ) + + const input = screen.getByPlaceholderText('https://example.com') + const style = StyleSheet.flatten(input.props.style) + + expect(screen.getByText('Site URL')).toBeTruthy() + expect(screen.getByText('Required')).toBeTruthy() + expect(style).toEqual( + expect.objectContaining({ borderColor: getThemeColors('light').destructive }) + ) + }) + + it('respects editable=false even without disabled', () => { + render() + + const input = screen.getByPlaceholderText('Username') + + expect(input.props.editable).toBe(false) + }) +}) diff --git a/ui/mobile/tests/unit/mobileEnexHelpers.test.ts b/ui/mobile/tests/unit/mobileEnexHelpers.test.ts new file mode 100644 index 00000000000..63374a286d8 --- /dev/null +++ b/ui/mobile/tests/unit/mobileEnexHelpers.test.ts @@ -0,0 +1,92 @@ +import { + buildEnexExportFileName, + extractEnexContent, + parseEnexDate, + parseEnexXml, + toEnexExportNotes, +} from '@ui/mobile/lib/enexMobile' + +describe('enexMobile helpers', () => { + it('parses Evernote dates', () => { + expect(parseEnexDate('20260314T103000Z').toISOString()).toBe('2026-03-14T10:30:00.000Z') + }) + + it('extracts ENEX content and replaces media placeholders', () => { + expect( + extractEnexContent( + '
Hello
' + ) + ).toBe('
Hello

[Imported media placeholder]

') + }) + + it('parses ENEX xml into notes', () => { + const xml = ` + + + Imported note +
Hello
]]>
+ 20260314T103000Z + 20260314T104500Z + mobile + enex +
+
` + + expect(parseEnexXml(xml)).toEqual([ + expect.objectContaining({ + title: 'Imported note', + content: '
Hello
', + created: new Date('2026-03-14T10:30:00.000Z'), + updated: new Date('2026-03-14T10:45:00.000Z'), + tags: ['mobile', 'enex'], + resources: [], + }), + ]) + }) + + it('normalizes whitespace-only ENEX titles to Untitled', () => { + const xml = ` + + + +
Hello
]]>
+ 20260314T103000Z +
+
` + + expect(parseEnexXml(xml)).toEqual([ + expect.objectContaining({ + title: 'Untitled', + }), + ]) + }) + + it('maps notes into export format and builds a stable filename', () => { + const exportNotes = toEnexExportNotes([ + { + id: '1', + title: 'My note', + description: '

Hello

', + tags: ['tag-a'], + created_at: '2026-03-13T12:00:00.000Z', + updated_at: '2026-03-14T08:30:00.000Z', + user_id: 'user-1', + }, + ]) + + expect(exportNotes).toEqual([ + { + title: 'My note', + content: '

Hello

', + created: new Date('2026-03-13T12:00:00.000Z'), + updated: new Date('2026-03-14T08:30:00.000Z'), + tags: ['tag-a'], + resources: [], + }, + ]) + + expect(buildEnexExportFileName(new Date('2026-03-14T08:30:00.000Z'))).toBe( + 'everfreenote-export-20260314-083000.enex' + ) + }) +}) diff --git a/ui/mobile/tests/unit/mobileEnexImportService.test.ts b/ui/mobile/tests/unit/mobileEnexImportService.test.ts new file mode 100644 index 00000000000..8a2643c6256 --- /dev/null +++ b/ui/mobile/tests/unit/mobileEnexImportService.test.ts @@ -0,0 +1,190 @@ +const mockCreate = jest.fn() +const mockReadAsStringAsync = jest.fn() +const mockResolveExistingTitlesForImport = jest.fn() + +jest.mock('expo-file-system/legacy', () => ({ + __esModule: true, + EncodingType: { + UTF8: 'utf8', + }, + readAsStringAsync: (...args: unknown[]) => mockReadAsStringAsync(...args), +})) + +jest.mock('@core/enex/note-creator', () => ({ + NoteCreator: jest.fn().mockImplementation(() => ({ + create: mockCreate, + })), +})) + +jest.mock('@core/enex/import-shared', () => { + const actual = jest.requireActual('@core/enex/import-shared') + + return { + ...actual, + resolveExistingTitlesForImport: (...args: unknown[]) => + mockResolveExistingTitlesForImport(...args), + } +}) + +import { MobileEnexImportService } from '@ui/mobile/services/enexImport' + +describe('MobileEnexImportService', () => { + beforeEach(() => { + jest.clearAllMocks() + mockReadAsStringAsync.mockResolvedValue('') + mockResolveExistingTitlesForImport.mockResolvedValue(new Map([['Duplicate note', 'existing-id']])) + }) + + it('passes duplicate settings through and treats skipped duplicates as non-errors', async () => { + const progress = jest.fn() + mockCreate + .mockResolvedValueOnce('created-id') + .mockResolvedValueOnce(null) + + const service = new MobileEnexImportService({} as never) + const xml = ` + + + Fresh note +
Hello
]]>
+ 20260314T103000Z +
+ + Duplicate note +
World
]]>
+ 20260314T104500Z +
+
` + + const result = await service.importXml( + xml, + 'user-1', + { + duplicateStrategy: 'skip', + skipFileDuplicates: true, + }, + progress + ) + + expect(mockResolveExistingTitlesForImport).toHaveBeenCalledWith( + expect.anything(), + 'user-1', + 'skip' + ) + expect(mockCreate).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ title: 'Fresh note' }), + 'user-1', + 'skip', + expect.objectContaining({ + skipFileDuplicates: true, + existingByTitle: expect.any(Map), + seenTitlesInImport: expect.any(Set), + }) + ) + expect(mockCreate).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ title: 'Duplicate note' }), + 'user-1', + 'skip', + expect.any(Object) + ) + expect(progress).toHaveBeenLastCalledWith({ processed: 2, total: 2 }) + expect(result).toEqual({ + success: 1, + errors: 0, + failedNotes: [], + message: 'Imported 1 note(s), skipped 1 duplicate note(s).', + }) + }) + + it('falls back to per-note duplicate lookup when prefix snapshot lookup fails', async () => { + mockResolveExistingTitlesForImport.mockResolvedValueOnce(null) + mockCreate.mockResolvedValueOnce('created-id') + + const service = new MobileEnexImportService({} as never) + const xml = ` + + + Fresh note +
Hello
]]>
+ 20260314T103000Z +
+
` + + const result = await service.importXml( + xml, + 'user-1', + { + duplicateStrategy: 'prefix', + skipFileDuplicates: false, + } + ) + + expect(mockResolveExistingTitlesForImport).toHaveBeenCalledWith( + expect.anything(), + 'user-1', + 'prefix' + ) + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Fresh note' }), + 'user-1', + 'prefix', + expect.objectContaining({ + existingByTitle: null, + }) + ) + expect(result).toEqual({ + success: 1, + errors: 0, + failedNotes: [], + message: 'Imported 1 note(s).', + }) + }) + + it('surfaces a user-facing error when duplicate lookup is unavailable for skip mode', async () => { + mockResolveExistingTitlesForImport.mockRejectedValueOnce( + new Error( + 'Could not verify existing notes. Try again, or switch duplicate handling to "Add [duplicate] prefix to title".' + ) + ) + + const service = new MobileEnexImportService({} as never) + const xml = ` + + + Fresh note +
Hello
]]>
+ 20260314T103000Z +
+
` + + await expect( + service.importXml(xml, 'user-1', { + duplicateStrategy: 'skip', + skipFileDuplicates: false, + }) + ).rejects.toThrow( + 'Could not verify existing notes. Try again, or switch duplicate handling to "Add [duplicate] prefix to title".' + ) + expect(mockCreate).not.toHaveBeenCalled() + }) + + it('rejects oversized import assets before reading them', async () => { + const service = new MobileEnexImportService({} as never) + + await expect( + service.importAsset( + { + uri: 'file:///tmp/huge.enex', + name: 'huge.enex', + size: 101 * 1024 * 1024, + lastModified: 0, + }, + 'user-1' + ) + ).rejects.toThrow('Selected .enex file is too large to import. Maximum supported size is 100 MB.') + + expect(mockReadAsStringAsync).not.toHaveBeenCalled() + }) +}) diff --git a/ui/mobile/tests/unit/settingsPanels.test.tsx b/ui/mobile/tests/unit/settingsPanels.test.tsx new file mode 100644 index 00000000000..eba9a635df8 --- /dev/null +++ b/ui/mobile/tests/unit/settingsPanels.test.tsx @@ -0,0 +1,340 @@ +import React from 'react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react-native' +import { Platform } from 'react-native' + +const mockUseSupabase = jest.fn() +const mockGetDocumentAsync = jest.fn() +const mockImportAsset = jest.fn() +const mockIsAvailableAsync = jest.fn() +const mockShareAsync = jest.fn() +const mockExportAllNotes = jest.fn() +const mockWordPressGetStatus = jest.fn() +const mockWordPressUpsert = jest.fn() +const mockApiKeysGetStatus = jest.fn() +const mockApiKeysUpsert = jest.fn() + +jest.mock('expo-document-picker', () => ({ + getDocumentAsync: (...args: unknown[]) => mockGetDocumentAsync(...args), +})) + +jest.mock('expo-sharing', () => ({ + isAvailableAsync: (...args: unknown[]) => mockIsAvailableAsync(...args), + shareAsync: (...args: unknown[]) => mockShareAsync(...args), +})) + +jest.mock('@ui/mobile/services/enexImport', () => ({ + MobileEnexImportService: jest.fn().mockImplementation(() => ({ + importAsset: mockImportAsset, + })), +})) + +jest.mock('@ui/mobile/services/enexExport', () => ({ + MobileEnexExportService: jest.fn().mockImplementation(() => ({ + exportAllNotes: mockExportAllNotes, + })), +})) + +jest.mock('@core/services/wordpressSettings', () => ({ + WordPressSettingsService: jest.fn().mockImplementation(() => ({ + getStatus: mockWordPressGetStatus, + upsert: mockWordPressUpsert, + })), +})) + +jest.mock('@core/services/apiKeysSettings', () => ({ + ApiKeysSettingsService: jest.fn().mockImplementation(() => ({ + getStatus: mockApiKeysGetStatus, + upsert: mockApiKeysUpsert, + })), +})) + +jest.mock('@ui/mobile/providers', () => { + const { getThemeColors } = jest.requireActual('@ui/mobile/lib/theme') + + return { + __esModule: true, + ThemeProvider: ({ children }: { children: React.ReactNode }) => children, + useTheme: () => ({ + colors: getThemeColors('light'), + mode: 'light', + setMode: jest.fn(), + colorScheme: 'light', + isHydrated: true, + }), + useSupabase: () => mockUseSupabase(), + } +}) + +import { ApiKeysSettingsPanel } from '@ui/mobile/components/settings/ApiKeysSettingsPanel' +import { EnexExportPanel } from '@ui/mobile/components/settings/EnexExportPanel' +import { EnexImportPanel } from '@ui/mobile/components/settings/EnexImportPanel' +import { WordPressSettingsPanel } from '@ui/mobile/components/settings/WordPressSettingsPanel' + +const renderWithTheme = (ui: React.ReactElement) => render(ui) + +describe('settings panels', () => { + const expectedPickerType = Platform.OS === 'android' ? '*/*' : ['application/xml', 'text/xml'] + + beforeEach(() => { + jest.clearAllMocks() + + mockUseSupabase.mockReturnValue({ + client: {}, + user: { id: 'user-1', email: 'test@example.com' }, + }) + + mockGetDocumentAsync.mockResolvedValue({ + canceled: false, + assets: [ + { + uri: 'file:///tmp/import.enex', + name: 'import.enex', + lastModified: 0, + }, + ], + }) + + mockImportAsset.mockResolvedValue({ + success: 2, + errors: 0, + failedNotes: [], + message: 'Imported 2 note(s).', + }) + + mockIsAvailableAsync.mockResolvedValue(true) + mockShareAsync.mockResolvedValue(undefined) + mockExportAllNotes.mockResolvedValue({ + fileUri: 'file:///tmp/everfreenote-export-20260315-120000.enex', + fileName: 'everfreenote-export-20260315-120000.enex', + noteCount: 4, + }) + + mockWordPressGetStatus.mockResolvedValue({ + configured: true, + integration: { + siteUrl: 'https://saved.example.com', + wpUsername: 'saved-user', + enabled: true, + hasPassword: true, + }, + }) + mockWordPressUpsert.mockResolvedValue({ + configured: true, + integration: { + siteUrl: 'https://example.com', + wpUsername: 'editor-user', + enabled: false, + hasPassword: true, + }, + }) + + mockApiKeysGetStatus.mockResolvedValue({ gemini: { configured: false } }) + mockApiKeysUpsert.mockResolvedValue({ gemini: { configured: true } }) + }) + + it('imports only .enex files and passes the selected asset to the mobile import service', async () => { + renderWithTheme() + + fireEvent.press(screen.getByRole('button', { name: 'Choose and import .enex' })) + + await waitFor(() => { + expect(mockGetDocumentAsync).toHaveBeenCalledWith({ + type: expectedPickerType, + copyToCacheDirectory: true, + multiple: false, + }) + expect(mockImportAsset).toHaveBeenCalledWith( + expect.objectContaining({ name: 'import.enex' }), + 'user-1', + { + duplicateStrategy: 'prefix', + skipFileDuplicates: false, + }, + expect.any(Function) + ) + expect(screen.getByText('Imported 2 note(s).')).toBeTruthy() + }) + }) + + it('passes the selected duplicate settings into the import service', async () => { + renderWithTheme() + + fireEvent.press(screen.getByRole('radio', { name: 'Replace existing notes' })) + fireEvent.press(screen.getByRole('checkbox', { name: 'Skip duplicates inside imported file(s)' })) + fireEvent.press(screen.getByRole('button', { name: 'Choose and import .enex' })) + + await waitFor(() => { + expect(mockImportAsset).toHaveBeenCalledWith( + expect.objectContaining({ name: 'import.enex' }), + 'user-1', + { + duplicateStrategy: 'replace', + skipFileDuplicates: true, + }, + expect.any(Function) + ) + }) + }) + + it('shows note-based progress while import is running', async () => { + let resolveImport!: () => void + const importCompleted = new Promise((resolve) => { + resolveImport = resolve + }) + + mockImportAsset.mockImplementationOnce(async (_asset, _userId, _settings, onProgress) => { + onProgress?.({ processed: 1, total: 3 }) + await importCompleted + return { + success: 3, + errors: 0, + failedNotes: [], + message: 'Imported 3 note(s).', + } + }) + + renderWithTheme() + + fireEvent.press(screen.getByRole('button', { name: 'Choose and import .enex' })) + + await waitFor(() => { + expect(screen.getByText('Importing notes 1 / 3...')).toBeTruthy() + }) + + resolveImport() + + await waitFor(() => { + expect(screen.getByText('Imported 3 note(s).')).toBeTruthy() + }) + }) + + it('rejects picked files that do not use the .enex extension', async () => { + mockGetDocumentAsync.mockResolvedValueOnce({ + canceled: false, + assets: [ + { + uri: 'file:///tmp/import.xml', + name: 'import.xml', + lastModified: 0, + }, + ], + }) + + renderWithTheme() + + fireEvent.press(screen.getByRole('button', { name: 'Choose and import .enex' })) + + await waitFor(() => { + expect(screen.getByText('Only Evernote .enex export files are supported.')).toBeTruthy() + expect(mockImportAsset).not.toHaveBeenCalled() + }) + }) + + it('exports all notes through the share sheet', async () => { + renderWithTheme() + + fireEvent.press(screen.getByRole('button', { name: 'Export all notes' })) + + await waitFor(() => { + expect(mockExportAllNotes).toHaveBeenCalledWith('user-1', expect.any(Function)) + expect(mockShareAsync).toHaveBeenCalledWith( + 'file:///tmp/everfreenote-export-20260315-120000.enex', + expect.objectContaining({ + mimeType: 'application/xml', + dialogTitle: 'Export .enex file', + }) + ) + expect( + screen.getByText('Exported 4 note(s) to everfreenote-export-20260315-120000.enex.') + ).toBeTruthy() + }) + }) + + it('shows staged progress while export is running', async () => { + let resolveExport!: () => void + const exportCompleted = new Promise((resolve) => { + resolveExport = resolve + }) + + mockExportAllNotes.mockImplementationOnce(async (_userId, onProgress) => { + onProgress?.({ stage: 'loading', loaded: 4, total: 4 }) + onProgress?.({ stage: 'building', noteCount: 4 }) + onProgress?.({ + stage: 'writing', + noteCount: 4, + fileName: 'everfreenote-export-20260315-120000.enex', + }) + await exportCompleted + return { + fileUri: 'file:///tmp/everfreenote-export-20260315-120000.enex', + fileName: 'everfreenote-export-20260315-120000.enex', + noteCount: 4, + } + }) + + renderWithTheme() + + fireEvent.press(screen.getByRole('button', { name: 'Export all notes' })) + + await waitFor(() => { + expect(screen.getByText('Saving everfreenote-export-20260315-120000.enex...')).toBeTruthy() + }) + + resolveExport() + + await waitFor(() => { + expect(screen.getByText('Exported 4 note(s) to everfreenote-export-20260315-120000.enex.')).toBeTruthy() + }) + }) + + it('normalizes WordPress settings before saving and supports saving without a new password when one already exists', async () => { + renderWithTheme() + + await waitFor(() => { + expect(mockWordPressGetStatus).toHaveBeenCalledTimes(1) + expect(screen.getByText('Saved WordPress credentials are available.')).toBeTruthy() + }) + + fireEvent.changeText(screen.getByPlaceholderText('https://example.com'), ' https://example.com/// ') + fireEvent.changeText(screen.getByPlaceholderText('editor-user'), ' editor-user ') + fireEvent.press(screen.getByRole('checkbox')) + fireEvent.press(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => { + expect(mockWordPressUpsert).toHaveBeenCalledWith({ + siteUrl: 'https://example.com', + wpUsername: 'editor-user', + applicationPassword: undefined, + enabled: false, + }) + expect(screen.getByText('WordPress settings saved successfully.')).toBeTruthy() + }) + }) + + it('keeps API key validation local and trims the submitted key', async () => { + renderWithTheme() + + await waitFor(() => { + expect(mockApiKeysGetStatus).toHaveBeenCalledTimes(1) + }) + + await waitFor(() => { + expect(screen.queryByText('Loading API key status...')).toBeNull() + }) + + fireEvent.press(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => { + expect(screen.getByText('Enter a Gemini API key to continue.')).toBeTruthy() + expect(mockApiKeysUpsert).not.toHaveBeenCalled() + }) + + fireEvent.changeText(screen.getByPlaceholderText('AIzaSy...'), ' AIza-unit-test ') + fireEvent.press(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => { + expect(mockApiKeysUpsert).toHaveBeenCalledWith('AIza-unit-test') + expect(screen.getByText('Gemini API key saved successfully.')).toBeTruthy() + }) + }) +}) diff --git a/ui/web/components/ImportButton.tsx b/ui/web/components/ImportButton.tsx index a040de7b261..1c9523b8965 100644 --- a/ui/web/components/ImportButton.tsx +++ b/ui/web/components/ImportButton.tsx @@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button" import { ImportDialog } from "@/components/ImportDialog" import { ImportProgressDialog } from "@/components/ImportProgressDialog" import { ContentConverter } from "@core/enex/converter" +import { resolveExistingTitlesForImport } from "@core/enex/import-shared" import { EnexParser } from "@core/enex/parser" import { ImageProcessor } from "@core/enex/image-processor" import { NoteCreator } from "@core/enex/note-creator" @@ -23,28 +24,6 @@ import { browser } from "@ui/web/adapters/browser" const IMPORT_STATE_KEY = "everfreenote-import-state" -type SupabaseClientType = ReturnType["supabase"] - -async function fetchExistingTitles(client: SupabaseClientType, userId: string) { - const { data, error } = await client - .from("notes") - .select("id, title") - .eq("user_id", userId) - - if (error) { - console.error("Failed to fetch existing titles:", error) - return new Map() - } - - const map = new Map() - data?.forEach((row) => { - if (!map.has(row.title)) { - map.set(row.title, row.id) - } - }) - return map -} - const initialProgress: ImportProgress = { currentFile: 0, totalFiles: 0, @@ -131,6 +110,7 @@ export function ImportButton({ onImportComplete, maxFileSize = 100 * 1024 * 1024 } let successCount = 0 + let skippedCount = 0 let errorCount = 0 let totalNotes = 0 const failedNotes: FailedImportNote[] = [] @@ -155,7 +135,12 @@ export function ImportButton({ onImportComplete, maxFileSize = 100 * 1024 * 1024 const noteCreator = new NoteCreator(supabase) // Snapshot существующих заголовков в базе до импорта - const existingByTitle = await fetchExistingTitles(supabase, user.id) + const existingByTitle = await resolveExistingTitlesForImport( + supabase, + user.id, + settings.duplicateStrategy + ) + const fallbackExistingByTitle = new Map() // Отслеживаем титлы, уже встреченные в текущем сеансе импорта (внутри файла/файлов) const seenTitlesInImport = new Set() @@ -200,9 +185,10 @@ export function ImportButton({ onImportComplete, maxFileSize = 100 * 1024 * 1024 JSON.stringify({ currentFile: fileIndex + 1, totalFiles: files.length, - currentNote: successCount + errorCount, + currentNote: successCount + skippedCount + errorCount, totalNotes, successCount, + skippedCount, errorCount, fileName: file.name, }) @@ -221,7 +207,7 @@ export function ImportButton({ onImportComplete, maxFileSize = 100 * 1024 * 1024 note.title || "Untitled" ) - await noteCreator.create( + const createdId = await noteCreator.create( { ...note, content: convertedContent, @@ -231,16 +217,21 @@ export function ImportButton({ onImportComplete, maxFileSize = 100 * 1024 * 1024 { skipFileDuplicates: settings.skipFileDuplicates, existingByTitle, + fallbackExistingByTitle, seenTitlesInImport, } ) - successCount++ + if (createdId) { + successCount++ + } else { + skippedCount++ + } // Update note progress setProgress((prev) => ({ ...prev, - currentNote: successCount + errorCount, + currentNote: successCount + skippedCount + errorCount, })) } catch (error) { const err = error as Error @@ -256,7 +247,7 @@ export function ImportButton({ onImportComplete, maxFileSize = 100 * 1024 * 1024 // Update note progress even on error setProgress((prev) => ({ ...prev, - currentNote: successCount + errorCount, + currentNote: successCount + skippedCount + errorCount, })) } } @@ -276,12 +267,29 @@ export function ImportButton({ onImportComplete, maxFileSize = 100 * 1024 * 1024 success: successCount, errors: errorCount, failedNotes, - message: - successCount > 0 - ? `Successfully imported ${successCount} note${successCount > 1 ? "s" : ""}` - : errorCount > 0 - ? "All imports failed" - : "No notes were imported", + message: (() => { + const messageParts: string[] = [] + + if (successCount > 0) { + messageParts.push( + `Successfully imported ${successCount} note${successCount > 1 ? "s" : ""}` + ) + } + + if (skippedCount > 0) { + messageParts.push(`skipped ${skippedCount} duplicate note${skippedCount > 1 ? "s" : ""}`) + } + + if (errorCount > 0) { + if (messageParts.length > 0) { + messageParts.push(`with ${errorCount} error${errorCount > 1 ? "s" : ""}`) + } else { + messageParts.push("All imports failed") + } + } + + return messageParts.length > 0 ? messageParts.join(", ") : "No notes were imported" + })(), } setImportResult(result) diff --git a/ui/web/components/ImportDialog.tsx b/ui/web/components/ImportDialog.tsx index 94484f4ca98..123a20d1244 100644 --- a/ui/web/components/ImportDialog.tsx +++ b/ui/web/components/ImportDialog.tsx @@ -9,6 +9,11 @@ import { Label } from "@/components/ui/label" import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group" import { Checkbox } from "@/components/ui/checkbox" import type { DuplicateStrategy } from "@core/enex/types" +import { + DEFAULT_IMPORT_SETTINGS, + DUPLICATE_STRATEGY_OPTIONS, + IMPORT_SETTINGS_COPY, +} from "@core/enex/import-shared" type ImportDialogProps = { open: boolean @@ -22,8 +27,9 @@ type ImportDialogProps = { export function ImportDialog({ open, onOpenChange, onImport }: ImportDialogProps) { const [isDragging, setIsDragging] = React.useState(false) const [duplicateStrategy, setDuplicateStrategy] = - React.useState("prefix") - const [skipFileDuplicates, setSkipFileDuplicates] = React.useState(false) + React.useState(DEFAULT_IMPORT_SETTINGS.duplicateStrategy) + const [skipFileDuplicates, setSkipFileDuplicates] = + React.useState(DEFAULT_IMPORT_SETTINGS.skipFileDuplicates) const [selectedFiles, setSelectedFiles] = React.useState([]) React.useEffect(() => { @@ -142,12 +148,12 @@ export function ImportDialog({ open, onOpenChange, onImport }: ImportDialogProps {/* Import Settings */}
- + {/* Duplicate Strategy */}
-
- - -
-
- - -
-
- - -
+ {DUPLICATE_STRATEGY_OPTIONS.map((option) => ( +
+ + +
+ ))}
@@ -192,7 +182,7 @@ export function ImportDialog({ open, onOpenChange, onImport }: ImportDialogProps onCheckedChange={(checked) => setSkipFileDuplicates(Boolean(checked))} />