From 1cae5ae2b49a7f220b3983ab9fed2d94f3bc1e8e Mon Sep 17 00:00:00 2001 From: Denys Date: Sat, 14 Mar 2026 20:17:17 +0100 Subject: [PATCH 01/12] Implement full mobile settings menu --- .../ai/design/feature-mobile-settings-menu.md | 203 +++++++++ .../feature-mobile-settings-menu.md | 144 ++++++ .../planning/feature-mobile-settings-menu.md | 74 +++ .../feature-mobile-settings-menu.md | 86 ++++ .../testing/feature-mobile-settings-menu.md | 112 +++++ ui/mobile/app/(tabs)/settings.tsx | 422 +++--------------- .../settings/AccountSettingsPanel.tsx | 274 ++++++++++++ .../settings/ApiKeysSettingsPanel.tsx | 134 ++++++ .../components/settings/EnexExportPanel.tsx | 96 ++++ .../components/settings/EnexImportPanel.tsx | 119 +++++ .../components/settings/SettingsPanelCard.tsx | 86 ++++ .../settings/SettingsStatusMessage.tsx | 74 +++ .../components/settings/SettingsTabBar.tsx | 89 ++++ .../settings/WordPressSettingsPanel.tsx | 231 ++++++++++ ui/mobile/components/ui/Button.tsx | 1 + ui/mobile/lib/enexMobile.ts | 104 +++++ ui/mobile/package-lock.json | 104 ++++- ui/mobile/package.json | 6 +- ui/mobile/services/enexExport.ts | 56 +++ ui/mobile/services/enexImport.ts | 57 +++ .../tests/integration/settingsScreen.test.tsx | 220 ++++++++- .../tests/unit/mobileEnexHelpers.test.ts | 73 +++ 22 files changed, 2378 insertions(+), 387 deletions(-) create mode 100644 docs/ai/design/feature-mobile-settings-menu.md create mode 100644 docs/ai/implementation/feature-mobile-settings-menu.md create mode 100644 docs/ai/planning/feature-mobile-settings-menu.md create mode 100644 docs/ai/requirements/feature-mobile-settings-menu.md create mode 100644 docs/ai/testing/feature-mobile-settings-menu.md create mode 100644 ui/mobile/components/settings/AccountSettingsPanel.tsx create mode 100644 ui/mobile/components/settings/ApiKeysSettingsPanel.tsx create mode 100644 ui/mobile/components/settings/EnexExportPanel.tsx create mode 100644 ui/mobile/components/settings/EnexImportPanel.tsx create mode 100644 ui/mobile/components/settings/SettingsPanelCard.tsx create mode 100644 ui/mobile/components/settings/SettingsStatusMessage.tsx create mode 100644 ui/mobile/components/settings/SettingsTabBar.tsx create mode 100644 ui/mobile/components/settings/WordPressSettingsPanel.tsx create mode 100644 ui/mobile/lib/enexMobile.ts create mode 100644 ui/mobile/services/enexExport.ts create mode 100644 ui/mobile/services/enexImport.ts create mode 100644 ui/mobile/tests/unit/mobileEnexHelpers.test.ts 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..9bfa5e537ba --- /dev/null +++ b/docs/ai/design/feature-mobile-settings-menu.md @@ -0,0 +1,203 @@ +--- +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. + - `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"` + - Each tab has: + - label + - icon + - description + - render function / component + +- Account panel state + - `email: string` + - `themeMode: "system" | "light" | "dark"` + - `confirmDelete: boolean` + - `isDeleting: boolean` + - `errorMessage?: string` + +- Import panel state + - `selectedFileName?: string` + - `isImporting: boolean` + - `importSummary?: { success: number; errors: number; message: string }` + - `errorMessage?: string` + +- 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.importDocument(options): Promise` + - `MobileEnexExportService.exportAllNotes(userId): Promise<{ fileUri: string; fileName: string; noteCount: number }>` + +- 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. + +- 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..d8bb0b631cc --- /dev/null +++ b/docs/ai/implementation/feature-mobile-settings-menu.md @@ -0,0 +1,144 @@ +--- +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. + - 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 duplicate strategy `prefix`. + +- 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. + +### 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..de20142e05e --- /dev/null +++ b/docs/ai/requirements/feature-mobile-settings-menu.md @@ -0,0 +1,86 @@ +--- +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 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. +- `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..2cfcacfb4d2 --- /dev/null +++ b/docs/ai/testing/feature-mobile-settings-menu.md @@ -0,0 +1,112 @@ +--- +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. +- [ ] 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. +- [ ] 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. +- [ ] 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/mobileEnexHelpers.test.ts tests/integration/settingsScreen.test.tsx` from `ui/mobile/` +- 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?** + +- Verify tab switching stays responsive on a typical device. +- Verify export of a moderate notes set does not freeze the UI. +- Verify import of 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..b18bfc8e91f 100644 --- a/ui/mobile/app/(tabs)/settings.tsx +++ b/ui/mobile/app/(tabs)/settings.tsx @@ -1,211 +1,68 @@ -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 { 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' }, +] 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 [showDeleteModal, setShowDeleteModal] = useState(false) - const [understandDeletion, setUnderstandDeletion] = useState(false) - const [isDeleting, setIsDeleting] = useState(false) - const [error, setError] = useState(null) - - 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 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) - } - } + const styles = useMemo(() => createStyles(colors, insets.bottom ?? 0), [colors, insets.bottom]) + const [activeTab, setActiveTab] = useState('account') 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}} - - - - - - + + + + {activeTab === 'account' ? ( + + ) : null} + + {activeTab === 'import' ? : null} + {activeTab === 'export' ? : null} + {activeTab === 'wordpress' ? : null} + {activeTab === 'apiKeys' ? : null} - + ) } -const createStyles = (colors: ReturnType['colors'], insets: { bottom?: number }) => +const createStyles = (colors: ReturnType['colors'], bottomInset: number) => StyleSheet.create({ container: { flex: 1, @@ -213,182 +70,35 @@ const createStyles = (colors: ReturnType['colors'], insets: { b }, contentContainer: { paddingHorizontal: 16, - paddingBottom: (insets.bottom ?? 0) + 32, + paddingTop: 12, + paddingBottom: bottomInset + 24, }, - sectionSubtitle: { - fontSize: 12, - fontFamily: 'Inter_400Regular', - color: colors.mutedForeground, - marginBottom: 8, - paddingHorizontal: 4, - }, - 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, - }, - modalButton: { - flex: 1, - }, - errorText: { - color: colors.destructive, - fontSize: 12, - marginBottom: 16, - textAlign: 'center', + panelWrap: { + gap: 14, }, }) diff --git a/ui/mobile/components/settings/AccountSettingsPanel.tsx b/ui/mobile/components/settings/AccountSettingsPanel.tsx new file mode 100644 index 00000000000..41603fd0869 --- /dev/null +++ b/ui/mobile/components/settings/AccountSettingsPanel.tsx @@ -0,0 +1,274 @@ +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 = { + 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') + 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..fe1c5811a5f --- /dev/null +++ b/ui/mobile/components/settings/ApiKeysSettingsPanel.tsx @@ -0,0 +1,134 @@ +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 + /> + + {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..b8550004481 --- /dev/null +++ b/ui/mobile/components/settings/EnexExportPanel.tsx @@ -0,0 +1,96 @@ +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 + +export function EnexExportPanel() { + const { client, user } = useSupabase() + const { colors } = useTheme() + const styles = useMemo(() => createStyles(colors), [colors]) + const [isExporting, setIsExporting] = useState(false) + 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) + 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) + 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) + } + } + + return ( + } + title="Export .enex file" + subtitle="Download your notes as an archive." + > + {isExporting ? ( + + + Preparing your archive... + + ) : 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..adcb3016986 --- /dev/null +++ b/ui/mobile/components/settings/EnexImportPanel.tsx @@ -0,0 +1,119 @@ +import { useMemo, useState } from 'react' +import { ActivityIndicator, StyleSheet, Text, View } from 'react-native' +import { Upload } from 'lucide-react-native' +import * as DocumentPicker from 'expo-document-picker' + +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 + +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 [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 + } + + const result = await DocumentPicker.getDocumentAsync({ + type: ['application/xml', 'text/xml', '*/*'], + copyToCacheDirectory: true, + multiple: false, + }) + + if (result.canceled || !result.assets?.length) { + return + } + + const asset = result.assets[0] + setSelectedFileName(asset.name) + setIsImporting(true) + setFeedback(null) + + try { + const summary = await new MobileEnexImportService(client).importAsset(asset, user.id) + setFeedback({ variant: summary.errors > 0 ? 'info' : 'success', message: summary.message }) + } catch (error) { + setFeedback({ + variant: 'error', + message: error instanceof Error ? error.message : 'Failed to import .enex file', + }) + } finally { + setIsImporting(false) + } + } + + return ( + } + title="Import .enex file" + subtitle="Bring notes in from Evernote exports." + > + + Selected file + {selectedFileName ?? 'No file selected yet'} + + + {isImporting ? ( + + + Importing notes... + + ) : 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, + }, + 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..0cb059ca59f --- /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 = { + 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..8160c4df443 --- /dev/null +++ b/ui/mobile/components/settings/SettingsStatusMessage.tsx @@ -0,0 +1,74 @@ +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 = { + 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..06015f87cd6 --- /dev/null +++ b/ui/mobile/components/settings/SettingsTabBar.tsx @@ -0,0 +1,89 @@ +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 = { + key: SettingsTabKey + label: string +} + +type SettingsTabBarProps = { + 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..bfbbaa36a94 --- /dev/null +++ b/ui/mobile/components/settings/WordPressSettingsPanel.tsx @@ -0,0 +1,231 @@ +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) + + 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/lib/enexMobile.ts b/ui/mobile/lib/enexMobile.ts new file mode 100644 index 00000000000..21635c5c51a --- /dev/null +++ b/ui/mobile/lib/enexMobile.ts @@ -0,0 +1,104 @@ +import type { ParsedNote } from '@core/enex/types' +import type { Tables } from '@/supabase/types' +import type { ExportNote } from '@core/enex/export-types' +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 asArray = (value: T | T[] | undefined): T[] => { + if (!value) return [] + return Array.isArray(value) ? value : [value] +} + +export const parseEnexDate = (value?: string | null): Date => { + if (!value) return new Date() + + const trimmed = value.trim() + if (!trimmed) return new Date() + + const match = trimmed.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/) + 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 = value.match(/]*>([\s\S]*)<\/en-note>/i) + const content = match ? match[1] : value + + return content + .replace(/]*\/>/gi, '

[Imported media placeholder]

') + .replace(/]*>[\s\S]*?<\/en-media>/gi, '

[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 = + typeof note.content === 'string' + ? note.content + : typeof note.content?.['#text'] === 'string' + ? note.content['#text'] + : '' + const trimmedTitle = note.title?.trim() + + return { + title: trimmedTitle && trimmedTitle.length > 0 ? trimmedTitle : 'Untitled', + 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..4002860664d 100644 --- a/ui/mobile/package.json +++ b/ui/mobile/package.json @@ -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..acf65483a97 --- /dev/null +++ b/ui/mobile/services/enexExport.ts @@ -0,0 +1,56 @@ +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 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): Promise<{ fileUri: string; fileName: string; noteCount: number }> { + const allNotes: NoteRow[] = [] + const pageSize = 200 + let page = 0 + + while (true) { + const result = await this.noteService.getNotes(userId, { page, pageSize }) + allNotes.push(...result.notes) + + if (!result.hasMore) { + break + } + + page += 1 + } + + const fileName = buildEnexExportFileName() + const baseDirectory = FileSystem.cacheDirectory ?? FileSystem.documentDirectory + + if (!baseDirectory) { + throw new Error('Export directory is unavailable on this device') + } + + const fileUri = `${baseDirectory}${fileName}` + const xml = this.enexBuilder.build(toEnexExportNotes(allNotes)) + + 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..94984558f42 --- /dev/null +++ b/ui/mobile/services/enexImport.ts @@ -0,0 +1,57 @@ +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 { NoteCreator } from '@core/enex/note-creator' +import type { ImportResult } from '@core/enex/types' +import { parseEnexXml } from '@ui/mobile/lib/enexMobile' + +export class MobileEnexImportService { + private readonly noteCreator: NoteCreator + + constructor(supabase: SupabaseClient) { + this.noteCreator = new NoteCreator(supabase) + } + + async importAsset(asset: DocumentPickerAsset, userId: string): Promise { + const xml = await FileSystem.readAsStringAsync(asset.uri, { + encoding: FileSystem.EncodingType.UTF8, + }) + + return this.importXml(xml, userId) + } + + async importXml(xml: string, userId: string): Promise { + const parsedNotes = parseEnexXml(xml) + + let success = 0 + let errors = 0 + const failedNotes: ImportResult['failedNotes'] = [] + + for (const note of parsedNotes) { + try { + const created = await this.noteCreator.create(note, userId, 'prefix') + if (created) { + success += 1 + } + } catch (error) { + errors += 1 + failedNotes.push({ + title: note.title, + error: error instanceof Error ? error.message : 'Failed to import note', + }) + } + } + + return { + success, + errors, + failedNotes, + message: + errors > 0 + ? `Imported ${success} note(s) with ${errors} error(s).` + : `Imported ${success} note(s).`, + } + } +} diff --git a/ui/mobile/tests/integration/settingsScreen.test.tsx b/ui/mobile/tests/integration/settingsScreen.test.tsx index 3867636ecb9..483428525ec 100644 --- a/ui/mobile/tests/integration/settingsScreen.test.tsx +++ b/ui/mobile/tests/integration/settingsScreen.test.tsx @@ -1,37 +1,145 @@ 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 { 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 () => { + beforeEach(() => { 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, + }) + + 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) + }) - renderWithProviders() + 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 +148,77 @@ describe('SettingsScreen', () => { await waitFor(() => { expect(screen.getByText('Current: dark (dark)')).toBeTruthy() }) + + fireEvent.press(screen.getByRole('button', { 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('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 () => { + renderScreen() + + fireEvent.press(screen.getByRole('button', { 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 file' })) + + await waitFor(() => { + expect(mockImportAsset).toHaveBeenCalledWith( + expect.objectContaining({ name: 'import.enex' }), + 'user-1' + ) + expect(screen.getByText('Imported 2 note(s).')).toBeTruthy() + }) + + fireEvent.press(screen.getByRole('button', { name: 'Export .enex file' })) + fireEvent.press(screen.getByRole('button', { name: 'Export and share' })) + + await waitFor(() => { + expect(mockExportAllNotes).toHaveBeenCalledWith('user-1') + 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('button', { 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'), 'secret secret secret') + fireEvent.press(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => { + expect(mockWordPressUpsert).toHaveBeenCalledWith({ + siteUrl: 'https://example.com', + wpUsername: 'editor-user', + applicationPassword: 'secret secret secret', + enabled: true, + }) + expect(screen.getByText('WordPress settings saved successfully.')).toBeTruthy() + }) }) }) diff --git a/ui/mobile/tests/unit/mobileEnexHelpers.test.ts b/ui/mobile/tests/unit/mobileEnexHelpers.test.ts new file mode 100644 index 00000000000..adfae2cb29f --- /dev/null +++ b/ui/mobile/tests/unit/mobileEnexHelpers.test.ts @@ -0,0 +1,73 @@ +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
', + tags: ['mobile', 'enex'], + resources: [], + }), + ]) + }) + + 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' + ) + }) +}) From 42ca5a4ad12ff31e0328906dfe85b627fbad8a87 Mon Sep 17 00:00:00 2001 From: Denys Date: Sat, 14 Mar 2026 20:32:00 +0100 Subject: [PATCH 02/12] fix(mobile): preserve settings tab state --- ui/mobile/app/(tabs)/settings.tsx | 89 ++++++++++++++++--- .../tests/integration/settingsScreen.test.tsx | 8 ++ 2 files changed, 83 insertions(+), 14 deletions(-) diff --git a/ui/mobile/app/(tabs)/settings.tsx b/ui/mobile/app/(tabs)/settings.tsx index b18bfc8e91f..f934f498ce6 100644 --- a/ui/mobile/app/(tabs)/settings.tsx +++ b/ui/mobile/app/(tabs)/settings.tsx @@ -22,6 +22,14 @@ const tabs: SettingsTabDefinition[] = [ { key: 'apiKeys', label: 'API Keys' }, ] +const initialVisitedTabs: Record = { + account: true, + import: false, + export: false, + wordpress: false, + apiKeys: false, +} + export default function SettingsScreen() { const { colors, mode, setMode, colorScheme } = useTheme() const { signOut, deleteAccount } = useAuth() @@ -29,6 +37,12 @@ export default function SettingsScreen() { const insets = useSafeAreaInsets() const styles = useMemo(() => createStyles(colors, insets.bottom ?? 0), [colors, insets.bottom]) const [activeTab, setActiveTab] = useState('account') + const [visitedTabs, setVisitedTabs] = useState(initialVisitedTabs) + + const handleTabChange = (tab: SettingsTabKey) => { + setActiveTab(tab) + setVisitedTabs((current) => (current[tab] ? current : { ...current, [tab]: true })) + } return ( @@ -38,24 +52,65 @@ export default function SettingsScreen() { Everything for your account, imports, exports, and integrations. - + - {activeTab === 'account' ? ( - + {visitedTabs.account ? ( + + + + ) : null} + + {visitedTabs.import ? ( + + + + ) : null} + + {visitedTabs.export ? ( + + + + ) : null} + + {visitedTabs.wordpress ? ( + + + ) : null} - {activeTab === 'import' ? : null} - {activeTab === 'export' ? : null} - {activeTab === 'wordpress' ? : null} - {activeTab === 'apiKeys' ? : null} + {visitedTabs.apiKeys ? ( + + + + ) : null} @@ -101,4 +156,10 @@ const createStyles = (colors: ReturnType['colors'], bottomInset panelWrap: { gap: 14, }, + panel: { + width: '100%', + }, + panelHidden: { + display: 'none', + }, }) diff --git a/ui/mobile/tests/integration/settingsScreen.test.tsx b/ui/mobile/tests/integration/settingsScreen.test.tsx index 483428525ec..43369d2a77f 100644 --- a/ui/mobile/tests/integration/settingsScreen.test.tsx +++ b/ui/mobile/tests/integration/settingsScreen.test.tsx @@ -156,6 +156,14 @@ describe('SettingsScreen', () => { }) fireEvent.changeText(screen.getByPlaceholderText('AIzaSy...'), 'AIza-test') + fireEvent.press(screen.getByRole('button', { name: 'My Account' })) + fireEvent.press(screen.getByRole('button', { name: 'API Keys' })) + + await waitFor(() => { + expect(screen.getByDisplayValue('AIza-test')).toBeTruthy() + expect(mockApiKeysGetStatus).toHaveBeenCalledTimes(1) + }) + fireEvent.press(screen.getByRole('button', { name: 'Save' })) await waitFor(() => { From 6a177d7e446beb84cf4c2bfb6cd23028036d4eab Mon Sep 17 00:00:00 2001 From: Denys Date: Sun, 15 Mar 2026 11:02:35 +0100 Subject: [PATCH 03/12] fix(mobile): address PR review feedback --- .../settings/AccountSettingsPanel.tsx | 1 + .../components/settings/EnexImportPanel.tsx | 26 ++-- .../settings/SettingsStatusMessage.tsx | 1 + .../components/settings/SettingsTabBar.tsx | 3 +- .../settings/WordPressSettingsPanel.tsx | 7 +- ui/mobile/services/enexImport.ts | 4 + .../tests/integration/settingsScreen.test.tsx | 145 +++++++++++++++++- .../tests/unit/mobileEnexHelpers.test.ts | 2 + 8 files changed, 166 insertions(+), 23 deletions(-) diff --git a/ui/mobile/components/settings/AccountSettingsPanel.tsx b/ui/mobile/components/settings/AccountSettingsPanel.tsx index 41603fd0869..6976d735386 100644 --- a/ui/mobile/components/settings/AccountSettingsPanel.tsx +++ b/ui/mobile/components/settings/AccountSettingsPanel.tsx @@ -51,6 +51,7 @@ export function AccountSettingsPanel({ await onDeleteAccount() } catch (error) { setErrorMessage(error instanceof Error ? error.message : 'Failed to delete account') + } finally { setIsDeleting(false) } } diff --git a/ui/mobile/components/settings/EnexImportPanel.tsx b/ui/mobile/components/settings/EnexImportPanel.tsx index adcb3016986..50ca48db2eb 100644 --- a/ui/mobile/components/settings/EnexImportPanel.tsx +++ b/ui/mobile/components/settings/EnexImportPanel.tsx @@ -30,22 +30,22 @@ export function EnexImportPanel() { return } - const result = await DocumentPicker.getDocumentAsync({ - type: ['application/xml', 'text/xml', '*/*'], - copyToCacheDirectory: true, - multiple: false, - }) + try { + const result = await DocumentPicker.getDocumentAsync({ + type: ['application/xml', 'text/xml', '*/*'], + copyToCacheDirectory: true, + multiple: false, + }) - if (result.canceled || !result.assets?.length) { - return - } + if (result.canceled || !result.assets?.length) { + return + } - const asset = result.assets[0] - setSelectedFileName(asset.name) - setIsImporting(true) - setFeedback(null) + const asset = result.assets[0] + setSelectedFileName(asset.name) + setIsImporting(true) + setFeedback(null) - try { const summary = await new MobileEnexImportService(client).importAsset(asset, user.id) setFeedback({ variant: summary.errors > 0 ? 'info' : 'success', message: summary.message }) } catch (error) { diff --git a/ui/mobile/components/settings/SettingsStatusMessage.tsx b/ui/mobile/components/settings/SettingsStatusMessage.tsx index 8160c4df443..454bf6c0f3f 100644 --- a/ui/mobile/components/settings/SettingsStatusMessage.tsx +++ b/ui/mobile/components/settings/SettingsStatusMessage.tsx @@ -39,6 +39,7 @@ export function SettingsStatusMessage({ message, variant }: SettingsStatusMessag return ( onChange(tab.key)} diff --git a/ui/mobile/components/settings/WordPressSettingsPanel.tsx b/ui/mobile/components/settings/WordPressSettingsPanel.tsx index bfbbaa36a94..6503d081a03 100644 --- a/ui/mobile/components/settings/WordPressSettingsPanel.tsx +++ b/ui/mobile/components/settings/WordPressSettingsPanel.tsx @@ -26,6 +26,7 @@ export function WordPressSettingsPanel() { const [isLoading, setIsLoading] = useState(true) const [isSaving, setIsSaving] = useState(false) const [feedback, setFeedback] = useState(null) + const isBusy = isLoading || isSaving useEffect(() => { let isMounted = true @@ -139,6 +140,7 @@ export function WordPressSettingsPanel() { autoCorrect={false} keyboardType="url" placeholder="https://example.com" + editable={!isBusy} /> setEnabled((value) => !value)} style={({ pressed }) => [styles.checkboxRow, pressed && styles.checkboxRowPressed]} > @@ -181,7 +186,7 @@ export function WordPressSettingsPanel() { {feedback ? : null} - diff --git a/ui/mobile/services/enexImport.ts b/ui/mobile/services/enexImport.ts index 94984558f42..7ad914f6dd5 100644 --- a/ui/mobile/services/enexImport.ts +++ b/ui/mobile/services/enexImport.ts @@ -25,6 +25,10 @@ export class MobileEnexImportService { async importXml(xml: string, userId: string): Promise { const parsedNotes = parseEnexXml(xml) + if (parsedNotes.length === 0) { + throw new Error('No importable notes were found in the selected .enex file') + } + let success = 0 let errors = 0 const failedNotes: ImportResult['failedNotes'] = [] diff --git a/ui/mobile/tests/integration/settingsScreen.test.tsx b/ui/mobile/tests/integration/settingsScreen.test.tsx index 43369d2a77f..5060ecb176d 100644 --- a/ui/mobile/tests/integration/settingsScreen.test.tsx +++ b/ui/mobile/tests/integration/settingsScreen.test.tsx @@ -149,15 +149,15 @@ describe('SettingsScreen', () => { expect(screen.getByText('Current: dark (dark)')).toBeTruthy() }) - fireEvent.press(screen.getByRole('button', { name: 'API Keys' })) + 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('button', { name: 'My Account' })) - fireEvent.press(screen.getByRole('button', { name: 'API Keys' })) + fireEvent.press(screen.getByRole('tab', { name: 'My Account' })) + fireEvent.press(screen.getByRole('tab', { name: 'API Keys' })) await waitFor(() => { expect(screen.getByDisplayValue('AIza-test')).toBeTruthy() @@ -173,9 +173,11 @@ describe('SettingsScreen', () => { }) it('handles import, export, and WordPress save flows', async () => { + const testApplicationPassword = 'test-value' + renderScreen() - fireEvent.press(screen.getByRole('button', { name: 'Import .enex file' })) + fireEvent.press(screen.getByRole('tab', { name: 'Import .enex file' })) await waitFor(() => { expect(screen.getByText('Bring notes in from Evernote exports.')).toBeTruthy() @@ -191,7 +193,7 @@ describe('SettingsScreen', () => { expect(screen.getByText('Imported 2 note(s).')).toBeTruthy() }) - fireEvent.press(screen.getByRole('button', { name: 'Export .enex file' })) + fireEvent.press(screen.getByRole('tab', { name: 'Export .enex file' })) fireEvent.press(screen.getByRole('button', { name: 'Export and share' })) await waitFor(() => { @@ -208,7 +210,7 @@ describe('SettingsScreen', () => { ).toBeTruthy() }) - fireEvent.press(screen.getByRole('button', { name: 'WordPress settings' })) + fireEvent.press(screen.getByRole('tab', { name: 'WordPress settings' })) await waitFor(() => { expect(screen.getByText('Site URL, account, and publishing access.')).toBeTruthy() @@ -216,17 +218,144 @@ describe('SettingsScreen', () => { 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'), 'secret secret secret') + fireEvent.changeText(screen.getByPlaceholderText('xxxx xxxx xxxx xxxx'), testApplicationPassword) fireEvent.press(screen.getByRole('button', { name: 'Save' })) await waitFor(() => { expect(mockWordPressUpsert).toHaveBeenCalledWith({ siteUrl: 'https://example.com', wpUsername: 'editor-user', - applicationPassword: 'secret secret secret', + applicationPassword: testApplicationPassword, 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 file' })) + + 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 and share' })) + + 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 file' })) + + await waitFor(() => { + expect(screen.getByText('Picker unavailable')).toBeTruthy() + expect(mockImportAsset).not.toHaveBeenCalled() + }) + + fireEvent.press(screen.getByRole('button', { name: 'Choose and import file' })) + + 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 and share' })) + + 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 and share' })) + + await waitFor(() => { + expect(screen.getByText('Export failed')).toBeTruthy() + expect(mockShareAsync).not.toHaveBeenCalled() + }) + }) + + it('shows WordPress load, validation, and save errors', async () => { + const testApplicationPassword = 'test-value' + 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'), testApplicationPassword) + fireEvent.press(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => { + expect(mockWordPressUpsert).toHaveBeenCalledWith({ + siteUrl: 'https://example.com', + wpUsername: 'editor-user', + applicationPassword: testApplicationPassword, + enabled: true, + }) + expect(screen.getByText('Save failed')).toBeTruthy() + }) + }) }) diff --git a/ui/mobile/tests/unit/mobileEnexHelpers.test.ts b/ui/mobile/tests/unit/mobileEnexHelpers.test.ts index adfae2cb29f..4e7b4b9d210 100644 --- a/ui/mobile/tests/unit/mobileEnexHelpers.test.ts +++ b/ui/mobile/tests/unit/mobileEnexHelpers.test.ts @@ -36,6 +36,8 @@ describe('enexMobile helpers', () => { 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: [], }), From 9e31c01521017dbff59f696c8c5d9c8a59e778e4 Mon Sep 17 00:00:00 2001 From: Denys Date: Sun, 15 Mar 2026 11:36:25 +0100 Subject: [PATCH 04/12] refactor(mobile): address sonar code smells --- ui/mobile/app/(tabs)/settings.tsx | 135 ++++++++++-------- .../settings/AccountSettingsPanel.tsx | 4 +- .../components/settings/SettingsPanelCard.tsx | 4 +- .../settings/SettingsStatusMessage.tsx | 4 +- .../components/settings/SettingsTabBar.tsx | 8 +- ui/mobile/lib/enexMobile.ts | 31 ++-- 6 files changed, 110 insertions(+), 76 deletions(-) diff --git a/ui/mobile/app/(tabs)/settings.tsx b/ui/mobile/app/(tabs)/settings.tsx index f934f498ce6..45cd0cb420a 100644 --- a/ui/mobile/app/(tabs)/settings.tsx +++ b/ui/mobile/app/(tabs)/settings.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react' +import { useMemo, useState, type ReactNode } from 'react' import { ScrollView, StyleSheet, Text, View } from 'react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' @@ -30,6 +30,38 @@ const initialVisitedTabs: Record = { 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() @@ -44,6 +76,43 @@ export default function SettingsScreen() { setVisitedTabs((current) => (current[tab] ? current : { ...current, [tab]: true })) } + 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: , + }, + ] + return ( @@ -55,62 +124,16 @@ export default function SettingsScreen() { - {visitedTabs.account ? ( - - + panel.isVisited ? ( + - - ) : null} - - {visitedTabs.import ? ( - - - - ) : null} - - {visitedTabs.export ? ( - - - - ) : null} - - {visitedTabs.wordpress ? ( - - - - ) : null} - - {visitedTabs.apiKeys ? ( - - - - ) : null} + ) : null + )} diff --git a/ui/mobile/components/settings/AccountSettingsPanel.tsx b/ui/mobile/components/settings/AccountSettingsPanel.tsx index 6976d735386..adddc965c9b 100644 --- a/ui/mobile/components/settings/AccountSettingsPanel.tsx +++ b/ui/mobile/components/settings/AccountSettingsPanel.tsx @@ -8,14 +8,14 @@ import type { ColorScheme, ThemeMode } from '@ui/mobile/lib/theme' import { SettingsPanelCard } from './SettingsPanelCard' import { SettingsStatusMessage } from './SettingsStatusMessage' -type AccountSettingsPanelProps = { +type AccountSettingsPanelProps = Readonly<{ email: string mode: ThemeMode colorScheme: ColorScheme onModeChange: (mode: ThemeMode) => void onSignOut: () => Promise | void onDeleteAccount: () => Promise -} +}> const themeOptions: Array<{ value: ThemeMode diff --git a/ui/mobile/components/settings/SettingsPanelCard.tsx b/ui/mobile/components/settings/SettingsPanelCard.tsx index 0cb059ca59f..14f1f395267 100644 --- a/ui/mobile/components/settings/SettingsPanelCard.tsx +++ b/ui/mobile/components/settings/SettingsPanelCard.tsx @@ -4,12 +4,12 @@ import { useMemo } from 'react' import { useTheme } from '@ui/mobile/providers' -type SettingsPanelCardProps = { +type SettingsPanelCardProps = Readonly<{ icon: ReactNode title: string subtitle: string children: ReactNode -} +}> export function SettingsPanelCard({ icon, diff --git a/ui/mobile/components/settings/SettingsStatusMessage.tsx b/ui/mobile/components/settings/SettingsStatusMessage.tsx index 454bf6c0f3f..e80eef44f63 100644 --- a/ui/mobile/components/settings/SettingsStatusMessage.tsx +++ b/ui/mobile/components/settings/SettingsStatusMessage.tsx @@ -4,10 +4,10 @@ import { CircleAlert, CircleCheckBig, Info } from 'lucide-react-native' import { useTheme } from '@ui/mobile/providers' -type SettingsStatusMessageProps = { +type SettingsStatusMessageProps = Readonly<{ message: string variant: 'error' | 'success' | 'info' -} +}> export function SettingsStatusMessage({ message, variant }: SettingsStatusMessageProps) { const { colors } = useTheme() diff --git a/ui/mobile/components/settings/SettingsTabBar.tsx b/ui/mobile/components/settings/SettingsTabBar.tsx index b3f50af2d1b..5d47dc9c082 100644 --- a/ui/mobile/components/settings/SettingsTabBar.tsx +++ b/ui/mobile/components/settings/SettingsTabBar.tsx @@ -5,16 +5,16 @@ import { useTheme } from '@ui/mobile/providers' export type SettingsTabKey = 'account' | 'import' | 'export' | 'wordpress' | 'apiKeys' -export type SettingsTabDefinition = { +export type SettingsTabDefinition = Readonly<{ key: SettingsTabKey label: string -} +}> -type SettingsTabBarProps = { +type SettingsTabBarProps = Readonly<{ tabs: SettingsTabDefinition[] activeTab: SettingsTabKey onChange: (tab: SettingsTabKey) => void -} +}> export function SettingsTabBar({ tabs, activeTab, onChange }: SettingsTabBarProps) { const { colors } = useTheme() diff --git a/ui/mobile/lib/enexMobile.ts b/ui/mobile/lib/enexMobile.ts index 21635c5c51a..72b77bcbe14 100644 --- a/ui/mobile/lib/enexMobile.ts +++ b/ui/mobile/lib/enexMobile.ts @@ -24,19 +24,35 @@ const parser = new XMLParser({ 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 = trimmed.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/) + const match = evernoteDatePattern.exec(trimmed) if (!match) { const parsed = new Date(trimmed) return Number.isNaN(parsed.getTime()) ? new Date() : parsed @@ -47,12 +63,12 @@ export const parseEnexDate = (value?: string | null): Date => { } export const extractEnexContent = (value: string): string => { - const match = value.match(/]*>([\s\S]*)<\/en-note>/i) + const match = enNoteContentPattern.exec(value) const content = match ? match[1] : value return content - .replace(/]*\/>/gi, '

[Imported media placeholder]

') - .replace(/]*>[\s\S]*?<\/en-media>/gi, '

[Imported media placeholder]

') + .replaceAll(enMediaSelfClosingPattern, '

[Imported media placeholder]

') + .replaceAll(enMediaOpenClosePattern, '

[Imported media placeholder]

') .trim() } @@ -61,12 +77,7 @@ export const parseEnexXml = (xml: string): ParsedNote[] => { const notes = asArray(parsed['en-export']?.note) return notes.map((note) => { - const rawContent = - typeof note.content === 'string' - ? note.content - : typeof note.content?.['#text'] === 'string' - ? note.content['#text'] - : '' + const rawContent = readParsedNoteContent(note.content) const trimmedTitle = note.title?.trim() return { From 69aeb5e2856478ab6153a4f56607ddfa3e334e9b Mon Sep 17 00:00:00 2001 From: Denys Date: Sun, 15 Mar 2026 11:37:45 +0100 Subject: [PATCH 05/12] Update mobile adb connection scripts --- ui/mobile/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/mobile/package.json b/ui/mobile/package.json index 4002860664d..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", From 868bee6d53e2f75a5f0ace4350ec1ac2bdd292b8 Mon Sep 17 00:00:00 2001 From: Denys Date: Sun, 15 Mar 2026 12:25:07 +0100 Subject: [PATCH 06/12] =?UTF-8?q?=D0=9E=D0=B1=D1=81=D1=83=D0=B4=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D0=BF=D0=BE=D0=B2=D0=B5=D0=B4=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D0=B8=D0=BC=D0=BF=D0=BE=D1=80=D1=82=D0=B0=20=D1=8D?= =?UTF-8?q?=D0=BA=D1=81=D0=BF=D0=BE=D1=80=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../testing/feature-mobile-settings-menu.md | 4 +- .../components/settings/EnexExportPanel.tsx | 2 +- .../components/settings/EnexImportPanel.tsx | 17 +- ui/mobile/components/ui/Input.tsx | 5 +- .../tests/integration/settingsScreen.test.tsx | 44 +++- ui/mobile/tests/unit/input.test.tsx | 39 +++ ui/mobile/tests/unit/settingsPanels.test.tsx | 241 ++++++++++++++++++ 7 files changed, 339 insertions(+), 13 deletions(-) create mode 100644 ui/mobile/tests/unit/input.test.tsx create mode 100644 ui/mobile/tests/unit/settingsPanels.test.tsx diff --git a/docs/ai/testing/feature-mobile-settings-menu.md b/docs/ai/testing/feature-mobile-settings-menu.md index 2cfcacfb4d2..73e699386f8 100644 --- a/docs/ai/testing/feature-mobile-settings-menu.md +++ b/docs/ai/testing/feature-mobile-settings-menu.md @@ -29,6 +29,8 @@ description: Testing plan for the redesigned mobile settings screen and its nati ### 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] Export panel copy and share hand-off stay aligned with the "export all notes" mobile behavior. - [ ] Additional coverage: account deletion acknowledgement gating. ## Integration Tests @@ -70,7 +72,7 @@ description: Testing plan for the redesigned mobile settings screen and its nati ### Latest implementation run - Passed: `npm --prefix ui/mobile run type-check` - Passed: `npm --prefix ui/mobile run lint` -- Passed: `npx jest --runInBand tests/unit/mobileEnexHelpers.test.ts tests/integration/settingsScreen.test.tsx` from `ui/mobile/` +- Passed: `npx jest --runInBand tests/unit/mobileEnexHelpers.test.ts tests/unit/settingsPanels.test.tsx tests/unit/input.test.tsx tests/integration/settingsScreen.test.tsx` from `ui/mobile/` - Passed earlier in the feature workflow: `npx ai-devkit@latest lint --feature mobile-settings-menu` ## Manual Testing diff --git a/ui/mobile/components/settings/EnexExportPanel.tsx b/ui/mobile/components/settings/EnexExportPanel.tsx index b8550004481..0dbd725b404 100644 --- a/ui/mobile/components/settings/EnexExportPanel.tsx +++ b/ui/mobile/components/settings/EnexExportPanel.tsx @@ -75,7 +75,7 @@ export function EnexExportPanel() { {feedback ? : null} ) diff --git a/ui/mobile/components/settings/EnexImportPanel.tsx b/ui/mobile/components/settings/EnexImportPanel.tsx index 50ca48db2eb..fc303b0b2d1 100644 --- a/ui/mobile/components/settings/EnexImportPanel.tsx +++ b/ui/mobile/components/settings/EnexImportPanel.tsx @@ -13,6 +13,10 @@ type FeedbackState = | { variant: 'error' | 'success' | 'info'; message: string } | null +const enexPickerTypes = ['application/xml', 'text/xml'] + +const isEnexFileName = (fileName: string) => /\.enex$/i.test(fileName.trim()) + export function EnexImportPanel() { const { client, user } = useSupabase() const { colors } = useTheme() @@ -32,7 +36,7 @@ export function EnexImportPanel() { try { const result = await DocumentPicker.getDocumentAsync({ - type: ['application/xml', 'text/xml', '*/*'], + type: enexPickerTypes, copyToCacheDirectory: true, multiple: false, }) @@ -43,6 +47,15 @@ export function EnexImportPanel() { const asset = result.assets[0] setSelectedFileName(asset.name) + + if (!isEnexFileName(asset.name)) { + setFeedback({ + variant: 'error', + message: 'Please choose an .enex export file.', + }) + return + } + setIsImporting(true) setFeedback(null) @@ -79,7 +92,7 @@ export function EnexImportPanel() { {feedback ? : null} ) diff --git a/ui/mobile/components/ui/Input.tsx b/ui/mobile/components/ui/Input.tsx index 16fc589ee67..c72b0ec768a 100644 --- a/ui/mobile/components/ui/Input.tsx +++ b/ui/mobile/components/ui/Input.tsx @@ -38,15 +38,18 @@ 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', }, style, ]} diff --git a/ui/mobile/tests/integration/settingsScreen.test.tsx b/ui/mobile/tests/integration/settingsScreen.test.tsx index 5060ecb176d..81ab58fc5d4 100644 --- a/ui/mobile/tests/integration/settingsScreen.test.tsx +++ b/ui/mobile/tests/integration/settingsScreen.test.tsx @@ -183,9 +183,14 @@ describe('SettingsScreen', () => { expect(screen.getByText('Bring notes in from Evernote exports.')).toBeTruthy() }) - fireEvent.press(screen.getByRole('button', { name: 'Choose and import file' })) + fireEvent.press(screen.getByRole('button', { name: 'Choose and import .enex' })) await waitFor(() => { + expect(mockGetDocumentAsync).toHaveBeenCalledWith( + expect.objectContaining({ + type: ['application/xml', 'text/xml'], + }) + ) expect(mockImportAsset).toHaveBeenCalledWith( expect.objectContaining({ name: 'import.enex' }), 'user-1' @@ -194,7 +199,7 @@ describe('SettingsScreen', () => { }) fireEvent.press(screen.getByRole('tab', { name: 'Export .enex file' })) - fireEvent.press(screen.getByRole('button', { name: 'Export and share' })) + fireEvent.press(screen.getByRole('button', { name: 'Export all notes' })) await waitFor(() => { expect(mockExportAllNotes).toHaveBeenCalledWith('user-1') @@ -254,7 +259,7 @@ describe('SettingsScreen', () => { renderScreen() fireEvent.press(screen.getByRole('tab', { name: 'Import .enex file' })) - fireEvent.press(screen.getByRole('button', { name: 'Choose and import file' })) + fireEvent.press(screen.getByRole('button', { name: 'Choose and import .enex' })) await waitFor(() => { expect(mockGetDocumentAsync).not.toHaveBeenCalled() @@ -262,7 +267,7 @@ describe('SettingsScreen', () => { }) fireEvent.press(screen.getByRole('tab', { name: 'Export .enex file' })) - fireEvent.press(screen.getByRole('button', { name: 'Export and share' })) + fireEvent.press(screen.getByRole('button', { name: 'Export all notes' })) await waitFor(() => { expect(mockExportAllNotes).not.toHaveBeenCalled() @@ -282,14 +287,14 @@ describe('SettingsScreen', () => { renderScreen() fireEvent.press(screen.getByRole('tab', { name: 'Import .enex file' })) - fireEvent.press(screen.getByRole('button', { name: 'Choose and import 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 file' })) + fireEvent.press(screen.getByRole('button', { name: 'Choose and import .enex' })) await waitFor(() => { expect(screen.getByText('Imported 1 note(s) with 1 error(s).')).toBeTruthy() @@ -299,7 +304,7 @@ describe('SettingsScreen', () => { mockIsAvailableAsync.mockResolvedValueOnce(false) fireEvent.press(screen.getByRole('tab', { name: 'Export .enex file' })) - fireEvent.press(screen.getByRole('button', { name: 'Export and share' })) + fireEvent.press(screen.getByRole('button', { name: 'Export all notes' })) await waitFor(() => { expect(screen.getByText('Sharing is unavailable on this device')).toBeTruthy() @@ -310,7 +315,7 @@ describe('SettingsScreen', () => { mockIsAvailableAsync.mockResolvedValueOnce(true) mockExportAllNotes.mockRejectedValueOnce(new Error('Export failed')) - fireEvent.press(screen.getByRole('button', { name: 'Export and share' })) + fireEvent.press(screen.getByRole('button', { name: 'Export all notes' })) await waitFor(() => { expect(screen.getByText('Export failed')).toBeTruthy() @@ -318,6 +323,29 @@ describe('SettingsScreen', () => { }) }) + 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('Please choose an .enex export file.')).toBeTruthy() + expect(mockImportAsset).not.toHaveBeenCalled() + }) + }) + it('shows WordPress load, validation, and save errors', async () => { const testApplicationPassword = 'test-value' mockWordPressGetStatus.mockRejectedValueOnce(new Error('Load failed')) diff --git a/ui/mobile/tests/unit/input.test.tsx b/ui/mobile/tests/unit/input.test.tsx new file mode 100644 index 00000000000..98ac5e765e8 --- /dev/null +++ b/ui/mobile/tests/unit/input.test.tsx @@ -0,0 +1,39 @@ +import React from 'react' +import { render, screen } from '@testing-library/react-native' +import { StyleSheet } from 'react-native' + +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', + }) + ) + }) +}) diff --git a/ui/mobile/tests/unit/settingsPanels.test.tsx b/ui/mobile/tests/unit/settingsPanels.test.tsx new file mode 100644 index 00000000000..66647af1182 --- /dev/null +++ b/ui/mobile/tests/unit/settingsPanels.test.tsx @@ -0,0 +1,241 @@ +import React from 'react' +import { fireEvent, render, screen, waitFor } from '@testing-library/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', () => { + beforeEach(() => { + 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: ['application/xml', 'text/xml'], + copyToCacheDirectory: true, + multiple: false, + }) + expect(mockImportAsset).toHaveBeenCalledWith( + expect.objectContaining({ name: 'import.enex' }), + 'user-1' + ) + expect(screen.getByText('Imported 2 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('Please choose an .enex export file.')).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(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('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() + }) + }) +}) From 98a43f29d02a130a9c4616e8cf6113fafe29b6de Mon Sep 17 00:00:00 2001 From: Denys Date: Sun, 15 Mar 2026 12:49:56 +0100 Subject: [PATCH 07/12] Fix mobile ENEX import --- docs/ai/implementation/feature-mobile-settings-menu.md | 1 + docs/ai/testing/feature-mobile-settings-menu.md | 1 + ui/mobile/components/settings/EnexImportPanel.tsx | 10 ++++++---- ui/mobile/tests/integration/settingsScreen.test.tsx | 7 +++++-- ui/mobile/tests/unit/settingsPanels.test.tsx | 7 +++++-- 5 files changed, 18 insertions(+), 8 deletions(-) diff --git a/docs/ai/implementation/feature-mobile-settings-menu.md b/docs/ai/implementation/feature-mobile-settings-menu.md index d8bb0b631cc..2ba001124a0 100644 --- a/docs/ai/implementation/feature-mobile-settings-menu.md +++ b/docs/ai/implementation/feature-mobile-settings-menu.md @@ -81,6 +81,7 @@ description: Implementation notes for the redesigned mobile settings screen with - 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. - 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. diff --git a/docs/ai/testing/feature-mobile-settings-menu.md b/docs/ai/testing/feature-mobile-settings-menu.md index 73e699386f8..a70ff8d4d9b 100644 --- a/docs/ai/testing/feature-mobile-settings-menu.md +++ b/docs/ai/testing/feature-mobile-settings-menu.md @@ -30,6 +30,7 @@ description: Testing plan for the redesigned mobile settings screen and its nati - [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] 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. diff --git a/ui/mobile/components/settings/EnexImportPanel.tsx b/ui/mobile/components/settings/EnexImportPanel.tsx index fc303b0b2d1..173f7f1248e 100644 --- a/ui/mobile/components/settings/EnexImportPanel.tsx +++ b/ui/mobile/components/settings/EnexImportPanel.tsx @@ -1,5 +1,5 @@ import { useMemo, useState } from 'react' -import { ActivityIndicator, StyleSheet, Text, View } from 'react-native' +import { ActivityIndicator, Platform, StyleSheet, Text, View } from 'react-native' import { Upload } from 'lucide-react-native' import * as DocumentPicker from 'expo-document-picker' @@ -13,7 +13,7 @@ type FeedbackState = | { variant: 'error' | 'success' | 'info'; message: string } | null -const enexPickerTypes = ['application/xml', 'text/xml'] +const enexPickerType = Platform.OS === 'android' ? '*/*' : ['application/xml', 'text/xml'] const isEnexFileName = (fileName: string) => /\.enex$/i.test(fileName.trim()) @@ -35,8 +35,10 @@ export function EnexImportPanel() { } 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: enexPickerTypes, + type: enexPickerType, copyToCacheDirectory: true, multiple: false, }) @@ -51,7 +53,7 @@ export function EnexImportPanel() { if (!isEnexFileName(asset.name)) { setFeedback({ variant: 'error', - message: 'Please choose an .enex export file.', + message: 'Only Evernote .enex export files are supported.', }) return } diff --git a/ui/mobile/tests/integration/settingsScreen.test.tsx b/ui/mobile/tests/integration/settingsScreen.test.tsx index 81ab58fc5d4..96580df9662 100644 --- a/ui/mobile/tests/integration/settingsScreen.test.tsx +++ b/ui/mobile/tests/integration/settingsScreen.test.tsx @@ -1,6 +1,7 @@ import React from 'react' import { fireEvent, render, screen, waitFor } from '@testing-library/react-native' import AsyncStorage from '@react-native-async-storage/async-storage' +import { Platform } from 'react-native' import { SafeAreaProvider } from 'react-native-safe-area-context' import { ThemeProvider } from '@ui/mobile/providers/ThemeProvider' @@ -72,6 +73,8 @@ const renderScreen = () => ) describe('SettingsScreen', () => { + const expectedPickerType = Platform.OS === 'android' ? '*/*' : ['application/xml', 'text/xml'] + beforeEach(() => { const mockAsyncStorage = AsyncStorage as jest.Mocked mockAsyncStorage.getItem.mockResolvedValue('light') @@ -188,7 +191,7 @@ describe('SettingsScreen', () => { await waitFor(() => { expect(mockGetDocumentAsync).toHaveBeenCalledWith( expect.objectContaining({ - type: ['application/xml', 'text/xml'], + type: expectedPickerType, }) ) expect(mockImportAsset).toHaveBeenCalledWith( @@ -341,7 +344,7 @@ describe('SettingsScreen', () => { fireEvent.press(screen.getByRole('button', { name: 'Choose and import .enex' })) await waitFor(() => { - expect(screen.getByText('Please choose an .enex export file.')).toBeTruthy() + expect(screen.getByText('Only Evernote .enex export files are supported.')).toBeTruthy() expect(mockImportAsset).not.toHaveBeenCalled() }) }) diff --git a/ui/mobile/tests/unit/settingsPanels.test.tsx b/ui/mobile/tests/unit/settingsPanels.test.tsx index 66647af1182..54301f20cad 100644 --- a/ui/mobile/tests/unit/settingsPanels.test.tsx +++ b/ui/mobile/tests/unit/settingsPanels.test.tsx @@ -1,5 +1,6 @@ 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() @@ -72,6 +73,8 @@ import { WordPressSettingsPanel } from '@ui/mobile/components/settings/WordPress const renderWithTheme = (ui: React.ReactElement) => render(ui) describe('settings panels', () => { + const expectedPickerType = Platform.OS === 'android' ? '*/*' : ['application/xml', 'text/xml'] + beforeEach(() => { mockUseSupabase.mockReturnValue({ client: {}, @@ -134,7 +137,7 @@ describe('settings panels', () => { await waitFor(() => { expect(mockGetDocumentAsync).toHaveBeenCalledWith({ - type: ['application/xml', 'text/xml'], + type: expectedPickerType, copyToCacheDirectory: true, multiple: false, }) @@ -163,7 +166,7 @@ describe('settings panels', () => { fireEvent.press(screen.getByRole('button', { name: 'Choose and import .enex' })) await waitFor(() => { - expect(screen.getByText('Please choose an .enex export file.')).toBeTruthy() + expect(screen.getByText('Only Evernote .enex export files are supported.')).toBeTruthy() expect(mockImportAsset).not.toHaveBeenCalled() }) }) From b8a852e07a0c56a9653702e71efdd68ae673209e Mon Sep 17 00:00:00 2001 From: Denys Date: Sun, 15 Mar 2026 13:28:29 +0100 Subject: [PATCH 08/12] Fix mobile settings interactions --- .../feature-mobile-settings-menu.md | 2 + .../testing/feature-mobile-settings-menu.md | 2 + .../settings/ApiKeysSettingsPanel.tsx | 1 + .../components/settings/EnexExportPanel.tsx | 34 ++++++++- .../components/settings/EnexImportPanel.tsx | 22 +++++- .../settings/WordPressSettingsPanel.tsx | 1 + ui/mobile/components/ui/Input.tsx | 6 +- ui/mobile/services/enexExport.ts | 17 ++++- ui/mobile/services/enexImport.ts | 29 ++++++- .../tests/integration/settingsScreen.test.tsx | 68 ++++++++++++++++- ui/mobile/tests/unit/input.test.tsx | 8 ++ ui/mobile/tests/unit/settingsPanels.test.tsx | 76 ++++++++++++++++++- 12 files changed, 252 insertions(+), 14 deletions(-) diff --git a/docs/ai/implementation/feature-mobile-settings-menu.md b/docs/ai/implementation/feature-mobile-settings-menu.md index 2ba001124a0..e759b5fd67c 100644 --- a/docs/ai/implementation/feature-mobile-settings-menu.md +++ b/docs/ai/implementation/feature-mobile-settings-menu.md @@ -86,11 +86,13 @@ description: Implementation notes for the redesigned mobile settings screen with - Parse note entries with a RN-safe XML parser. - Preserve title, tags, timestamps, and note body HTML where possible. - Create notes through `NoteCreator` using duplicate strategy `prefix`. + - 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. diff --git a/docs/ai/testing/feature-mobile-settings-menu.md b/docs/ai/testing/feature-mobile-settings-menu.md index a70ff8d4d9b..390b7a19d54 100644 --- a/docs/ai/testing/feature-mobile-settings-menu.md +++ b/docs/ai/testing/feature-mobile-settings-menu.md @@ -18,11 +18,13 @@ description: Testing plan for the redesigned mobile settings screen and its nati ### 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. - [ ] 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. diff --git a/ui/mobile/components/settings/ApiKeysSettingsPanel.tsx b/ui/mobile/components/settings/ApiKeysSettingsPanel.tsx index fe1c5811a5f..b9cbf9e197f 100644 --- a/ui/mobile/components/settings/ApiKeysSettingsPanel.tsx +++ b/ui/mobile/components/settings/ApiKeysSettingsPanel.tsx @@ -108,6 +108,7 @@ export function ApiKeysSettingsPanel() { autoCorrect={false} placeholder="AIzaSy..." secureTextEntry + disabled={isLoading || isSaving} /> {feedback ? : null} diff --git a/ui/mobile/components/settings/EnexExportPanel.tsx b/ui/mobile/components/settings/EnexExportPanel.tsx index 0dbd725b404..ed50957aca7 100644 --- a/ui/mobile/components/settings/EnexExportPanel.tsx +++ b/ui/mobile/components/settings/EnexExportPanel.tsx @@ -13,11 +13,36 @@ 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.', @@ -30,6 +55,7 @@ export function EnexExportPanel() { } setIsExporting(true) + setProgress({ stage: 'loading', loaded: 0, total: 0 }) setFeedback(null) try { @@ -38,7 +64,10 @@ export function EnexExportPanel() { throw new Error('Sharing is unavailable on this device') } - const result = await new MobileEnexExportService(client).exportAllNotes(user.id) + 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', @@ -56,6 +85,7 @@ export function EnexExportPanel() { }) } finally { setIsExporting(false) + setProgress(null) } } @@ -68,7 +98,7 @@ export function EnexExportPanel() { {isExporting ? ( - Preparing your archive... + {getExportProgressLabel(progress)} ) : null} diff --git a/ui/mobile/components/settings/EnexImportPanel.tsx b/ui/mobile/components/settings/EnexImportPanel.tsx index 173f7f1248e..af307656a99 100644 --- a/ui/mobile/components/settings/EnexImportPanel.tsx +++ b/ui/mobile/components/settings/EnexImportPanel.tsx @@ -13,6 +13,11 @@ 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()) @@ -23,6 +28,7 @@ export function EnexImportPanel() { const styles = useMemo(() => createStyles(colors), [colors]) const [isImporting, setIsImporting] = useState(false) const [selectedFileName, setSelectedFileName] = useState(null) + const [progress, setProgress] = useState(null) const [feedback, setFeedback] = useState({ variant: 'info', message: 'Choose an .enex export from your device to import notes into EverFreeNote.', @@ -59,9 +65,16 @@ export function EnexImportPanel() { } setIsImporting(true) + setProgress({ stage: 'reading' }) setFeedback(null) - const summary = await new MobileEnexImportService(client).importAsset(asset, user.id) + const summary = await new MobileEnexImportService(client).importAsset(asset, user.id, (next) => { + setProgress({ + stage: 'importing', + processed: next.processed, + total: next.total, + }) + }) setFeedback({ variant: summary.errors > 0 ? 'info' : 'success', message: summary.message }) } catch (error) { setFeedback({ @@ -70,6 +83,7 @@ export function EnexImportPanel() { }) } finally { setIsImporting(false) + setProgress(null) } } @@ -87,7 +101,11 @@ export function EnexImportPanel() { {isImporting ? ( - Importing notes... + + {progress?.stage === 'importing' + ? `Importing notes ${progress.processed} / ${progress.total}...` + : 'Reading .enex file...'} + ) : null} diff --git a/ui/mobile/components/settings/WordPressSettingsPanel.tsx b/ui/mobile/components/settings/WordPressSettingsPanel.tsx index 6503d081a03..13518858860 100644 --- a/ui/mobile/components/settings/WordPressSettingsPanel.tsx +++ b/ui/mobile/components/settings/WordPressSettingsPanel.tsx @@ -165,6 +165,7 @@ export function WordPressSettingsPanel() { /> ( - ({ 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 ( @@ -50,9 +52,11 @@ export const Input = forwardRef( color: colors.foreground, backgroundColor: colors.background, textAlignVertical: 'center', + opacity: isEditable === false ? 0.65 : 1, }, style, ]} + editable={isEditable} {...props} /> {error && ( diff --git a/ui/mobile/services/enexExport.ts b/ui/mobile/services/enexExport.ts index acf65483a97..e7bf381afe5 100644 --- a/ui/mobile/services/enexExport.ts +++ b/ui/mobile/services/enexExport.ts @@ -8,6 +8,11 @@ import { buildEnexExportFileName, toEnexExportNotes } from '@ui/mobile/lib/enexM 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 @@ -17,14 +22,20 @@ export class MobileEnexExportService { this.enexBuilder = new EnexBuilder() } - async exportAllNotes(userId: string): Promise<{ fileUri: string; fileName: string; noteCount: number }> { + 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 @@ -34,15 +45,17 @@ export class MobileEnexExportService { } const fileName = buildEnexExportFileName() - const baseDirectory = FileSystem.cacheDirectory ?? FileSystem.documentDirectory + 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, }) diff --git a/ui/mobile/services/enexImport.ts b/ui/mobile/services/enexImport.ts index 7ad914f6dd5..c445225572f 100644 --- a/ui/mobile/services/enexImport.ts +++ b/ui/mobile/services/enexImport.ts @@ -7,6 +7,11 @@ import { NoteCreator } from '@core/enex/note-creator' import type { ImportResult } from '@core/enex/types' import { parseEnexXml } from '@ui/mobile/lib/enexMobile' +export type ImportProgress = { + processed: number + total: number +} + export class MobileEnexImportService { private readonly noteCreator: NoteCreator @@ -14,21 +19,31 @@ export class MobileEnexImportService { this.noteCreator = new NoteCreator(supabase) } - async importAsset(asset: DocumentPickerAsset, userId: string): Promise { + async importAsset( + asset: DocumentPickerAsset, + userId: string, + onProgress?: (progress: ImportProgress) => void + ): Promise { const xml = await FileSystem.readAsStringAsync(asset.uri, { encoding: FileSystem.EncodingType.UTF8, }) - return this.importXml(xml, userId) + return this.importXml(xml, userId, onProgress) } - async importXml(xml: string, userId: string): Promise { + async importXml( + xml: string, + userId: string, + 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') } + onProgress?.({ processed: 0, total: parsedNotes.length }) + let success = 0 let errors = 0 const failedNotes: ImportResult['failedNotes'] = [] @@ -38,6 +53,12 @@ export class MobileEnexImportService { const created = await this.noteCreator.create(note, userId, 'prefix') if (created) { success += 1 + } else { + errors += 1 + failedNotes.push({ + title: note.title, + error: 'Note creator did not return a created note', + }) } } catch (error) { errors += 1 @@ -45,6 +66,8 @@ export class MobileEnexImportService { title: note.title, error: error instanceof Error ? error.message : 'Failed to import note', }) + } finally { + onProgress?.({ processed: success + errors, total: parsedNotes.length }) } } diff --git a/ui/mobile/tests/integration/settingsScreen.test.tsx b/ui/mobile/tests/integration/settingsScreen.test.tsx index 96580df9662..232e88d6430 100644 --- a/ui/mobile/tests/integration/settingsScreen.test.tsx +++ b/ui/mobile/tests/integration/settingsScreen.test.tsx @@ -76,6 +76,8 @@ describe('SettingsScreen', () => { const expectedPickerType = Platform.OS === 'android' ? '*/*' : ['application/xml', 'text/xml'] beforeEach(() => { + jest.clearAllMocks() + const mockAsyncStorage = AsyncStorage as jest.Mocked mockAsyncStorage.getItem.mockResolvedValue('light') @@ -196,7 +198,8 @@ describe('SettingsScreen', () => { ) expect(mockImportAsset).toHaveBeenCalledWith( expect.objectContaining({ name: 'import.enex' }), - 'user-1' + 'user-1', + expect.any(Function) ) expect(screen.getByText('Imported 2 note(s).')).toBeTruthy() }) @@ -205,7 +208,7 @@ describe('SettingsScreen', () => { fireEvent.press(screen.getByRole('button', { name: 'Export all notes' })) await waitFor(() => { - expect(mockExportAllNotes).toHaveBeenCalledWith('user-1') + expect(mockExportAllNotes).toHaveBeenCalledWith('user-1', expect.any(Function)) expect(mockShareAsync).toHaveBeenCalledWith( 'file:///tmp/everfreenote-export-20260314-083000.enex', expect.objectContaining({ @@ -326,6 +329,67 @@ describe('SettingsScreen', () => { }) }) + 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, 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, diff --git a/ui/mobile/tests/unit/input.test.tsx b/ui/mobile/tests/unit/input.test.tsx index 98ac5e765e8..d2a75e754cc 100644 --- a/ui/mobile/tests/unit/input.test.tsx +++ b/ui/mobile/tests/unit/input.test.tsx @@ -36,4 +36,12 @@ describe('Input', () => { }) ) }) + + it('maps disabled to a non-editable text input', () => { + render() + + const input = screen.getByPlaceholderText('Gemini API Key') + + expect(input.props.editable).toBe(false) + }) }) diff --git a/ui/mobile/tests/unit/settingsPanels.test.tsx b/ui/mobile/tests/unit/settingsPanels.test.tsx index 54301f20cad..47ec5f9a1fb 100644 --- a/ui/mobile/tests/unit/settingsPanels.test.tsx +++ b/ui/mobile/tests/unit/settingsPanels.test.tsx @@ -76,6 +76,8 @@ 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' }, @@ -143,12 +145,45 @@ describe('settings panels', () => { }) expect(mockImportAsset).toHaveBeenCalledWith( expect.objectContaining({ name: 'import.enex' }), - 'user-1' + 'user-1', + expect.any(Function) ) expect(screen.getByText('Imported 2 note(s).')).toBeTruthy() }) }) + 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, 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, @@ -177,7 +212,7 @@ describe('settings panels', () => { fireEvent.press(screen.getByRole('button', { name: 'Export all notes' })) await waitFor(() => { - expect(mockExportAllNotes).toHaveBeenCalledWith('user-1') + expect(mockExportAllNotes).toHaveBeenCalledWith('user-1', expect.any(Function)) expect(mockShareAsync).toHaveBeenCalledWith( 'file:///tmp/everfreenote-export-20260315-120000.enex', expect.objectContaining({ @@ -191,6 +226,43 @@ describe('settings panels', () => { }) }) + 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() From f3c299fab9492b1c362a1aecb1afb9fa82321dbc Mon Sep 17 00:00:00 2001 From: Denys Date: Sun, 15 Mar 2026 14:20:29 +0100 Subject: [PATCH 09/12] Add mobile duplicate handling --- core/enex/import-shared.ts | 73 +++++++++ core/enex/note-creator.ts | 6 +- .../ai/design/feature-mobile-settings-menu.md | 34 ++++ .../feature-mobile-settings-menu.md | 8 +- .../feature-mobile-settings-menu.md | 2 + .../testing/feature-mobile-settings-menu.md | 5 +- .../components/settings/EnexImportPanel.tsx | 140 +++++++++++++++- ui/mobile/services/enexImport.ts | 53 ++++-- .../tests/integration/settingsScreen.test.tsx | 6 +- ui/mobile/tests/unit/importShared.test.ts | 84 ++++++++++ .../unit/mobileEnexImportService.test.ts | 154 ++++++++++++++++++ ui/mobile/tests/unit/settingsPanels.test.tsx | 26 ++- ui/web/components/ImportButton.tsx | 29 +--- ui/web/components/ImportDialog.tsx | 54 +++--- 14 files changed, 591 insertions(+), 83 deletions(-) create mode 100644 core/enex/import-shared.ts create mode 100644 ui/mobile/tests/unit/importShared.test.ts create mode 100644 ui/mobile/tests/unit/mobileEnexImportService.test.ts diff --git a/core/enex/import-shared.ts b/core/enex/import-shared.ts new file mode 100644 index 00000000000..3cc51b121cd --- /dev/null +++ b/core/enex/import-shared.ts @@ -0,0 +1,73 @@ +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 async function fetchExistingTitles( + client: SupabaseClient, + userId: string +): Promise> { + const { data, error } = await client + .from('notes') + .select('id, title') + .eq('user_id', userId) + + if (error) { + throw new Error(`Failed to fetch existing titles: ${error.message}`) + } + + const map = new Map() + data?.forEach((row) => { + const title = row?.title + + if (typeof title !== 'string' || title.trim().length === 0) { + return + } + + if (!map.has(title)) { + map.set(title, row.id) + } + }) + + return map +} + +export async function resolveExistingTitlesForImport( + client: SupabaseClient, + userId: string, + duplicateStrategy: DuplicateStrategy +): Promise> { + try { + return await fetchExistingTitles(client, userId) + } catch (error) { + if (duplicateStrategy === 'prefix') { + console.error('Failed to fetch existing titles for prefix import:', error) + return new Map() + } + + throw new Error(DUPLICATE_LOOKUP_UNAVAILABLE_MESSAGE) + } +} diff --git a/core/enex/note-creator.ts b/core/enex/note-creator.ts index 683971dee55..19e5b9329b5 100644 --- a/core/enex/note-creator.ts +++ b/core/enex/note-creator.ts @@ -51,12 +51,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) } - return data?.id ?? null + return data.id } const { data, error } = await this.supabase @@ -66,7 +67,8 @@ 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) diff --git a/docs/ai/design/feature-mobile-settings-menu.md b/docs/ai/design/feature-mobile-settings-menu.md index 9bfa5e537ba..cf040645cf9 100644 --- a/docs/ai/design/feature-mobile-settings-menu.md +++ b/docs/ai/design/feature-mobile-settings-menu.md @@ -47,6 +47,7 @@ graph TD - 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. @@ -78,6 +79,20 @@ graph TD - 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` - `importSummary?: { success: number; errors: number; message: string }` - `errorMessage?: string` @@ -178,6 +193,25 @@ graph TD - 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`); + - 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. + - Alternatives considered - Keep current sectioned list and only remove `Soon` badges. - Open separate modal screens for each setting. diff --git a/docs/ai/implementation/feature-mobile-settings-menu.md b/docs/ai/implementation/feature-mobile-settings-menu.md index e759b5fd67c..1c7d3491803 100644 --- a/docs/ai/implementation/feature-mobile-settings-menu.md +++ b/docs/ai/implementation/feature-mobile-settings-menu.md @@ -82,10 +82,14 @@ description: Implementation notes for the redesigned mobile settings screen with - 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. - - Read file content as UTF-8 text. +- 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 and falls back safely when lookup fails. +- 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 duplicate strategy `prefix`. + - 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 diff --git a/docs/ai/requirements/feature-mobile-settings-menu.md b/docs/ai/requirements/feature-mobile-settings-menu.md index de20142e05e..1219da5797f 100644 --- a/docs/ai/requirements/feature-mobile-settings-menu.md +++ b/docs/ai/requirements/feature-mobile-settings-menu.md @@ -47,6 +47,7 @@ description: Complete the mobile settings experience with fully interactive sett - 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. @@ -59,6 +60,7 @@ description: Complete the mobile settings experience with fully interactive sett - `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. diff --git a/docs/ai/testing/feature-mobile-settings-menu.md b/docs/ai/testing/feature-mobile-settings-menu.md index 390b7a19d54..02b0195d283 100644 --- a/docs/ai/testing/feature-mobile-settings-menu.md +++ b/docs/ai/testing/feature-mobile-settings-menu.md @@ -19,6 +19,8 @@ description: Testing plan for the redesigned mobile settings screen and its nati ### 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. - [ ] Handle malformed XML / empty note payloads with user-facing errors. - [ ] Additional coverage: duplicate title handling path through import orchestration. @@ -32,6 +34,7 @@ description: Testing plan for the redesigned mobile settings screen and its nati - [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. @@ -75,7 +78,7 @@ description: Testing plan for the redesigned mobile settings screen and its nati ### Latest implementation run - Passed: `npm --prefix ui/mobile run type-check` - Passed: `npm --prefix ui/mobile run lint` -- Passed: `npx jest --runInBand tests/unit/mobileEnexHelpers.test.ts tests/unit/settingsPanels.test.tsx tests/unit/input.test.tsx tests/integration/settingsScreen.test.tsx` from `ui/mobile/` +- 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 earlier in the feature workflow: `npx ai-devkit@latest lint --feature mobile-settings-menu` ## Manual Testing diff --git a/ui/mobile/components/settings/EnexImportPanel.tsx b/ui/mobile/components/settings/EnexImportPanel.tsx index af307656a99..cf4d678a50c 100644 --- a/ui/mobile/components/settings/EnexImportPanel.tsx +++ b/ui/mobile/components/settings/EnexImportPanel.tsx @@ -1,8 +1,14 @@ import { useMemo, useState } from 'react' import { ActivityIndicator, Platform, StyleSheet, Text, View } from 'react-native' -import { Upload } from 'lucide-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' @@ -28,6 +34,7 @@ export function EnexImportPanel() { 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', @@ -68,13 +75,18 @@ export function EnexImportPanel() { setProgress({ stage: 'reading' }) setFeedback(null) - const summary = await new MobileEnexImportService(client).importAsset(asset, user.id, (next) => { - setProgress({ - stage: 'importing', - processed: next.processed, - total: next.total, - }) - }) + 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 ? 'info' : 'success', message: summary.message }) } catch (error) { setFeedback({ @@ -98,6 +110,72 @@ export function EnexImportPanel() { {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 ? ( @@ -139,6 +217,52 @@ const createStyles = (colors: ReturnType['colors']) => 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', diff --git a/ui/mobile/services/enexImport.ts b/ui/mobile/services/enexImport.ts index c445225572f..625bb5f1a8e 100644 --- a/ui/mobile/services/enexImport.ts +++ b/ui/mobile/services/enexImport.ts @@ -3,8 +3,12 @@ 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 } from '@core/enex/types' +import type { ImportResult, ImportSettings } from '@core/enex/types' import { parseEnexXml } from '@ui/mobile/lib/enexMobile' export type ImportProgress = { @@ -13,27 +17,31 @@ export type ImportProgress = { } 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 { const xml = await FileSystem.readAsStringAsync(asset.uri, { encoding: FileSystem.EncodingType.UTF8, }) - return this.importXml(xml, userId, onProgress) + 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) @@ -42,23 +50,37 @@ export class MobileEnexImportService { throw new Error('No importable notes were found in the selected .enex file') } + const existingByTitle = await resolveExistingTitlesForImport( + this.supabase, + userId, + settings.duplicateStrategy + ) + 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, 'prefix') + const created = await this.noteCreator.create( + note, + userId, + settings.duplicateStrategy, + { + skipFileDuplicates: settings.skipFileDuplicates, + existingByTitle, + seenTitlesInImport, + } + ) + if (created) { success += 1 } else { - errors += 1 - failedNotes.push({ - title: note.title, - error: 'Note creator did not return a created note', - }) + skipped += 1 } } catch (error) { errors += 1 @@ -67,18 +89,23 @@ export class MobileEnexImportService { error: error instanceof Error ? error.message : 'Failed to import note', }) } finally { - onProgress?.({ processed: success + errors, total: parsedNotes.length }) + 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: - errors > 0 - ? `Imported ${success} note(s) with ${errors} error(s).` - : `Imported ${success} note(s).`, + message: `${messageParts.join(', ')}.`, } } } diff --git a/ui/mobile/tests/integration/settingsScreen.test.tsx b/ui/mobile/tests/integration/settingsScreen.test.tsx index 232e88d6430..fe14177ce67 100644 --- a/ui/mobile/tests/integration/settingsScreen.test.tsx +++ b/ui/mobile/tests/integration/settingsScreen.test.tsx @@ -199,6 +199,10 @@ describe('SettingsScreen', () => { 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() @@ -339,7 +343,7 @@ describe('SettingsScreen', () => { resolveExport = resolve }) - mockImportAsset.mockImplementationOnce(async (_asset, _userId, onProgress) => { + mockImportAsset.mockImplementationOnce(async (_asset, _userId, _settings, onProgress) => { onProgress?.({ processed: 2, total: 5 }) await importCompleted return { diff --git a/ui/mobile/tests/unit/importShared.test.ts b/ui/mobile/tests/unit/importShared.test.ts new file mode 100644 index 00000000000..0edfe58cba7 --- /dev/null +++ b/ui/mobile/tests/unit/importShared.test.ts @@ -0,0 +1,84 @@ +import { + DUPLICATE_LOOKUP_UNAVAILABLE_MESSAGE, + fetchExistingTitles, + resolveExistingTitlesForImport, +} from '@core/enex/import-shared' + +type QueryResult = { + data: Array<{ id: string; title: string | null | undefined }> | null + error: { message: string } | null +} + +function createSupabaseClient(result: QueryResult) { + return { + from: jest.fn(() => ({ + select: jest.fn(() => ({ + eq: jest.fn(async () => result), + })), + })), + } 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 an empty snapshot 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).toEqual(new Map()) + 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']]) + ) + }) +}) diff --git a/ui/mobile/tests/unit/mobileEnexImportService.test.ts b/ui/mobile/tests/unit/mobileEnexImportService.test.ts new file mode 100644 index 00000000000..39aa035d2b8 --- /dev/null +++ b/ui/mobile/tests/unit/mobileEnexImportService.test.ts @@ -0,0 +1,154 @@ +const mockCreate = jest.fn() +const mockResolveExistingTitlesForImport = jest.fn() + +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() + 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 an empty snapshot when prefix lookup fails', async () => { + mockResolveExistingTitlesForImport.mockResolvedValueOnce(new Map()) + 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(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() + }) +}) diff --git a/ui/mobile/tests/unit/settingsPanels.test.tsx b/ui/mobile/tests/unit/settingsPanels.test.tsx index 47ec5f9a1fb..eba9a635df8 100644 --- a/ui/mobile/tests/unit/settingsPanels.test.tsx +++ b/ui/mobile/tests/unit/settingsPanels.test.tsx @@ -146,19 +146,43 @@ describe('settings panels', () => { 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, onProgress) => { + mockImportAsset.mockImplementationOnce(async (_asset, _userId, _settings, onProgress) => { onProgress?.({ processed: 1, total: 3 }) await importCompleted return { diff --git a/ui/web/components/ImportButton.tsx b/ui/web/components/ImportButton.tsx index a040de7b261..8b0385b2605 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, @@ -155,7 +134,11 @@ 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 seenTitlesInImport = new Set() 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))} />
From d58195b5937a2e7800b2bc17611d1ecc46e79292 Mon Sep 17 00:00:00 2001 From: Denys Date: Sun, 15 Mar 2026 14:32:00 +0100 Subject: [PATCH 10/12] Handle null title map lookup --- core/enex/import-shared.ts | 4 ++-- core/enex/note-creator.ts | 19 ++++++++++++++----- .../feature-mobile-settings-menu.md | 2 +- ui/mobile/tests/unit/importShared.test.ts | 2 +- .../unit/mobileEnexImportService.test.ts | 12 ++++++++++-- 5 files changed, 28 insertions(+), 11 deletions(-) diff --git a/core/enex/import-shared.ts b/core/enex/import-shared.ts index 3cc51b121cd..9c651e465af 100644 --- a/core/enex/import-shared.ts +++ b/core/enex/import-shared.ts @@ -59,13 +59,13 @@ export async function resolveExistingTitlesForImport( client: SupabaseClient, userId: string, duplicateStrategy: DuplicateStrategy -): Promise> { +): 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 new Map() + return null } throw new Error(DUPLICATE_LOOKUP_UNAVAILABLE_MESSAGE) diff --git a/core/enex/note-creator.ts b/core/enex/note-creator.ts index 19e5b9329b5..74924e99fbc 100644 --- a/core/enex/note-creator.ts +++ b/core/enex/note-creator.ts @@ -21,7 +21,7 @@ export class NoteCreator { duplicateStrategy: DuplicateStrategy = 'prefix', duplicateContext?: { skipFileDuplicates: boolean - existingByTitle: Map + existingByTitle: Map | null seenTitlesInImport: Set } ): Promise { @@ -93,7 +93,7 @@ export class NoteCreator { strategy: DuplicateStrategy, context?: { skipFileDuplicates: boolean - existingByTitle: Map + existingByTitle: Map | null seenTitlesInImport: Set } ): Promise { @@ -104,12 +104,13 @@ export class NoteCreator { return { title, skip: true, replace: false } } + if (!existingByTitle) { + return this.lookupExistingDuplicate(title, userId, strategy) + } + const existingId = existingByTitle.get(title) if (!existingId) { - if (skipFileDuplicates) { - seenTitlesInImport.add(title) - } return { title, skip: false, replace: false } } @@ -124,6 +125,14 @@ export class NoteCreator { } } + return this.lookupExistingDuplicate(title, userId, strategy) + } + + private async lookupExistingDuplicate( + title: string, + userId: string, + strategy: DuplicateStrategy + ): Promise { const { data, error } = await this.supabase .from('notes') .select('id, title') diff --git a/docs/ai/implementation/feature-mobile-settings-menu.md b/docs/ai/implementation/feature-mobile-settings-menu.md index 1c7d3491803..7038cb766d9 100644 --- a/docs/ai/implementation/feature-mobile-settings-menu.md +++ b/docs/ai/implementation/feature-mobile-settings-menu.md @@ -85,7 +85,7 @@ description: Implementation notes for the redesigned mobile settings screen with - 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 and falls back safely when lookup fails. + - `prefix` remains available and falls back to per-note duplicate lookup when the shared snapshot is unavailable. - 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. diff --git a/ui/mobile/tests/unit/importShared.test.ts b/ui/mobile/tests/unit/importShared.test.ts index 0edfe58cba7..19b8fc911b8 100644 --- a/ui/mobile/tests/unit/importShared.test.ts +++ b/ui/mobile/tests/unit/importShared.test.ts @@ -44,7 +44,7 @@ describe('import-shared helpers', () => { const existingByTitle = await resolveExistingTitlesForImport(client, 'user-1', 'prefix') - expect(existingByTitle).toEqual(new Map()) + expect(existingByTitle).toBeNull() expect(consoleErrorSpy).toHaveBeenCalledWith( 'Failed to fetch existing titles for prefix import:', expect.any(Error) diff --git a/ui/mobile/tests/unit/mobileEnexImportService.test.ts b/ui/mobile/tests/unit/mobileEnexImportService.test.ts index 39aa035d2b8..9637e8c28e6 100644 --- a/ui/mobile/tests/unit/mobileEnexImportService.test.ts +++ b/ui/mobile/tests/unit/mobileEnexImportService.test.ts @@ -88,8 +88,8 @@ describe('MobileEnexImportService', () => { }) }) - it('falls back to an empty snapshot when prefix lookup fails', async () => { - mockResolveExistingTitlesForImport.mockResolvedValueOnce(new Map()) + 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) @@ -116,6 +116,14 @@ describe('MobileEnexImportService', () => { '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, From 9747f3385ef4d13d303436973db3e5f73c35595c Mon Sep 17 00:00:00 2001 From: Denys Date: Sun, 15 Mar 2026 14:48:11 +0100 Subject: [PATCH 11/12] Resolve PR 72 review comments --- core/enex/import-shared.ts | 3 +- .../ai/design/feature-mobile-settings-menu.md | 31 ++++++++----- .../testing/feature-mobile-settings-menu.md | 6 +-- .../components/settings/EnexImportPanel.tsx | 2 +- ui/mobile/services/enexImport.ts | 10 +++++ .../tests/integration/settingsScreen.test.tsx | 12 ++--- ui/mobile/tests/unit/importShared.test.ts | 10 +++-- ui/mobile/tests/unit/input.test.tsx | 32 +++++++++++++ .../unit/mobileEnexImportService.test.ts | 28 ++++++++++++ ui/web/components/ImportButton.tsx | 45 ++++++++++++++----- 10 files changed, 144 insertions(+), 35 deletions(-) diff --git a/core/enex/import-shared.ts b/core/enex/import-shared.ts index 9c651e465af..1384938523f 100644 --- a/core/enex/import-shared.ts +++ b/core/enex/import-shared.ts @@ -32,8 +32,9 @@ export async function fetchExistingTitles( ): Promise> { const { data, error } = await client .from('notes') - .select('id, title') + .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}`) diff --git a/docs/ai/design/feature-mobile-settings-menu.md b/docs/ai/design/feature-mobile-settings-menu.md index cf040645cf9..3c7676bb1da 100644 --- a/docs/ai/design/feature-mobile-settings-menu.md +++ b/docs/ai/design/feature-mobile-settings-menu.md @@ -64,11 +64,10 @@ graph TD - Core UI model - `SettingsTabKey = "account" | "import" | "export" | "wordpress" | "apiKeys"` - - Each tab has: - - label - - icon - - description - - render function / component + - `SettingsTabDefinition` currently includes: + - `key` + - `label` + - Iconography, descriptions, and render/component wiring live outside `SettingsTabDefinition` in the current implementation. - Account panel state - `email: string` @@ -94,8 +93,13 @@ graph TD - 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` - - `importSummary?: { success: number; errors: number; message: string }` - - `errorMessage?: string` + - `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` @@ -135,8 +139,15 @@ graph TD - `NoteCreator.create()` - New mobile-only service interfaces - - `MobileEnexImportService.importDocument(options): Promise` - - `MobileEnexExportService.exportAllNotes(userId): Promise<{ fileUri: string; fileName: string; noteCount: number }>` + - `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: @@ -204,7 +215,7 @@ graph TD - `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`); + - 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: diff --git a/docs/ai/testing/feature-mobile-settings-menu.md b/docs/ai/testing/feature-mobile-settings-menu.md index 02b0195d283..959b5d927f0 100644 --- a/docs/ai/testing/feature-mobile-settings-menu.md +++ b/docs/ai/testing/feature-mobile-settings-menu.md @@ -100,9 +100,9 @@ description: Testing plan for the redesigned mobile settings screen and its nati ## Performance Testing **How do we validate performance?** -- Verify tab switching stays responsive on a typical device. -- Verify export of a moderate notes set does not freeze the UI. -- Verify import of a small `.enex` file completes without app crash. +- 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?** diff --git a/ui/mobile/components/settings/EnexImportPanel.tsx b/ui/mobile/components/settings/EnexImportPanel.tsx index cf4d678a50c..e7d48021092 100644 --- a/ui/mobile/components/settings/EnexImportPanel.tsx +++ b/ui/mobile/components/settings/EnexImportPanel.tsx @@ -87,7 +87,7 @@ export function EnexImportPanel() { }) } ) - setFeedback({ variant: summary.errors > 0 ? 'info' : 'success', message: summary.message }) + setFeedback({ variant: summary.errors > 0 ? 'error' : 'success', message: summary.message }) } catch (error) { setFeedback({ variant: 'error', diff --git a/ui/mobile/services/enexImport.ts b/ui/mobile/services/enexImport.ts index 625bb5f1a8e..cdfac66ad25 100644 --- a/ui/mobile/services/enexImport.ts +++ b/ui/mobile/services/enexImport.ts @@ -11,6 +11,8 @@ 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 @@ -31,6 +33,14 @@ export class MobileEnexImportService { 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, }) diff --git a/ui/mobile/tests/integration/settingsScreen.test.tsx b/ui/mobile/tests/integration/settingsScreen.test.tsx index fe14177ce67..a5fc41f795d 100644 --- a/ui/mobile/tests/integration/settingsScreen.test.tsx +++ b/ui/mobile/tests/integration/settingsScreen.test.tsx @@ -178,7 +178,7 @@ describe('SettingsScreen', () => { }) it('handles import, export, and WordPress save flows', async () => { - const testApplicationPassword = 'test-value' + const wordpressAppFixture = 'fixture-identifier' renderScreen() @@ -233,14 +233,14 @@ describe('SettingsScreen', () => { 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'), testApplicationPassword) + 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: testApplicationPassword, + applicationPassword: wordpressAppFixture, enabled: true, }) expect(screen.getByText('WordPress settings saved successfully.')).toBeTruthy() @@ -418,7 +418,7 @@ describe('SettingsScreen', () => { }) it('shows WordPress load, validation, and save errors', async () => { - const testApplicationPassword = 'test-value' + const wordpressAppFixture = 'fixture-identifier' mockWordPressGetStatus.mockRejectedValueOnce(new Error('Load failed')) mockWordPressUpsert.mockRejectedValueOnce(new Error('Save failed')) @@ -444,14 +444,14 @@ describe('SettingsScreen', () => { expect(screen.getByText('Application password is required for the initial setup.')).toBeTruthy() }) - fireEvent.changeText(screen.getByPlaceholderText('xxxx xxxx xxxx xxxx'), testApplicationPassword) + 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: testApplicationPassword, + 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 index 19b8fc911b8..07e81a46c73 100644 --- a/ui/mobile/tests/unit/importShared.test.ts +++ b/ui/mobile/tests/unit/importShared.test.ts @@ -5,15 +5,19 @@ import { } from '@core/enex/import-shared' type QueryResult = { - data: Array<{ id: string; title: string | null | undefined }> | null + 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(async () => result), + eq: jest.fn(() => ({ + order, + })), })), })), } as never @@ -35,7 +39,7 @@ describe('import-shared helpers', () => { ) }) - it('falls back to an empty snapshot for prefix imports when lookup fails', async () => { + 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' }, diff --git a/ui/mobile/tests/unit/input.test.tsx b/ui/mobile/tests/unit/input.test.tsx index d2a75e754cc..b1b60168680 100644 --- a/ui/mobile/tests/unit/input.test.tsx +++ b/ui/mobile/tests/unit/input.test.tsx @@ -1,6 +1,7 @@ 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') @@ -41,6 +42,37 @@ describe('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/mobileEnexImportService.test.ts b/ui/mobile/tests/unit/mobileEnexImportService.test.ts index 9637e8c28e6..8a2643c6256 100644 --- a/ui/mobile/tests/unit/mobileEnexImportService.test.ts +++ b/ui/mobile/tests/unit/mobileEnexImportService.test.ts @@ -1,6 +1,15 @@ 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, @@ -22,6 +31,7 @@ import { MobileEnexImportService } from '@ui/mobile/services/enexImport' describe('MobileEnexImportService', () => { beforeEach(() => { jest.clearAllMocks() + mockReadAsStringAsync.mockResolvedValue('') mockResolveExistingTitlesForImport.mockResolvedValue(new Map([['Duplicate note', 'existing-id']])) }) @@ -159,4 +169,22 @@ describe('MobileEnexImportService', () => { ) 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/web/components/ImportButton.tsx b/ui/web/components/ImportButton.tsx index 8b0385b2605..dfc8a5c943e 100644 --- a/ui/web/components/ImportButton.tsx +++ b/ui/web/components/ImportButton.tsx @@ -110,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[] = [] @@ -183,9 +184,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, }) @@ -204,7 +206,7 @@ export function ImportButton({ onImportComplete, maxFileSize = 100 * 1024 * 1024 note.title || "Untitled" ) - await noteCreator.create( + const createdId = await noteCreator.create( { ...note, content: convertedContent, @@ -218,12 +220,16 @@ export function ImportButton({ onImportComplete, maxFileSize = 100 * 1024 * 1024 } ) - 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 @@ -239,7 +245,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, })) } } @@ -259,12 +265,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) From 332409f09d22c985cae77272a863624f64ac4f17 Mon Sep 17 00:00:00 2001 From: Denys Date: Sun, 15 Mar 2026 15:55:34 +0100 Subject: [PATCH 12/12] Handle duplicate lookup errors --- core/enex/import-shared.ts | 11 +- core/enex/note-creator.ts | 109 +++++++------ core/enex/parser.ts | 3 +- core/tests/unit/core-enex-noteCreator.test.ts | 152 ++++++++++++++++++ cypress/component/lib/enex/note-creator.cy.ts | 12 +- cypress/component/lib/enex/parser.cy.ts | 16 ++ cypress/component/ui/ImportButton.cy.tsx | 3 + .../ai/design/feature-mobile-settings-menu.md | 42 +++++ .../feature-mobile-settings-menu.md | 4 +- .../testing/feature-mobile-settings-menu.md | 5 + ui/mobile/lib/enexMobile.ts | 4 +- ui/mobile/services/enexImport.ts | 2 + ui/mobile/tests/unit/importShared.test.ts | 13 +- .../tests/unit/mobileEnexHelpers.test.ts | 17 ++ ui/web/components/ImportButton.tsx | 2 + 15 files changed, 332 insertions(+), 63 deletions(-) create mode 100644 core/tests/unit/core-enex-noteCreator.test.ts diff --git a/core/enex/import-shared.ts b/core/enex/import-shared.ts index 1384938523f..ad80690ea43 100644 --- a/core/enex/import-shared.ts +++ b/core/enex/import-shared.ts @@ -26,6 +26,11 @@ export const DUPLICATE_STRATEGY_OPTIONS: ReadonlyArray<{ 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 @@ -42,11 +47,7 @@ export async function fetchExistingTitles( const map = new Map() data?.forEach((row) => { - const title = row?.title - - if (typeof title !== 'string' || title.trim().length === 0) { - return - } + const title = normalizeNoteTitle(row?.title) if (!map.has(title)) { map.set(title, row.id) diff --git a/core/enex/note-creator.ts b/core/enex/note-creator.ts index 74924e99fbc..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 | null - seenTitlesInImport: Set - } + duplicateContext?: DuplicateContext ): Promise { try { + const normalizedTitle = normalizeNoteTitle(note.title) const result = await this.handleDuplicate(note.title, userId, duplicateStrategy, duplicateContext) if (result.skip) { @@ -54,7 +59,7 @@ export class NoteCreator { 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 @@ -71,7 +76,7 @@ export class NoteCreator { const createdId = data.id if (duplicateContext?.skipFileDuplicates) { - duplicateContext.seenTitlesInImport.add(note.title) + duplicateContext.seenTitlesInImport.add(normalizedTitle) } return createdId @@ -91,73 +96,81 @@ export class NoteCreator { title: string, userId: string, strategy: DuplicateStrategy, - context?: { - skipFileDuplicates: boolean - existingByTitle: Map | null - 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 } } - if (!existingByTitle) { - return this.lookupExistingDuplicate(title, userId, strategy) - } + const existingId = + existingByTitle === null + ? await this.lookupExistingDuplicateIdWithCache(normalizedTitle, userId, context) + : (existingByTitle.get(normalizedTitle) ?? null) - const existingId = existingByTitle.get(title) + return this.buildDuplicateResult(normalizedTitle, strategy, existingId) + } - if (!existingId) { - return { title, skip: false, replace: false } - } + const existingId = await this.lookupExistingDuplicateId(normalizedTitle, userId) + 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 } - } + private buildDuplicateResult( + title: string, + strategy: DuplicateStrategy, + existingId?: string | null + ): DuplicateCheckResult { + if (!existingId) { + return { title, skip: false, replace: false } } - return this.lookupExistingDuplicate(title, userId, strategy) + 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 lookupExistingDuplicate( + private async lookupExistingDuplicateIdWithCache( title: string, userId: string, - strategy: DuplicateStrategy - ): Promise { + 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 index 3c7676bb1da..2cf9cc4f14b 100644 --- a/docs/ai/design/feature-mobile-settings-menu.md +++ b/docs/ai/design/feature-mobile-settings-menu.md @@ -222,6 +222,48 @@ graph TD - 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. diff --git a/docs/ai/implementation/feature-mobile-settings-menu.md b/docs/ai/implementation/feature-mobile-settings-menu.md index 7038cb766d9..e9239a7a080 100644 --- a/docs/ai/implementation/feature-mobile-settings-menu.md +++ b/docs/ai/implementation/feature-mobile-settings-menu.md @@ -85,7 +85,9 @@ description: Implementation notes for the redesigned mobile settings screen with - 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 and falls back to per-note duplicate lookup when the shared snapshot 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. diff --git a/docs/ai/testing/feature-mobile-settings-menu.md b/docs/ai/testing/feature-mobile-settings-menu.md index 959b5d927f0..73f31b38c8f 100644 --- a/docs/ai/testing/feature-mobile-settings-menu.md +++ b/docs/ai/testing/feature-mobile-settings-menu.md @@ -21,6 +21,8 @@ description: Testing plan for the redesigned mobile settings screen and its nati - [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. @@ -79,6 +81,9 @@ description: Testing plan for the redesigned mobile settings screen and its nati - 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 diff --git a/ui/mobile/lib/enexMobile.ts b/ui/mobile/lib/enexMobile.ts index 72b77bcbe14..69155f2742a 100644 --- a/ui/mobile/lib/enexMobile.ts +++ b/ui/mobile/lib/enexMobile.ts @@ -1,6 +1,7 @@ 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'> @@ -78,10 +79,9 @@ export const parseEnexXml = (xml: string): ParsedNote[] => { return notes.map((note) => { const rawContent = readParsedNoteContent(note.content) - const trimmedTitle = note.title?.trim() return { - title: trimmedTitle && trimmedTitle.length > 0 ? trimmedTitle : 'Untitled', + title: normalizeNoteTitle(note.title), content: extractEnexContent(rawContent), created: parseEnexDate(note.created), updated: parseEnexDate(note.updated ?? note.created), diff --git a/ui/mobile/services/enexImport.ts b/ui/mobile/services/enexImport.ts index cdfac66ad25..356cbc9ed63 100644 --- a/ui/mobile/services/enexImport.ts +++ b/ui/mobile/services/enexImport.ts @@ -65,6 +65,7 @@ export class MobileEnexImportService { userId, settings.duplicateStrategy ) + const fallbackExistingByTitle = new Map() const seenTitlesInImport = new Set() onProgress?.({ processed: 0, total: parsedNotes.length }) @@ -83,6 +84,7 @@ export class MobileEnexImportService { { skipFileDuplicates: settings.skipFileDuplicates, existingByTitle, + fallbackExistingByTitle, seenTitlesInImport, } ) diff --git a/ui/mobile/tests/unit/importShared.test.ts b/ui/mobile/tests/unit/importShared.test.ts index 07e81a46c73..deac1524b29 100644 --- a/ui/mobile/tests/unit/importShared.test.ts +++ b/ui/mobile/tests/unit/importShared.test.ts @@ -1,6 +1,7 @@ import { DUPLICATE_LOOKUP_UNAVAILABLE_MESSAGE, fetchExistingTitles, + normalizeNoteTitle, resolveExistingTitlesForImport, } from '@core/enex/import-shared' @@ -82,7 +83,17 @@ describe('import-shared helpers', () => { }) await expect(fetchExistingTitles(client, 'user-1')).resolves.toEqual( - new Map([['First note', 'note-1']]) + 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/mobileEnexHelpers.test.ts b/ui/mobile/tests/unit/mobileEnexHelpers.test.ts index 4e7b4b9d210..63374a286d8 100644 --- a/ui/mobile/tests/unit/mobileEnexHelpers.test.ts +++ b/ui/mobile/tests/unit/mobileEnexHelpers.test.ts @@ -44,6 +44,23 @@ describe('enexMobile helpers', () => { ]) }) + 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([ { diff --git a/ui/web/components/ImportButton.tsx b/ui/web/components/ImportButton.tsx index dfc8a5c943e..1c9523b8965 100644 --- a/ui/web/components/ImportButton.tsx +++ b/ui/web/components/ImportButton.tsx @@ -140,6 +140,7 @@ export function ImportButton({ onImportComplete, maxFileSize = 100 * 1024 * 1024 user.id, settings.duplicateStrategy ) + const fallbackExistingByTitle = new Map() // Отслеживаем титлы, уже встреченные в текущем сеансе импорта (внутри файла/файлов) const seenTitlesInImport = new Set() @@ -216,6 +217,7 @@ export function ImportButton({ onImportComplete, maxFileSize = 100 * 1024 * 1024 { skipFileDuplicates: settings.skipFileDuplicates, existingByTitle, + fallbackExistingByTitle, seenTitlesInImport, } )