diff --git a/.cursor/plans/multi-user-permission-system-eec4a786.plan.md b/.cursor/plans/multi-user-permission-system-eec4a786.plan.md new file mode 100644 index 0000000..a0c75ec --- /dev/null +++ b/.cursor/plans/multi-user-permission-system-eec4a786.plan.md @@ -0,0 +1,174 @@ +--- +name: Multi-User Permission System for PersonalOS +overview: "" +todos: + - id: ad878f02-5372-4ed3-99d8-e8a985857fb3 + content: Add use_personalos and admin_personalos caps in fix_versions() + status: completed + - id: 2dd8d984-d5ab-40bc-b4af-25b9c5d61a31 + content: Add map_meta_cap filter to check caps based on post ownership + status: completed + - id: 12c6aeee-d7e8-4cfc-b802-ebff48011291 + content: Test new capabilities and if the default test user (admin) has them, run tests + status: completed + - id: 5f94f234-29a0-431c-9a63-3a9e37b1e87c + content: Update create() methods to default to private status + status: completed + - id: 8c16b0d5-2312-492e-8a2f-deeeebd5eb7c + content: Write test for create methods in each module, run tests + status: completed + - id: 6fb6e7ca-a46b-4222-8307-dd11f0b1fb75 + content: Update POS_Settings to handle scope flag (global vs user) + status: completed + - id: 26501cfc-218c-4ba1-b832-ce23d9301685 + content: Write tests for the scoped settings, test if proper users have acces to them, run tests + status: completed + - id: d38f77a0-d9c0-46c1-8baa-d02598d1c30b + content: Add scope flags to all module settings declarations + status: completed + - id: 8e06cd9d-2ebd-4858-b9ba-01f3381a237e + content: Update specific modules tests for settings declarations, run tests + status: completed + - id: 41fef6f7-835a-434f-8f71-780c6a603f04 + content: Update Evernote/Readwise sync to loop through configured users + status: pending + - id: a8109789-2f8f-4afd-b730-09e9ded7192a + content: Write tests if possible,run them + status: pending + - id: 3703e080-4827-49b5-af36-beff42a08260 + content: Update IMAP to match emails to users by WP email address + status: pending + - id: 570ada98-80b8-4d80-92de-3d68448e5090 + content: write tests for imap users if possible and email matching + status: pending + - id: 0dc0199a-3930-491f-b0b9-5499601325b5 + content: Implement token-to-user mapping for ollama/podcast access tokens, write tests and test + status: pending + - id: 64230487-7d37-4883-bcae-67bbeb32ec92 + content: Filter dashboard widgets to show only current user's content + status: pending + - id: 71aad706-688a-430c-8294-670b1842987b + content: Verify REST API respects new capability checks, write tests and test + status: pending +--- + +# Multi-User Permission System for PersonalOS + +## Implementation Plan + +### 1. Simplified Capability System + +**Two capabilities:** + +| Capability | Roles | What it allows | +|------------|-------|----------------| +| `use_personalos` | Editor, Administrator | Use PersonalOS for your own notes & todos | +| `admin_personalos` | Administrator only | Access other users' private notes & todos | + +**Files to modify:** + +- [modules/class-pos-module.php](modules/class-pos-module.php) - Add `map_meta_cap` filter +- [personalos.php](personalos.php) - Add capabilities in `fix_versions()` on first install + +### 2. + +### Private Post Status by Default + +**Files to modify:** + +- [modules/notes/class-notes-module.php](modules/notes/class-notes-module.php) +- [modules/todo/class-todo-module.php](modules/todo/class-todo-module.php) + +**Changes:** + +- `create()` methods default to `post_status => 'private'` +- Starter content (prompts) can remain `publish` for sharing +- Update `autopublish_drafts()` to publish as private + +### 3. Setting Scope System (Global vs User) + +**Files to modify:** + +- [class-pos-settings.php](class-pos-settings.php) - Handle scope-based storage and UI +- [modules/class-pos-module.php](modules/class-pos-module.php) - Update `get_setting()` +- All module files - Add `'scope' => 'user'` or `'scope' => 'global'` + +**Storage:** + +- `scope === 'user'` → user meta: `pos_{module_id}_{setting_id}` +- `scope === 'global'` → wp_options: `{module_id}_{setting_id}` + +### 4. Per-User Sync Jobs (Evernote, Readwise) + +**Files to modify:** + +- [modules/class-pos-module.php](modules/class-pos-module.php) - `External_Service_Module` +- [modules/notes/class-notes-module.php](modules/notes/class-notes-module.php) - Remove `user` setting +- [modules/evernote/class-evernote-module.php](modules/evernote/class-evernote-module.php) +- [modules/readwise/class-readwise.php](modules/readwise/class-readwise.php) + +**Changes:** + +- Single cron job loops through all users with configured tokens +- Try/catch per user (one failure doesn't block others) +- Remove `notes_user` setting and `switch_to_user()` method + +### 5. IMAP: Shared Inbox with User Matching + +**Files to modify:** + +- [modules/imap/class-imap-module.php](modules/imap/class-imap-module.php) + +**Behavior:** + +- IMAP credentials are global (admin-configured) +- Match email recipient to WordPress user by email address +- Create content as the matched user + +### 6. Access Token → User Mapping + +For access tokens (ollama, podcast), need to identify which user owns the token: + +- Option A: Query users by meta to find matching token +- Option B: Encode user ID in token format + +### 7. Dashboard Widgets Filter + +**Files to modify:** + +- [modules/notes/class-notes-module.php](modules/notes/class-notes-module.php) - `notebook_admin_widget()` + +**Changes:** + +- Add `'author' => get_current_user_id()` to queries (unless admin) + +### 8. REST API Permission Updates + +**Files to modify:** + +- [modules/class-pos-module.php](modules/class-pos-module.php) - `POS_CPT_Rest_Controller` + +## Setting Scope Reference + +| Module | Setting | Scope | +|--------|---------|-------| +| **OpenAI** | `api_key` | global | +| **OpenAI** | `prompt_describe_image` | global | +| **Evernote** | `token` | user | +| **Evernote** | `synced_notebooks` | user | +| **Evernote** | `active` | user | +| **AI Podcast** | `token` | user | +| **AI Podcast** | `tts_service` | global | +| **AI Podcast** | `elevenlabs_voice` | global | +| **Readwise** | `token` | user | +| **IMAP** | all settings | global | +| **Ollama** | `ollama_auth_token` | user | + +## Follow-up Items (Not in Initial Scope) + +- **Mine/Team toggle** - UX improvement for admins to filter their own content +- **Slack module** - needs same per-user treatment +- **Daily module** - needs user context for content creation +- **Transcription module** - needs to be user-aware +- **Taxonomy privacy** - notebooks are shared for now +- **WP Admin list tables** - `pre_get_posts` filter for notes CPT \ No newline at end of file diff --git a/.github/workflows/pr-preview.yml b/.github/workflows/pr-preview.yml index 08a67a9..a1ac094 100644 --- a/.github/workflows/pr-preview.yml +++ b/.github/workflows/pr-preview.yml @@ -45,7 +45,7 @@ jobs: run: | echo "Linting changed PHP files:" echo "${{ steps.changed-files.outputs.all_changed_files }}" - vendor/bin/phpcs --standard=phpcs.xml --report=checkstyle ${{ steps.changed-files.outputs.all_changed_files }} | cs2pr + vendor/bin/phpcs --standard=phpcs.xml --report=checkstyle --runtime-set ignore_warnings_on_exit 1 ${{ steps.changed-files.outputs.all_changed_files }} | cs2pr lint-js: if: ${{ github.event.pull_request.state == 'open' && github.event.pull_request.draft == false }} diff --git a/class-pos-settings.php b/class-pos-settings.php index bafb5d9..380869a 100644 --- a/class-pos-settings.php +++ b/class-pos-settings.php @@ -8,6 +8,11 @@ public function __construct( $modules ) { if ( is_admin() ) { add_action( 'admin_menu', array( $this, 'options_page' ) ); add_action( 'admin_init', array( $this, 'settings_init' ) ); + add_action( 'show_user_profile', array( $this, 'render_user_settings_fields' ) ); + add_action( 'edit_user_profile', array( $this, 'render_user_settings_fields' ) ); + add_action( 'personal_options_update', array( $this, 'save_user_settings_fields' ) ); + add_action( 'edit_user_profile_update', array( $this, 'save_user_settings_fields' ) ); + add_action( 'admin_post_pos_save_user_settings', array( $this, 'handle_user_settings_form' ) ); } } @@ -21,103 +26,58 @@ public function __construct( $modules ) { public function settings_init() { foreach ( $this->modules as $module ) { + $settings = $module->get_settings_fields(); + $global_settings = array_filter( + $settings, + function( $setting ) { + return ( $setting['scope'] ?? 'global' ) === 'global'; + } + ); - $settings = $module->get_settings_fields(); - if ( ! empty( $settings ) ) { - add_settings_section( - 'pos_section_' . $module->id, - $module->name, - function( $args ) use ( $module ) { - echo '

' . esc_html( $module->get_module_description() ) . '

'; - }, - 'pos_' . $module->id - ); - foreach ( $settings as $setting_id => $setting ) { - if ( empty( $setting['type'] ) || empty( $setting['name'] ) ) { - continue; - } - $option_name = $module->get_setting_option_name( $setting_id ); - - // Register setting with appropriate sanitization callback - $sanitize_callback = null; - if ( $setting['type'] === 'bool' ) { - $sanitize_callback = function( $value ) { - return ! empty( $value ) ? '1' : ''; - }; - } elseif ( $setting['type'] === 'text' ) { - $sanitize_callback = 'sanitize_text_field'; - } elseif ( $setting['type'] === 'textarea' ) { - $sanitize_callback = 'sanitize_textarea_field'; - } elseif ( $setting['type'] === 'select' ) { - $sanitize_callback = 'sanitize_text_field'; - } - - register_setting( - 'pos_' . $module->id, - $option_name, - array( - 'sanitize_callback' => $sanitize_callback, - ) - ); + if ( empty( $global_settings ) ) { + continue; + } - add_settings_field( - 'pos_field_' . $setting['name'], - $setting['name'], - function() use ( $setting, $option_name, $module ) { - $value = get_option( $option_name, $setting['default'] ?? '' ); - - if ( $setting['type'] === 'text' ) { - printf( - '
', - esc_attr( $option_name ), - wp_kses_post( $value ), - wp_kses_post( $setting['label'] ) ?? '' - ); - } elseif ( $setting['type'] === 'textarea' ) { - printf( - '
', - esc_attr( $option_name ), - wp_kses_post( $value ), - wp_kses_post( $setting['label'] ) ?? '', - wp_kses_post( $setting['default'] ) ?? '' - ); - } elseif ( $setting['type'] === 'select' ) { - printf( - '
', - esc_attr( $option_name ), - wp_kses( - $this->get_select_options( $setting['options'], $value ), - array( - 'option' => array( - 'value' => array(), - 'selected' => array(), - ), - ) - ), - wp_kses_post( $setting['label'] ) ?? '' - ); - } elseif ( $setting['type'] === 'bool' ) { - printf( - '', - esc_attr( $option_name ), - wp_kses_post( $setting['label'] ) ?? '', - $value ? 'checked' : '' - ); - } elseif ( $setting['type'] === 'callback' && ! empty( $setting['callback'] ) && is_callable( $setting['callback'] ) ) { - call_user_func( $setting['callback'], $option_name, $value, $setting ); - } elseif ( $setting['type'] === 'callback' && ! empty( $setting['callback'] ) && is_callable( array( $module, $setting['callback'] ) ) ) { - call_user_func( array( $module, $setting['callback'] ), $option_name, $value, $setting ); - } + add_settings_section( + 'pos_section_' . $module->id, + $module->name, + function() use ( $module ) { + echo '

' . esc_html( $module->get_module_description() ) . '

'; + }, + 'pos_' . $module->id + ); - }, - 'pos_' . $module->id, - 'pos_section_' . $module->id, - array() - ); + foreach ( $global_settings as $setting_id => $setting ) { + if ( empty( $setting['type'] ) || empty( $setting['name'] ) ) { + continue; } + + $option_name = $module->get_setting_option_name( $setting_id ); + + register_setting( + 'pos_' . $module->id, + $option_name, + array( + 'sanitize_callback' => function( $value ) use ( $setting ) { + return $this->sanitize_setting_value( $setting, $value ); + }, + ) + ); + + add_settings_field( + 'pos_field_' . $setting['name'], + $setting['name'], + function() use ( $setting, $option_name, $module, $setting_id ) { + $value = $module->get_setting( $setting_id ); + $field_id = $this->generate_field_id( $option_name ); + $this->render_setting_input( $setting, $option_name, $value, $field_id, $module ); + }, + 'pos_' . $module->id, + 'pos_section_' . $module->id, + array() + ); } } - } /** @@ -135,6 +95,130 @@ public function get_select_options( $options, $value ) { return $html; } + private function render_setting_input( $setting, $field_name, $value, $field_id, $module ) { + $label = $setting['label'] ?? ''; + + if ( $setting['type'] === 'text' ) { + printf( + '
%4$s', + esc_attr( $field_name ), + esc_attr( $field_id ), + esc_attr( $value ), + wp_kses_post( $label ) + ); + } elseif ( $setting['type'] === 'textarea' ) { + printf( + '
%5$s', + esc_attr( $field_name ), + esc_attr( $field_id ), + esc_textarea( $value ), + esc_attr( $setting['default'] ?? '' ), + wp_kses_post( $label ) + ); + } elseif ( $setting['type'] === 'select' ) { + printf( + '
%4$s', + esc_attr( $field_name ), + esc_attr( $field_id ), + wp_kses( + $this->get_select_options( $setting['options'], $value ), + array( + 'option' => array( + 'value' => array(), + 'selected' => array(), + ), + ) + ), + wp_kses_post( $label ) + ); + } elseif ( $setting['type'] === 'bool' ) { + printf( + '', + esc_attr( $field_name ), + esc_attr( $field_id ), + checked( ! empty( $value ), true, false ), + wp_kses_post( $label ) + ); + } elseif ( $setting['type'] === 'callback' && ! empty( $setting['callback'] ) && is_callable( $setting['callback'] ) ) { + call_user_func( $setting['callback'], $field_name, $value, $setting ); + } elseif ( $setting['type'] === 'callback' && ! empty( $setting['callback'] ) && is_callable( array( $module, $setting['callback'] ) ) ) { + call_user_func( array( $module, $setting['callback'] ), $field_name, $value, $setting ); + } + } + + private function sanitize_setting_value( $setting, $value ) { + switch ( $setting['type'] ) { + case 'bool': + return ! empty( $value ) ? '1' : ''; + case 'text': + return sanitize_text_field( $value ?? '' ); + case 'textarea': + return sanitize_textarea_field( $value ?? '' ); + case 'select': + return sanitize_text_field( $value ?? '' ); + case 'callback': + if ( is_array( $value ) ) { + return array_map( 'sanitize_text_field', $value ); + } + return is_string( $value ) ? sanitize_text_field( $value ) : $value; + default: + return $value; + } + } + + private function generate_field_id( $field_name ) { + return sanitize_key( str_replace( array( '[', ']' ), '_', $field_name ) ); + } + + private function get_settings_by_scope( $module, $scope ) { + $settings = $module->get_settings_fields(); + return array_filter( + $settings, + function( $setting ) use ( $scope ) { + return ( $setting['scope'] ?? 'global' ) === $scope; + } + ); + } + + private function get_module_by_id( $module_id ) { + foreach ( $this->modules as $module ) { + if ( $module->id === $module_id ) { + return $module; + } + } + return null; + } + + private function render_module_user_settings_form( $module ) { + $user_settings = $this->get_settings_by_scope( $module, 'user' ); + if ( empty( $user_settings ) ) { + return; + } + + echo '
'; + echo '

' . esc_html__( 'My Settings (stored per user)', 'personalos' ) . '

'; + echo '

' . esc_html__( 'These settings apply only to your account.', 'personalos' ) . '

'; + echo '
'; + wp_nonce_field( 'pos_save_user_settings', 'pos_user_settings_nonce' ); + echo ''; + echo ''; + echo ''; + foreach ( $user_settings as $setting_id => $setting ) { + $field_name = sprintf( 'pos_user_settings[%s]', $setting_id ); + $field_id = $this->generate_field_id( $field_name ); + $value = $module->get_setting( $setting_id ); + echo ''; + printf( '', esc_attr( $field_id ), esc_html( $setting['name'] ) ); + echo ''; + } + echo '
'; + $this->render_setting_input( $setting, $field_name, $value, $field_id, $module ); + echo '
'; + submit_button( __( 'Save My Settings', 'personalos' ) ); + echo '
'; + echo '
'; + } + /** * Add the top level menu page. */ @@ -148,6 +232,117 @@ public function options_page() { ); } + public function render_user_settings_fields( $user ) { + $has_fields = false; + + foreach ( $this->modules as $module ) { + $user_settings = $this->get_settings_by_scope( $module, 'user' ); + if ( empty( $user_settings ) ) { + continue; + } + + if ( ! $has_fields ) { + echo '

' . esc_html__( 'PersonalOS Settings', 'personalos' ) . '

'; + echo '

' . esc_html__( 'Configure module settings that are specific to your account.', 'personalos' ) . '

'; + wp_nonce_field( 'pos_save_user_settings', 'pos_user_settings_nonce' ); + echo ''; + $has_fields = true; + } + + echo '

' . esc_html( $module->name ) . '

'; + echo ''; + foreach ( $user_settings as $setting_id => $setting ) { + $field_name = sprintf( 'pos_user_settings[%s][%s]', $module->id, $setting_id ); + $field_id = $this->generate_field_id( $field_name ); + $value = $module->get_setting( $setting_id, $user->ID ); + echo ''; + printf( '', esc_attr( $field_id ), esc_html( $setting['name'] ) ); + echo ''; + } + echo '
'; + $this->render_setting_input( $setting, $field_name, $value, $field_id, $module ); + echo '
'; + } + } + + public function save_user_settings_fields( $user_id ) { + if ( empty( $_POST['pos_user_settings_present'] ) ) { + return; + } + + if ( ! isset( $_POST['pos_user_settings_nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['pos_user_settings_nonce'] ) ), 'pos_save_user_settings' ) ) { + return; + } + + if ( ! current_user_can( 'edit_user', $user_id ) ) { + return; + } + + $submitted = isset( $_POST['pos_user_settings'] ) ? wp_unslash( $_POST['pos_user_settings'] ) : array(); + + foreach ( $this->modules as $module ) { + $user_settings = $this->get_settings_by_scope( $module, 'user' ); + if ( empty( $user_settings ) ) { + continue; + } + + foreach ( $user_settings as $setting_id => $setting ) { + $field_value = $submitted[ $module->id ][ $setting_id ] ?? null; + if ( $setting['type'] === 'bool' ) { + $field_value = isset( $submitted[ $module->id ][ $setting_id ] ) ? '1' : ''; + } + $sanitized_value = $this->sanitize_setting_value( $setting, $field_value ); + $module->update_setting( $setting_id, $sanitized_value, $user_id ); + } + } + } + + public function handle_user_settings_form() { + if ( ! isset( $_POST['pos_user_settings_nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['pos_user_settings_nonce'] ) ), 'pos_save_user_settings' ) ) { + wp_die( esc_html__( 'Invalid nonce specified. Settings not saved.', 'personalos' ) ); + } + + if ( ! is_user_logged_in() ) { + wp_die( esc_html__( 'You must be logged in to save settings.', 'personalos' ) ); + } + + $user_id = get_current_user_id(); + $module_id = isset( $_POST['pos_user_settings_module'] ) ? sanitize_key( wp_unslash( $_POST['pos_user_settings_module'] ) ) : ''; + $module = $this->get_module_by_id( $module_id ); + + if ( ! $module ) { + $redirect_url = wp_get_referer(); + if ( ! $redirect_url ) { + $redirect_url = admin_url( 'options-general.php?page=pos' ); + } + wp_safe_redirect( $redirect_url ); + exit; + } + + $submitted = isset( $_POST['pos_user_settings'] ) ? wp_unslash( $_POST['pos_user_settings'] ) : array(); + $user_settings = $this->get_settings_by_scope( $module, 'user' ); + + foreach ( $user_settings as $setting_id => $setting ) { + $field_value = $submitted[ $setting_id ] ?? null; + if ( 'bool' === $setting['type'] ) { + $field_value = isset( $submitted[ $setting_id ] ) ? '1' : ''; + } + $sanitized = $this->sanitize_setting_value( $setting, $field_value ); + $module->update_setting( $setting_id, $sanitized, $user_id ); + } + + wp_safe_redirect( + add_query_arg( + array( + 'page' => 'pos', + 'module' => $module_id, + ), + admin_url( 'options-general.php' ) + ) + ); + exit; + } + public function page_html() { // check user capabilities @@ -221,6 +416,11 @@ function( $mod ) { get_module_by_id( $current_tab ); + if ( $current_module ) { + $this->render_module_user_settings_form( $current_module ); + } } public static function wp_terms_select_form( $taxonomy, $selected, $select_name = '', $none_value = false, $none_label = 'Select a notebook' ) { diff --git a/modules/class-pos-module.php b/modules/class-pos-module.php index d135b77..d45e3d4 100644 --- a/modules/class-pos-module.php +++ b/modules/class-pos-module.php @@ -51,8 +51,27 @@ public function get_setting_option_name( $setting_id ) { return $this->id . '_' . $setting_id; } - public function get_setting( $id ) { - $default = isset( $this->settings[ $id ]['default'] ) ? $this->settings[ $id ]['default'] : false; + public function get_setting( $id, $user_id = null ) { + if ( ! isset( $this->settings[ $id ] ) ) { + return false; + } + + $setting = $this->settings[ $id ]; + $scope = isset( $setting['scope'] ) ? $setting['scope'] : 'global'; + $default = isset( $setting['default'] ) ? $setting['default'] : false; + + if ( 'user' === $scope ) { + $user_id = $user_id ?? get_current_user_id(); + if ( ! $user_id ) { + return $default; + } + $value = get_user_meta( $user_id, $this->get_user_setting_meta_key( $id ), true ); + if ( '' === $value || null === $value ) { + return $default; + } + return $value; + } + return get_option( $this->get_setting_option_name( $id ), $default ); } @@ -89,6 +108,12 @@ public function register_post_type( $args = array(), $redirect_to_admin = false unset( $args['labels'] ); } + if ( isset( $args['capabilities'] ) ) { + $args['capabilities'] = array_merge( $this->get_default_capabilities(), $args['capabilities'] ); + } else { + $args['capabilities'] = $this->get_default_capabilities(); + } + $defaults = array_merge( array( 'show_in_rest' => true, @@ -103,6 +128,7 @@ public function register_post_type( $args = array(), $redirect_to_admin = false 'labels' => $labels, 'supports' => array( 'title', 'excerpt', 'editor', 'custom-fields' ), 'taxonomies' => array(), + 'map_meta_cap' => true, ), $args ); @@ -110,6 +136,32 @@ public function register_post_type( $args = array(), $redirect_to_admin = false if ( $redirect_to_admin ) { add_action( 'template_redirect', array( $this, 'redirect_cpt_to_admin_edit' ) ); } + + // Filter admin list to show only user's own posts (unless admin) + add_action( 'pre_get_posts', array( $this, 'filter_admin_posts_list' ) ); + } + + /** + * Filter admin post list to show only current user's posts unless they have admin_personalos. + * + * @param WP_Query $query The query object. + */ + public function filter_admin_posts_list( $query ) { + if ( ! is_admin() || ! $query->is_main_query() ) { + return; + } + + if ( $query->get( 'post_type' ) !== $this->id ) { + return; + } + + // If user has admin_personalos, they can see all posts + if ( current_user_can( 'admin_personalos' ) ) { + return; + } + + // Otherwise, only show their own posts + $query->set( 'author', get_current_user_id() ); } public function redirect_cpt_to_admin_edit() { @@ -215,6 +267,93 @@ public function log( $message, $level = 'DEBUG' ) { \WP_CLI::line( "[{$level}] [{$this->id}] {$message}" ); } } + + protected function get_default_capabilities() { + return array( + 'edit_posts' => 'use_personalos', + 'edit_others_posts' => 'admin_personalos', + 'publish_posts' => 'use_personalos', + 'read_private_posts' => 'admin_personalos', + 'delete_posts' => 'use_personalos', + 'delete_others_posts' => 'admin_personalos', + 'delete_private_posts' => 'admin_personalos', + 'delete_published_posts' => 'use_personalos', + 'edit_private_posts' => 'use_personalos', + 'edit_published_posts' => 'use_personalos', + ); + } + + protected function get_user_setting_meta_key( $setting_id ) { + return 'pos_' . $this->id . '_' . $setting_id; + } + + /** + * Locate a WordPress user by matching a user-scoped token setting. + * + * @param string $setting_id Token setting identifier. + * @param string $token Token value to match. + * @return WP_User|null User when found and authorized, null otherwise. + */ + public function find_user_for_setting_token( string $setting_id, string $token ): ?WP_User { + if ( strlen( $token ) < 3 ) { + return null; + } + + global $wpdb; + $meta_key = $this->get_user_setting_meta_key( $setting_id ); + $user_id = $wpdb->get_var( + $wpdb->prepare( + "SELECT user_id FROM {$wpdb->usermeta} WHERE meta_key = %s AND meta_value = %s LIMIT 1", + $meta_key, + $token + ) + ); + + if ( ! $user_id ) { + return null; + } + + $user = get_user_by( 'ID', (int) $user_id ); + if ( ! $user instanceof WP_User ) { + return null; + } + + if ( ! user_can( $user, 'use_personalos' ) ) { + return null; + } + + return $user; + } + + protected function get_user_ids_with_setting( $setting_id ) { + global $wpdb; + $meta_key = $this->get_user_setting_meta_key( $setting_id ); + $user_ids = $wpdb->get_col( + $wpdb->prepare( + "SELECT user_id FROM {$wpdb->usermeta} WHERE meta_key = %s AND meta_value <> ''", + $meta_key + ) + ); + + return array_map( 'intval', $user_ids ); + } + + public function update_setting( $id, $value, $user_id = null ) { + if ( ! isset( $this->settings[ $id ] ) ) { + return false; + } + + $scope = isset( $this->settings[ $id ]['scope'] ) ? $this->settings[ $id ]['scope'] : 'global'; + if ( 'user' === $scope ) { + $user_id = $user_id ?? get_current_user_id(); + if ( ! $user_id ) { + return false; + } + return update_user_meta( $user_id, $this->get_user_setting_meta_key( $id ), $value ); + } + + return update_option( $this->get_setting_option_name( $id ), $value ); + } } class POS_CPT_Rest_Controller extends WP_REST_Posts_Controller { @@ -231,6 +370,7 @@ public function check_read_permission( $post ) { class External_Service_Module extends POS_Module { public $id = 'external_service'; public $name = 'External Service'; + protected $current_sync_user_id = 0; public function get_sync_hook_name() { return 'pos_sync_' . $this->id; @@ -247,5 +387,66 @@ public function register_sync( $interval = 'hourly' ) { public function sync() { $this->log( 'EMPTY SYNC' ); } + + protected function run_for_user( int $user_id, callable $callback ) { + $previous_user_id = get_current_user_id(); + $previous_sync_user_id = $this->current_sync_user_id; + + wp_set_current_user( $user_id ); + $this->current_sync_user_id = $user_id; + $this->reset_user_context(); + + try { + return $callback( $user_id ); + } finally { + $this->reset_user_context(); + $this->current_sync_user_id = $previous_sync_user_id; + wp_set_current_user( $previous_user_id ); + } + } + + protected function reset_user_context() { + // Allow subclasses to reset cached data between user runs. + } + + protected function get_user_state_meta_key( string $key ): string { + return 'pos_' . $this->id . '_state_' . $key; + } + + protected function get_user_state( string $key, $default = null, ?int $user_id = null ) { + $user_id = $user_id ?? $this->current_sync_user_id; + if ( ! $user_id ) { + return $default; + } + + $value = get_user_meta( $user_id, $this->get_user_state_meta_key( $key ), true ); + if ( '' === $value || null === $value ) { + return $default; + } + + return $value; + } + + protected function set_user_state( string $key, $value, ?int $user_id = null ) { + $user_id = $user_id ?? $this->current_sync_user_id; + if ( ! $user_id ) { + return false; + } + + if ( null === $value ) { + return delete_user_meta( $user_id, $this->get_user_state_meta_key( $key ) ); + } + + return update_user_meta( $user_id, $this->get_user_state_meta_key( $key ), $value ); + } + + protected function delete_user_state( string $key, ?int $user_id = null ) { + $user_id = $user_id ?? $this->current_sync_user_id; + if ( ! $user_id ) { + return false; + } + + return delete_user_meta( $user_id, $this->get_user_state_meta_key( $key ) ); + } } diff --git a/modules/evernote/class-evernote-module.php b/modules/evernote/class-evernote-module.php index 556248c..b22abd7 100644 --- a/modules/evernote/class-evernote-module.php +++ b/modules/evernote/class-evernote-module.php @@ -16,9 +16,11 @@ class Evernote_Module extends External_Service_Module { public $parent_notebook = null; public $simple_client = null; public $advanced_client = null; - public $token = null; - public $synced_notebooks = array(); - public $cached_data = null; + public $token = null; + public $synced_notebooks = array(); + public $cached_data = null; + protected $current_user_id = 0; + protected $client_user_id = 0; // phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase public $settings = array( @@ -26,17 +28,20 @@ class Evernote_Module extends External_Service_Module { 'type' => 'text', 'name' => 'Evernote Developer API Token', 'label' => 'You can get it from here', + 'scope' => 'user', ), 'synced_notebooks' => array( 'type' => 'callback', 'name' => 'Synced notebooks', 'label' => 'Comma separated list of notebooks to sync', 'default' => array(), + 'scope' => 'user', ), 'active' => array( 'type' => 'bool', 'name' => 'Evernote Sync Active', 'label' => 'If this is not checked, sync will be paused. Changes will still be pushed from WordPress to Evernote.', + 'scope' => 'user', ), ); @@ -44,10 +49,6 @@ class Evernote_Module extends External_Service_Module { public function __construct( \Notes_Module $notes_module ) { $this->notes_module = $notes_module; - $this->token = $this->get_setting( 'token' ); - if ( ! $this->token ) { - return false; - } $this->settings['synced_notebooks']['callback'] = array( $this, 'synced_notebooks_setting_callback' ); $this->register_sync( 'hourly' ); $this->register_meta( 'evernote_guid', $this->notes_module->id ); @@ -64,6 +65,16 @@ public function __construct( \Notes_Module $notes_module ) { add_action( 'wp_abilities_api_init', array( $this, 'register_abilities' ) ); } + protected function reset_user_context() { + parent::reset_user_context(); + $this->token = null; + $this->synced_notebooks = array(); + $this->cached_data = null; + $this->simple_client = null; + $this->advanced_client = null; + $this->client_user_id = 0; + } + /** * Register Evernote module abilities with WordPress Abilities API. */ @@ -148,7 +159,7 @@ function( $note ) { * : GUID of the note to sync. */ public function cli( $args ) { - $this->connect(); + $this->connect( get_current_user_id() ); $note = $this->advanced_client->getNoteStore()->getNote( $args[0], false, @@ -250,14 +261,46 @@ public function get_note_evernote_notebook_guid( int $id, string $type = 'notebo * @param bool $update */ public function sync_note_to_evernote( int $post_id, \WP_Post $post, bool $update ) { - //return;// Off for now - $this->connect(); + $author_id = (int) $post->post_author; + if ( ! $author_id ) { + return; + } + + if ( ! $this->get_setting( 'token', $author_id ) ) { + return; + } + + $synced_notebooks = (array) $this->get_setting( 'synced_notebooks', $author_id ); + if ( empty( $synced_notebooks ) ) { + return; + } + + $post_copy = clone $post; + $this->run_for_user( + $author_id, + function () use ( $post_id, $post_copy, $update, $synced_notebooks ) { + if ( ! $this->connect( $this->current_sync_user_id ) ) { + return; + } + + $this->synced_notebooks = $synced_notebooks; + $this->sync_note_to_evernote_current_user( $post_id, $post_copy, $update ); + } + ); + } + + protected function sync_note_to_evernote_current_user( int $post_id, \WP_Post $post, bool $update ) { $guid = get_post_meta( $post->ID, 'evernote_guid', true ); $url = get_post_meta( $post->ID, 'url', true ); + $note_store = $this->advanced_client ? $this->advanced_client->getNoteStore() : null; + if ( ! $note_store || ! method_exists( $note_store, 'getNote' ) ) { + return; + } + if ( $guid ) { - $note = $this->advanced_client->getNoteStore()->getNote( $guid, false, false, false, false ); + $note = $note_store->getNote( $guid, false, false, false, false ); if ( $note ) { $note->title = $post->post_title; $note->content = self::html2enml( $post->post_content ); @@ -267,7 +310,7 @@ public function sync_note_to_evernote( int $post_id, \WP_Post $post, bool $updat } try { - $result = $this->advanced_client->getNoteStore()->updateNote( $note ); + $result = $note_store->updateNote( $note ); $this->update_note_from_evernote( $result, $post ); } catch ( \EDAM\Error\EDAMSystemException $e ) { // Silently fail because conflicts and stuff. @@ -283,7 +326,7 @@ public function sync_note_to_evernote( int $post_id, \WP_Post $post, bool $updat $notebook = new \Evernote\Model\Notebook(); $evernote_notebooks = $this->get_note_evernote_notebook_guid( $post->ID, 'notebook' ); - if ( empty( $evernote_notebooks ) || ! in_array( array_values( $evernote_notebooks )[0], $this->get_setting( 'synced_notebooks' ), true ) ) { + if ( empty( $evernote_notebooks ) || ! in_array( array_values( $evernote_notebooks )[0], $this->synced_notebooks, true ) ) { return; } $notebook->guid = array_values( $evernote_notebooks )[0]; @@ -336,7 +379,8 @@ public function search_notes( $args ) { } $start += $step; $notes = array_merge( $notes, $n->notes ); - } while ( count( $notes ) < $limit ); + $note_count = count( $notes ); + } while ( $note_count < $limit ); } catch ( EDAMUserException $edue ) { $this->log( 'EDAMUserException[' . $edue->errorCode . ']: ' . $edue, E_USER_WARNING ); @@ -377,8 +421,9 @@ public function update_note_from_evernote( \EDAM\Types\Note $note, \WP_Post $pos 'post_title' => $note->title, 'post_name' => $note->guid, 'post_type' => $this->notes_module->id, - 'post_status' => 'publish', + 'post_status' => 'private', 'post_date' => gmdate( 'Y-m-d H:i:s', floor( $note->created / 1000 ) ), + 'post_author' => get_current_user_id(), ) ); // By default we are marking this as "Evernote". @@ -643,12 +688,34 @@ public function synced_notebooks_setting_callback( string $option_name, $value, } /** - * Connect to Evernote and create a client + * Connect to Evernote and create a client. + * + * @param int|null $user_id Optional user ID to use for credentials. */ - public function connect() { + public function connect( $user_id = null ) { + $user_id = $user_id ?? $this->current_sync_user_id; + if ( ! $user_id ) { + $user_id = get_current_user_id(); + } + + if ( ! $user_id ) { + return false; + } + + $token = $this->get_setting( 'token', $user_id ); + if ( ! $token ) { + return false; + } + + if ( $this->simple_client && $this->client_user_id === $user_id ) { + return $this->simple_client; + } + + $this->token = $token; + $this->client_user_id = $user_id; require_once plugin_dir_path( __FILE__ ) . '/../../vendor/autoload.php'; - $this->simple_client = new \Evernote\Client( $this->token, false ); - $this->advanced_client = new \Evernote\AdvancedClient( $this->token, false ); + $this->simple_client = new \Evernote\Client( $this->token, false ); + $this->advanced_client = new \Evernote\AdvancedClient( $this->token, false ); return $this->simple_client; } @@ -659,19 +726,51 @@ public function connect() { * @see register_sync */ public function sync() { - if ( ! $this->get_setting( 'active' ) ) { + $user_ids = $this->get_user_ids_with_setting( 'token' ); + if ( empty( $user_ids ) ) { return; } - $this->notes_module->switch_to_user(); - $this->log( 'Syncing Evernote triggering ' ); - $this->connect(); - $this->synced_notebooks = $this->get_setting( 'synced_notebooks' ); - if ( empty( $this->synced_notebooks ) || ! $this->advanced_client ) { - return array(); + + foreach ( $user_ids as $user_id ) { + if ( ! $this->get_setting( 'active', $user_id ) ) { + continue; + } + + $synced_notebooks = $this->get_setting( 'synced_notebooks', $user_id ); + if ( empty( $synced_notebooks ) ) { + continue; + } + + $this->run_for_user( + $user_id, + function () use ( $user_id, $synced_notebooks ) { + try { + $this->synced_notebooks = $synced_notebooks; + $this->sync_user( $user_id ); + } catch ( \Throwable $e ) { + $this->log( sprintf( 'User %d sync error: %s', $user_id, $e->getMessage() ), E_USER_WARNING ); + } + } + ); + } + } + + protected function sync_user( int $user_id ) { + if ( ! $this->connect( $user_id ) || ! $this->advanced_client ) { + return; + } + + $this->log( sprintf( 'Syncing Evernote for user %d', $user_id ) ); + if ( empty( $this->synced_notebooks ) ) { + $this->synced_notebooks = (array) $this->get_setting( 'synced_notebooks', $user_id ); } - $usn = get_option( $this->get_setting_option_name( 'usn' ), 0 ); - $last_sync = get_option( $this->get_setting_option_name( 'last_sync' ), 0 ); - $last_update_count = get_option( $this->get_setting_option_name( 'last_update_count' ), 0 ); + if ( empty( $this->synced_notebooks ) ) { + return; + } + + $usn = (int) $this->get_user_state( 'usn', 0, $user_id ); + $last_sync = (int) $this->get_user_state( 'last_sync', 0, $user_id ); + $last_update_count = (int) $this->get_user_state( 'last_update_count', 0, $user_id ); $sync_state = $this->advanced_client->getNoteStore()->getSyncState(); $sync_filter = new \EDAM\NoteStore\SyncChunkFilter( array( @@ -686,8 +785,8 @@ public function sync() { ) ); $sync_filter->includeExpunged = false; - update_option( $this->get_setting_option_name( 'last_sync' ), $sync_state->currentTime ); - update_option( $this->get_setting_option_name( 'last_update_count' ), $sync_state->updateCount ); + $this->set_user_state( 'last_sync', $sync_state->currentTime, $user_id ); + $this->set_user_state( 'last_update_count', $sync_state->updateCount, $user_id ); if ( $sync_state->updateCount === $last_update_count || $sync_state->updateCount === $usn ) { $this->log( 'Evernote: No updates since last sync' ); @@ -708,7 +807,7 @@ public function sync() { // We want to unschedule any regular sync events until we have initial sync complete. wp_unschedule_hook( $this->get_sync_hook_name() ); // We will schedule ONE TIME sync event for the next page. - update_option( $this->get_setting_option_name( 'usn' ), $sync_chunk->chunkHighUSN ); + $this->set_user_state( 'usn', $sync_chunk->chunkHighUSN, $user_id ); wp_schedule_single_event( time() + 60, $this->get_sync_hook_name() ); $this->log( "Scheduling next page chunk with cursor {$sync_chunk->chunkHighUSN}" ); } else { @@ -727,7 +826,7 @@ public function sync() { $this->cached_data['notebooks'][ $notebook->guid ]['default'] = true; } } - update_option( $this->get_setting_option_name( 'cached_data' ), wp_json_encode( $this->cached_data ) ); + $this->set_user_state( 'cached_data', wp_json_encode( $this->cached_data ), $user_id ); $updated_cache = true; } @@ -740,7 +839,7 @@ public function sync() { $this->cached_data['tags'][ $tag->guid ]['parent'] = $tag->parentGuid; } } - update_option( $this->get_setting_option_name( 'cached_data' ), wp_json_encode( $this->cached_data ) ); + $this->set_user_state( 'cached_data', wp_json_encode( $this->cached_data ), $user_id ); $updated_cache = true; } @@ -784,7 +883,7 @@ public function get_cached_data() { if ( ! empty( $this->cached_data ) ) { return $this->cached_data; } - $data = get_option( $this->get_setting_option_name( 'cached_data' ), false ); + $data = $this->get_user_state( 'cached_data', false ); if ( ! $data ) { $this->cached_data = array( 'notebooks' => array(), @@ -1287,19 +1386,28 @@ public function get_notes_by_guid( string $guid, $post_type = null ) { $post_type = $this->notes_module->id; } - return get_posts( - array( - 'post_type' => $post_type, - 'numberposts' => -1, - 'post_status' => array( 'publish', 'pending', 'draft', 'auto-draft', 'future', 'private', 'inherit' ), - 'meta_query' => array( - array( - 'key' => 'evernote_guid', - 'value' => $guid, - ), + $author_id = $this->current_sync_user_id; + if ( ! $author_id ) { + $author_id = get_current_user_id(); + } + + $args = array( + 'post_type' => $post_type, + 'numberposts' => -1, + 'post_status' => array( 'publish', 'pending', 'draft', 'auto-draft', 'future', 'private', 'inherit' ), + 'meta_query' => array( + array( + 'key' => 'evernote_guid', + 'value' => $guid, ), - ) + ), ); + + if ( $author_id ) { + $args['author'] = $author_id; + } + + return get_posts( $args ); } /** diff --git a/modules/imap/class-imap-module.php b/modules/imap/class-imap-module.php index 1347970..e8b8df8 100644 --- a/modules/imap/class-imap-module.php +++ b/modules/imap/class-imap-module.php @@ -109,8 +109,8 @@ public function __construct() { $this->register_sync( 'minutely' ); // Register actions to log emails - add_action( 'pos_imap_new_email', array( $this, 'log_new_email' ), 10, 1 ); - add_action( 'pos_imap_new_email_unverified', array( $this, 'log_new_email_unverified' ), 10, 1 ); + add_action( 'pos_imap_new_email', array( $this, 'log_new_email' ), 10, 3 ); + add_action( 'pos_imap_new_email_unverified', array( $this, 'log_new_email_unverified' ), 10, 3 ); } /** @@ -296,27 +296,16 @@ private function process_email( $imap, $email_id ) { 'is_trusted' => (bool) $auth_evaluation['is_trusted'], ); + $recipient_addresses = $this->extract_recipient_addresses( $header, $unfolded_headers ); + $email_data['recipients'] = $recipient_addresses; + $matched_user_ids = $this->match_recipient_user_ids( $recipient_addresses, $email_data ); + $email_data['matched_user_ids'] = $matched_user_ids; + // Trigger appropriate action based on authentication status if ( $email_data['is_trusted'] ) { - /** - * Fires when a verified/authenticated email is received. - * - * @since 0.2.4 - * - * @param array $email_data Email data with authentication passed. - * @param object $this IMAP module instance. - */ - do_action( 'pos_imap_new_email', $email_data, $this ); + $this->dispatch_email_action( 'pos_imap_new_email', $email_data, $matched_user_ids ); } else { - /** - * Fires when an unverified/unauthenticated email is received. - * - * @since 0.2.4 - * - * @param array $email_data Email data with authentication failed. - * @param object $this IMAP module instance. - */ - do_action( 'pos_imap_new_email_unverified', $email_data, $this ); + $this->dispatch_email_action( 'pos_imap_new_email_unverified', $email_data, $matched_user_ids ); } } @@ -381,6 +370,179 @@ private function mark_as_processed( string $message_id ): void { set_transient( $transient_key, true, DAY_IN_SECONDS ); } + /** + * Extract email addresses from IMAP header entries (to/cc). + * + * @param array|null $entries Header entries. + * @return array + */ + private function extract_addresses_from_header_entries( $entries ): array { + $addresses = array(); + if ( empty( $entries ) || ! is_array( $entries ) ) { + return $addresses; + } + + foreach ( $entries as $entry ) { + if ( empty( $entry->mailbox ) || empty( $entry->host ) ) { + continue; + } + $email = sanitize_email( $entry->mailbox . '@' . $entry->host ); + if ( ! empty( $email ) ) { + $addresses[] = $email; + } + } + + return $addresses; + } + + /** + * Extract addresses from raw headers (e.g., Delivered-To). + * + * @param string $headers Raw unfolded headers. + * @param string $header_name Header name to extract. + * @return array + */ + private function extract_addresses_from_raw_headers( string $headers, string $header_name ): array { + if ( '' === $headers ) { + return array(); + } + + $pattern = '/^' . preg_quote( $header_name, '/' ) . ':\s*(.+)$/mi'; + if ( 0 === preg_match_all( $pattern, $headers, $matches ) ) { + return array(); + } + + $addresses = array(); + foreach ( $matches[1] as $line ) { + $line_addresses = $this->parse_address_list( $line ); + if ( ! empty( $line_addresses ) ) { + $addresses = array_merge( $addresses, $line_addresses ); + } + } + + return $addresses; + } + + /** + * Parse address list string using IMAP helpers when available. + * + * @param string $value Header value. + * @return array + */ + private function parse_address_list( string $value ): array { + if ( '' === trim( $value ) ) { + return array(); + } + + if ( ! function_exists( 'imap_rfc822_parse_adrlist' ) ) { + $pieces = array_map( 'trim', explode( ',', $value ) ); + return array_filter( array_map( 'sanitize_email', $pieces ) ); + } + + $parsed = imap_rfc822_parse_adrlist( $value, '' ); + if ( empty( $parsed ) || ! is_array( $parsed ) ) { + return array(); + } + + $addresses = array(); + foreach ( $parsed as $entry ) { + if ( empty( $entry->mailbox ) || empty( $entry->host ) ) { + continue; + } + $email = sanitize_email( $entry->mailbox . '@' . $entry->host ); + if ( ! empty( $email ) ) { + $addresses[] = $email; + } + } + + return $addresses; + } + + /** + * Extract all recipient addresses for the message. + * + * @param object $header IMAP header info object. + * @param string $unfolded_headers Raw unfolded headers. + * @return array + */ + private function extract_recipient_addresses( $header, string $unfolded_headers ): array { + $addresses = array(); + + if ( isset( $header->to ) ) { + $addresses = array_merge( $addresses, $this->extract_addresses_from_header_entries( $header->to ) ); + } + + if ( isset( $header->cc ) ) { + $addresses = array_merge( $addresses, $this->extract_addresses_from_header_entries( $header->cc ) ); + } + + if ( empty( $addresses ) ) { + $addresses = array_merge( $addresses, $this->extract_addresses_from_raw_headers( $unfolded_headers, 'Delivered-To' ) ); + } + + $addresses = array_map( 'sanitize_email', $addresses ); + $addresses = array_filter( $addresses, 'is_email' ); + + return array_values( array_unique( $addresses ) ); + } + + /** + * Map recipient addresses to WordPress user IDs. + * + * @param array $addresses Recipient email addresses. + * @param array $email_data Email data payload. + * @return array + */ + private function match_recipient_user_ids( array $addresses, array $email_data ): array { + if ( empty( $addresses ) ) { + return array(); + } + + $user_ids = array(); + foreach ( $addresses as $address ) { + $user = get_user_by( 'email', $address ); + /** + * Filter to map recipient email addresses to WordPress users. + * + * @param WP_User|false|null $user Matched user object if found. + * @param string $address Email address being checked. + * @param array $email_data Full email data. + */ + $user = apply_filters( 'pos_imap_map_recipient_user', $user, $address, $email_data ); + if ( $user instanceof WP_User ) { + $user_ids[] = (int) $user->ID; + } + } + + return array_values( array_unique( $user_ids ) ); + } + + /** + * Dispatch IMAP email actions for each matched user. + * + * @param string $hook Hook name. + * @param array $email_data Email payload. + * @param array $user_ids Matched user IDs. + * @return void + */ + private function dispatch_email_action( string $hook, array $email_data, array $user_ids ): void { + if ( empty( $user_ids ) ) { + $email_data['matched_user_id'] = 0; + do_action( $hook, $email_data, $this, 0 ); + return; + } + + foreach ( $user_ids as $user_id ) { + $this->run_for_user( + $user_id, + function() use ( $hook, $email_data, $user_id ) { + $email_data['matched_user_id'] = $user_id; + do_action( $hook, $email_data, $this, $user_id ); + } + ); + } + } + /** * Extract domain portion from an email address. * @@ -711,8 +873,10 @@ private function sanitize_header_value( $value ) { * Log verified/trusted email (hooked to pos_imap_new_email action) * * @param array $email_data Email data. + * @param IMAP_Module|null $module Module instance (unused, provided for action parity). + * @param int $user_id Matched WordPress user ID (0 when none). */ - public function log_new_email( $email_data ) { + public function log_new_email( $email_data, $module = null, $user_id = 0 ) { // Truncate body for security (body may contain sensitive info) $body = isset( $email_data['body'] ) ? $email_data['body'] : ''; $body_preview = ! empty( $body ) ? substr( $body, 0, 200 ) : '(empty)'; @@ -720,9 +884,12 @@ public function log_new_email( $email_data ) { $body_preview .= '...'; } + $user_fragment = $user_id ? 'User #' . $user_id . ' | ' : ''; + $this->log( sprintf( - '[VERIFIED] Email - Subject: %s, From: %s, Body: %s', + '[VERIFIED] %sEmail - Subject: %s, From: %s, Body: %s', + $user_fragment, $email_data['subject'], $email_data['from'], $body_preview @@ -734,11 +901,16 @@ public function log_new_email( $email_data ) { * Log unverified/untrusted email (hooked to pos_imap_new_email_unverified action) * * @param array $email_data Email data. + * @param IMAP_Module|null $module Module instance (unused, provided for action parity). + * @param int $user_id Matched WordPress user ID (0 when none). */ - public function log_new_email_unverified( $email_data ) { + public function log_new_email_unverified( $email_data, $module = null, $user_id = 0 ) { + $user_fragment = $user_id ? 'User #' . $user_id . ' | ' : ''; + $this->log( sprintf( - '[UNVERIFIED] Email - Subject: %s, From: %s', + '[UNVERIFIED] %sEmail - Subject: %s, From: %s', + $user_fragment, $email_data['subject'], $email_data['from'] ), diff --git a/modules/notes/class-notes-module.php b/modules/notes/class-notes-module.php index 4786b8f..679b9c5 100644 --- a/modules/notes/class-notes-module.php +++ b/modules/notes/class-notes-module.php @@ -351,7 +351,7 @@ public function notebook_edit_form_fields( $term, $taxonomy ) { @@ -401,7 +401,14 @@ public function render_note_block( $attributes, $content ) { public function autopublish_drafts( $post_id, $post, $updating ) { if ( $post->post_status === 'draft' ) { - wp_publish_post( $post ); + remove_action( 'save_post_' . $this->id, array( $this, 'autopublish_drafts' ), 10 ); + wp_update_post( + array( + 'ID' => $post_id, + 'post_status' => 'private', + ) + ); + add_action( 'save_post_' . $this->id, array( $this, 'autopublish_drafts' ), 10, 3 ); } } @@ -409,7 +416,7 @@ public function create( $title, $content, $notebooks = array(), $args = array() $post = array( 'post_title' => $title, 'post_content' => $content, - 'post_status' => 'publish', + 'post_status' => 'private', 'post_type' => $this->id, ); $post = wp_parse_args( $post, $args ); @@ -543,7 +550,7 @@ function saveNote() { method: 'POST', data: { title: 'Quick Note', - status: 'publish', + status: 'private', content: document.querySelector('#quicknote textarea').value, } } ).then( function( response ) { @@ -578,6 +585,37 @@ public function register_notebook_admin_widget( $term ) { $term ); } + + protected function get_notebook_widget_posts( $post_type, $notebook_slug ) { + $args = array( + 'post_type' => $post_type, + 'post_status' => array( 'publish', 'private' ), + 'posts_per_page' => 25, + 'tax_query' => array( + array( + 'taxonomy' => 'notebook', + 'field' => 'slug', + 'terms' => array( $notebook_slug ), + ), + ), + ); + + if ( ! current_user_can( 'admin_personalos' ) ) { + $current_user_id = get_current_user_id(); + if ( $current_user_id ) { + $args['author'] = $current_user_id; + } + } + + $posts = get_posts( $args ); + return array_filter( + $posts, + function( $post ) { + return current_user_can( 'read_post', $post->ID ); + } + ); + } + public function notebook_admin_widget( $widget_config, $conf ) { $check = ''; @@ -611,28 +649,7 @@ public function notebook_admin_widget( $widget_config, $conf ) { 'defs' => array(), ) ); - $notes = get_posts( - array( - 'post_type' => $this->id, - 'post_status' => array( 'publish', 'private' ), - 'posts_per_page' => 25, - 'tax_query' => array( - array( - 'taxonomy' => 'notebook', - 'field' => 'slug', - 'terms' => array( - $conf['args']->slug, - ), - ), - ), - ) - ); - $notes = array_filter( - $notes, - function( $post ) { - return current_user_can( 'read_post', $post->ID ); - } - ); + $notes = $this->get_notebook_widget_posts( $this->id, $conf['args']->slug ); if ( count( $notes ) > 0 ) { echo '

' . esc_html( $conf['args']->name ) . ': Notes

'; $notes = array_map( @@ -644,28 +661,7 @@ function( $note ) { echo ''; } - $notes = get_posts( - array( - 'post_type' => 'todo', - 'post_status' => array( 'publish', 'private' ), - 'posts_per_page' => 25, - 'tax_query' => array( - array( - 'taxonomy' => 'notebook', - 'field' => 'slug', - 'terms' => array( - $conf['args']->slug, - ), - ), - ), - ) - ); - $notes = array_filter( - $notes, - function( $post ) { - return current_user_can( 'read_post', $post->ID ); - } - ); + $notes = $this->get_notebook_widget_posts( 'todo', $conf['args']->slug ); if ( count( $notes ) > 0 ) { echo '

' . esc_html( $conf['args']->name ) . ': TODOs

'; $notes = array_map( diff --git a/modules/openai/class-elevenlabs-module.php b/modules/openai/class-elevenlabs-module.php index 9770204..fbce262 100644 --- a/modules/openai/class-elevenlabs-module.php +++ b/modules/openai/class-elevenlabs-module.php @@ -9,11 +9,12 @@ class ElevenLabs_Module extends POS_Module { 'type' => 'text', 'name' => 'Eleven labs API Key', 'label' => '', + 'scope' => 'global', ), ); public function is_configured() { - return ! empty( $this->settings['api_key'] ); + return ! empty( $this->get_setting( 'api_key' ) ); } public function get_voices() { diff --git a/modules/openai/class-openai-email-responder.php b/modules/openai/class-openai-email-responder.php index d91593b..05731d4 100644 --- a/modules/openai/class-openai-email-responder.php +++ b/modules/openai/class-openai-email-responder.php @@ -20,7 +20,7 @@ class OpenAI_Email_Responder { */ public function __construct( OpenAI_Module $module ) { $this->module = $module; - add_action( 'pos_imap_new_email', array( $this, 'handle_new_email' ), 20, 2 ); + add_action( 'pos_imap_new_email', array( $this, 'handle_new_email' ), 20, 3 ); } /** @@ -28,8 +28,9 @@ public function __construct( OpenAI_Module $module ) { * * @param array $email_data Email data from the IMAP module. * @param object $imap_module IMAP module instance. + * @param int $user_id Matched WordPress user ID if provided by IMAP. */ - public function handle_new_email( array $email_data, $imap_module = null ): void { + public function handle_new_email( array $email_data, $imap_module = null, $user_id = 0 ): void { // Classify the email first to check if it's an auto-responder or spam $classification = $this->classify_email( $email_data ); if ( ! empty( $classification['skip'] ) ) { @@ -39,7 +40,23 @@ public function handle_new_email( array $email_data, $imap_module = null ): void return; } - $matched_user = $this->resolve_user_from_email( $email_data ); + $matched_user = null; + + if ( ! empty( $email_data['matched_user_ids'] ) && is_array( $email_data['matched_user_ids'] ) ) { + if ( $user_id && in_array( $user_id, $email_data['matched_user_ids'], true ) ) { + $matched_user = get_user_by( 'id', $user_id ); + } else { + $first_id = (int) reset( $email_data['matched_user_ids'] ); + if ( $first_id > 0 ) { + $matched_user = get_user_by( 'id', $first_id ); + } + } + } + + if ( ! $matched_user instanceof WP_User ) { + $matched_user = $this->resolve_user_from_email( $email_data ); + } + if ( ! $matched_user instanceof WP_User ) { $from_address = isset( $email_data['from'] ) ? sanitize_email( $email_data['from'] ) : ''; if ( '' === $from_address ) { diff --git a/modules/openai/class-openai-module.php b/modules/openai/class-openai-module.php index ece6569..d145921 100644 --- a/modules/openai/class-openai-module.php +++ b/modules/openai/class-openai-module.php @@ -12,6 +12,7 @@ class OpenAI_Module extends POS_Module { 'type' => 'text', 'name' => 'OpenAI API Key', 'label' => 'You can get it from here', + 'scope' => 'global', ), 'prompt_describe_image' => array( 'type' => 'textarea', @@ -22,6 +23,7 @@ class OpenAI_Module extends POS_Module { - If Image presents some kind of assortment of items without people in it, assume that your role is to list everything present in the image. Do not describe the scene, but instead list every item in the image. Default to listing all individual items instead of whole groups. - If the image presents a scene with people in it, describe what it's presenting. EOF, + 'scope' => 'global', ), ); @@ -33,7 +35,7 @@ class OpenAI_Module extends POS_Module { protected $email_responder; public function is_configured() { - return ! empty( $this->settings['api_key'] ); + return ! empty( $this->get_setting( 'api_key' ) ); } public function register() { @@ -712,8 +714,6 @@ public function get_system_state_ability( $args ) { ); } - - public function rest_api_init() { register_rest_route( $this->rest_namespace, diff --git a/modules/openai/class-pos-ai-podcast-module.php b/modules/openai/class-pos-ai-podcast-module.php index c3c97ca..695ea7d 100644 --- a/modules/openai/class-pos-ai-podcast-module.php +++ b/modules/openai/class-pos-ai-podcast-module.php @@ -24,6 +24,7 @@ public function __construct( $openai, $elevenlabs ) { 'name' => 'Private token for accessing the podcast feed', 'label' => strlen( $token ) < 3 ? 'You need a token longer than 3 characters to enable the podcast feed' : 'Your feed is accessible here', 'default' => '0', + 'scope' => 'user', ), 'tts_service' => array( 'type' => 'select', @@ -33,6 +34,7 @@ public function __construct( $openai, $elevenlabs ) { 'options' => array( 'openai-gpt4o-audio' => 'OpenAI GPT-4o Audio', ), + 'scope' => 'global', ), ); if ( $this->elevenlabs->is_configured() ) { @@ -42,6 +44,7 @@ public function __construct( $openai, $elevenlabs ) { 'name' => 'ElevenLabs Voice ID', 'label' => 'The voice to use for your motivational podcast. Add this voice to your account or paste another id here', 'default' => 'jB108zg64sTcu1kCbN9L', + 'scope' => 'global', ); } @@ -57,6 +60,32 @@ public function __construct( $openai, $elevenlabs ) { } + /** + * Locate the user that owns a given podcast token. + * + * @param string $token Token from the request. + * @return WP_User|null + */ + private function get_user_for_token( string $token ): ?WP_User { + return $this->find_user_for_setting_token( 'token', $token ); + } + + /** + * Authorize a REST request by token. + * + * @param string $token Token from the request. + * @return bool + */ + private function authorize_token( string $token ): bool { + $user = $this->get_user_for_token( $token ); + if ( ! $user ) { + return false; + } + + wp_set_current_user( $user->ID ); + return true; + } + public function admin_menu() { add_submenu_page( 'personalos', 'Hype Me', 'Hype Me', 'read', 'pos-hype-me', array( $this, 'render_admin_player_page' ) ); } @@ -116,6 +145,7 @@ public function output_feed() { array( 'post_type' => 'attachment', 'post_status' => 'private, publish, inherit', + 'author' => get_current_user_id(), 'meta_query' => array( array( 'key' => 'pos_podcast', @@ -125,27 +155,28 @@ public function output_feed() { ) ); global $post; - header( 'Content-Type: ' . feed_content_type( 'rss-http' ) . '; charset=' . get_option( 'blog_charset' ), true ); - echo ''; + $blog_charset = esc_attr( get_option( 'blog_charset' ) ); + header( 'Content-Type: ' . feed_content_type( 'rss-http' ) . '; charset=' . $blog_charset, true ); + echo ''; ?> - Good morning from <?php echo get_bloginfo( 'name' ); ?> - - - - + Good morning from <?php echo esc_html( get_bloginfo( 'name' ) ); ?> + + + + Private podcast with all the hype and energy you need to start your day. - - + + "; + echo ''; } ?> @@ -161,13 +192,13 @@ public function output_feed() { ?> <?php the_title_rss(); ?> - + ID; $fileurl = wp_get_attachment_url( $attachment_id ); $filesize = filesize( get_attached_file( $attachment_id ) ); - $dateformatstring = _x( 'D, d M Y H:i:s O', 'Date formating for iTunes feed.' ); + $dateformatstring = _x( 'D, d M Y H:i:s O', 'Date formating for iTunes feed.', 'personalos' ); ?> @@ -203,11 +234,7 @@ public function rest_api_init() { 'methods' => 'GET', 'callback' => array( $this, 'output_feed' ), 'permission_callback' => function( $request ) { - $token = $this->get_setting( 'token' ); - if ( strlen( $token ) < 3 ) { - return false; - } - return $token === $request->get_param( 'token' ); + return $this->authorize_token( (string) $request->get_param( 'token' ) ); }, ) ); @@ -230,7 +257,7 @@ public function rest_api_init() { return $this->generate( $request->has_param( 'prompt_id' ) ? $request->get_param( 'prompt_id' ) : null ); }, 'permission_callback' => function( $request ) { - return true; + return $this->authorize_token( (string) $request->get_param( 'token' ) ); }, ) ); @@ -268,6 +295,7 @@ public function get_todos_now() { 'post_type' => 'todo', 'post_status' => array( 'publish', 'private' ), 'posts_per_page' => 25, + 'author' => get_current_user_id(), 'tax_query' => array( array( 'taxonomy' => 'notebook', @@ -288,7 +316,7 @@ function( $post ) { array_map( function( $term ) { $termmeta = get_term_meta( $term->term_id, 'flag' ); - if ( ! in_array( 'project', $termmeta ) ) { + if ( ! in_array( 'project', $termmeta, true ) ) { return ''; } return '#' . $term->name; @@ -316,6 +344,7 @@ public function generate( $prompt_id = null ) { array( 'post_type' => 'attachment', 'post_status' => 'private', + 'author' => get_current_user_id(), 'meta_query' => array( array( 'key' => 'pos_podcast', diff --git a/modules/openai/class-pos-ollama-server.php b/modules/openai/class-pos-ollama-server.php index 1cd5f2e..26f696d 100644 --- a/modules/openai/class-pos-ollama-server.php +++ b/modules/openai/class-pos-ollama-server.php @@ -52,6 +52,7 @@ public function __construct( $module ) { 'name' => 'Token for authorizing OLLAMA mock API.', 'label' => strlen( $token ) < 3 ? 'Set a token to enable Ollama-compatible API for external clients' : 'OLLAMA Api accessible at here', 'default' => '0', + 'scope' => 'user', ); if ( strlen( $token ) >= 3 ) { // Initialize models lazily when needed, not during construction. @@ -138,17 +139,14 @@ private function model_exists( string $name ): bool { } public function check_permission( WP_REST_Request $request ) { - $token = $request->get_param( 'token' ); - if ( $token === $this->module->get_setting( 'ollama_auth_token' ) ) { - // Switch to the async jobs user if configured. - // @TODO actually make this endpoint authorize by user and store tokens in user meta. - $notes_module = POS::get_module_by_id( 'notes' ); - if ( $notes_module ) { - $notes_module->switch_to_user(); - } - return true; + $token = (string) $request->get_param( 'token' ); + $user = $this->get_user_for_token( $token, 'ollama_auth_token' ); + if ( ! $user ) { + return false; } - return false; + + wp_set_current_user( $user->ID ); + return true; } /** @@ -776,4 +774,49 @@ public function get_ps( WP_REST_Request $request ): WP_REST_Response { 200 ); } + + /** + * Resolve WordPress user for a provided token. + * + * @param string $token Token string from the request. + * @param string $setting_id Related OpenAI module setting id. + * @return WP_User|null + */ + private function get_user_for_token( string $token, string $setting_id ): ?WP_User { + if ( method_exists( $this->module, 'find_user_for_setting_token' ) ) { + $user = $this->module->find_user_for_setting_token( $setting_id, $token ); + if ( $user instanceof WP_User ) { + return $user; + } + } + + if ( strlen( $token ) < 3 ) { + return null; + } + + global $wpdb; + $meta_key = 'pos_' . $this->module->id . '_' . $setting_id; + $user_id = $wpdb->get_var( + $wpdb->prepare( + "SELECT user_id FROM {$wpdb->usermeta} WHERE meta_key = %s AND meta_value = %s LIMIT 1", + $meta_key, + $token + ) + ); + + if ( ! $user_id ) { + return null; + } + + $user = get_user_by( 'ID', (int) $user_id ); + if ( ! $user instanceof WP_User ) { + return null; + } + + if ( ! user_can( $user, 'use_personalos' ) ) { + return null; + } + + return $user; + } } diff --git a/modules/perplexity/class.perplexity-module.php b/modules/perplexity/class.perplexity-module.php index 6c52f17..f4534d5 100644 --- a/modules/perplexity/class.perplexity-module.php +++ b/modules/perplexity/class.perplexity-module.php @@ -13,6 +13,7 @@ class Perplexity_Module extends POS_Module { 'type' => 'text', 'name' => 'Perplexity API Token', 'label' => 'Enter your Perplexity API token.', + 'scope' => 'global', ), ); diff --git a/modules/readwise/class-readwise.php b/modules/readwise/class-readwise.php index c92bbac..27c17c7 100644 --- a/modules/readwise/class-readwise.php +++ b/modules/readwise/class-readwise.php @@ -18,12 +18,14 @@ class Readwise extends External_Service_Module { 'type' => 'text', 'name' => 'Readwise API Token', 'label' => 'You can get it from here', + 'scope' => 'user', ), 'autotag' => array( 'type' => 'callback', 'callback' => 'autotag_setting_callback', 'name' => 'Notebook for incoming highlights', 'label' => 'Automatically add new highlights to this notebook. They will also be added to the "Readwise" notebook.', + 'scope' => 'user', ), ); @@ -31,10 +33,6 @@ class Readwise extends External_Service_Module { public function __construct( Notes_Module $notes_module ) { $this->notes_module = $notes_module; - $token = $this->get_setting( 'token' ); - if ( ! $token ) { - return false; - } $this->register_sync( 'hourly' ); $this->register_meta( 'readwise_id', $this->notes_module->id ); $this->register_meta( 'readwise_category', $this->notes_module->id ); @@ -60,21 +58,40 @@ public function setup_default_notebook() { } public function sync() { - $this->log( '[DEBUG] Syncing readwise triggering ' ); + $user_ids = $this->get_user_ids_with_setting( 'token' ); + if ( empty( $user_ids ) ) { + return; + } + + foreach ( $user_ids as $user_id ) { + $token = $this->get_setting( 'token', $user_id ); + if ( ! $token ) { + continue; + } - $token = $this->get_setting( 'token' ); - if ( ! $token ) { - return false; + $this->run_for_user( + $user_id, + function () use ( $token, $user_id ) { + try { + $this->sync_user( $token, $user_id ); + } catch ( \Throwable $e ) { + $this->log( sprintf( 'User %d readwise sync error: %s', $user_id, $e->getMessage() ), E_USER_WARNING ); + } + } + ); } - $this->notes_module->switch_to_user(); + } + + protected function sync_user( string $token, int $user_id ) { + $this->log( sprintf( '[DEBUG] Syncing Readwise for user %d', $user_id ) ); $this->setup_default_notebook(); $query_args = array(); - $page_cursor = get_option( $this->get_setting_option_name( 'page_cursor' ), null ); + $page_cursor = $this->get_user_state( 'page_cursor', null, $user_id ); if ( $page_cursor ) { $query_args['pageCursor'] = $page_cursor; } else { - $last_sync = get_option( $this->get_setting_option_name( 'last_sync' ) ); + $last_sync = $this->get_user_state( 'last_sync', null, $user_id ); if ( $last_sync ) { $query_args['updatedAfter'] = $last_sync; } @@ -90,26 +107,34 @@ public function sync() { ); if ( is_wp_error( $request ) ) { $this->log( '[ERROR] Fetching readwise ' . $request->get_error_message(), E_USER_WARNING ); - return false; // Bail early + return; } $body = wp_remote_retrieve_body( $request ); $data = json_decode( $body ); + if ( empty( $data ) ) { + $this->log( '[ERROR] Empty response from Readwise', E_USER_WARNING ); + return; + } + $this->log( "[DEBUG] Readwise Syncing {$data->count} highlights" ); //phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase if ( ! empty( $data->nextPageCursor ) ) { - // We want to unschedule any regular sync events until we have initial sync complete. wp_unschedule_hook( $this->get_sync_hook_name() ); - // We will schedule ONE TIME sync event for the next page. //phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase - update_option( $this->get_setting_option_name( 'page_cursor' ), $data->nextPageCursor ); + $this->set_user_state( 'page_cursor', $data->nextPageCursor, $user_id ); wp_schedule_single_event( time() + 60, $this->get_sync_hook_name() ); + //phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase $this->log( "Scheduling next page sync with cursor {$data->nextPageCursor}" ); } else { $this->log( '[DEBUG] Full sync completed' ); - update_option( $this->get_setting_option_name( 'last_sync' ), gmdate( 'c' ) ); - delete_option( $this->get_setting_option_name( 'page_cursor' ) ); + $this->set_user_state( 'last_sync', gmdate( 'c' ), $user_id ); + $this->delete_user_state( 'page_cursor', $user_id ); + } + + if ( empty( $data->results ) ) { + return; } foreach ( $data->results as $book ) { @@ -122,7 +147,8 @@ public function find_note_by_readwise_id( $readwise_id ) { array( 'posts_per_page' => -1, 'post_type' => $this->notes_module->id, - 'post_status' => 'publish', // TODO: Remove this after testing - the post from trash should not be duplicated. + 'post_status' => array( 'private', 'publish', 'pending', 'draft', 'future' ), + 'author' => get_current_user_id(), 'meta_key' => 'readwise_id', 'meta_value' => $readwise_id, ) @@ -214,7 +240,8 @@ function( $name ) use ( $parent_notebook ) { 'post_title' => $book->title, 'post_type' => $this->notes_module->id, 'post_content' => implode( "\n", $content ), - 'post_status' => 'publish', + 'post_status' => 'private', + 'post_author' => get_current_user_id(), 'meta_input' => array( 'readwise_id' => $book->user_book_id, 'readwise_category' => $book->category, diff --git a/personalos.php b/personalos.php index 50718d3..b65c8b7 100644 --- a/personalos.php +++ b/personalos.php @@ -42,6 +42,7 @@ public static function init() { add_action( 'wp_abilities_api_categories_init', array( 'POS', 'register_ability_category' ) ); self::load_modules(); + add_filter( 'map_meta_cap', array( 'POS', 'map_meta_cap' ), 10, 4 ); add_action( 'enqueue_block_editor_assets', array( 'POS', 'enqueue_assets' ) ); if ( defined( 'WP_CLI' ) && class_exists( 'WP_CLI' ) ) { WP_CLI::add_command( 'pos populate', array( 'POS', 'populate_starter_content' ) ); @@ -81,6 +82,7 @@ public static function fix_versions() { if ( ! function_exists( 'get_plugin_data' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } + self::ensure_capabilities(); $data_version = get_option( 'pos_data_version', false ); $plugin_data = get_plugin_data( __FILE__ ); self::$version = $plugin_data['Version']; @@ -99,6 +101,28 @@ public static function fix_versions() { update_option( 'pos_data_version', self::$version ); } + /** + * Ensure PersonalOS capabilities are assigned to proper roles. + */ + public static function ensure_capabilities() { + $role_caps = array( + 'editor' => array( 'use_personalos' ), + 'administrator' => array( 'use_personalos', 'admin_personalos' ), + ); + + foreach ( $role_caps as $role_name => $caps ) { + $role = get_role( $role_name ); + if ( ! $role ) { + continue; + } + foreach ( $caps as $cap ) { + if ( ! $role->has_cap( $cap ) ) { + $role->add_cap( $cap ); + } + } + } + } + public static function get_module_by_id( $id ) { foreach ( self::$modules as $module ) { if ( $module->id === $id ) { @@ -109,9 +133,9 @@ public static function get_module_by_id( $id ) { } public static function admin_menu() { - add_menu_page( 'Personal OS', 'Personal OS', 'manage_options', 'personalos', false, 'dashicons-admin-generic', 3 ); - add_submenu_page( 'personalos', 'Your Dashboard', 'Dashboard', 'manage_options', 'personalos-settings', array( 'POS', 'admin_page' ), 0 ); - add_submenu_page( 'personalos', 'Notebooks', 'Notebooks', 'manage_options', 'edit-tags.php?taxonomy=notebook&post_type=notes' ); + add_menu_page( 'Personal OS', 'Personal OS', 'use_personalos', 'personalos', false, 'dashicons-admin-generic', 3 ); + add_submenu_page( 'personalos', 'Your Dashboard', 'Dashboard', 'use_personalos', 'personalos-settings', array( 'POS', 'admin_page' ), 0 ); + add_submenu_page( 'personalos', 'Notebooks', 'Notebooks', 'use_personalos', 'edit-tags.php?taxonomy=notebook&post_type=notes' ); } public static function enqueue_assets() { wp_enqueue_script( 'pos' ); @@ -121,6 +145,62 @@ public static function enqueue_assets() { public static function admin_page() { require plugin_dir_path( __FILE__ ) . 'dashboard.php'; } + /** + * Return the list of PersonalOS post types that require special permission handling. + * + * @return array + */ + public static function get_personal_post_types() { + $types = array( 'notes', 'todo' ); + return apply_filters( 'pos_personal_post_types', $types ); + } + + /** + * Map meta capabilities for PersonalOS post types. + * + * @param string[] $caps Primitive capabilities that the user must have. + * @param string $cap Capability being checked. + * @param int $user_id User ID. + * @param mixed[] $args Optional additional args. For post caps, includes the post ID. + * + * @return string[] + */ + public static function map_meta_cap( $caps, $cap, $user_id, $args ) { + $handled_caps = array( 'edit_post', 'delete_post', 'read_post' ); + if ( ! in_array( $cap, $handled_caps, true ) ) { + return $caps; + } + + $post_id = isset( $args[0] ) ? (int) $args[0] : 0; + if ( ! $post_id ) { + return $caps; + } + + $post = get_post( $post_id ); + if ( ! $post || ! in_array( $post->post_type, self::get_personal_post_types(), true ) ) { + return $caps; + } + + $is_owner = (int) $post->post_author === (int) $user_id; + + if ( 'read_post' === $cap ) { + if ( $is_owner ) { + return array( 'use_personalos' ); + } + + if ( 'private' !== $post->post_status ) { + return array( 'use_personalos' ); + } + + return array( 'admin_personalos' ); + } + + if ( $is_owner ) { + return array( 'use_personalos' ); + } + + return array( 'admin_personalos' ); + } public static function load_modules() { require_once plugin_dir_path( __FILE__ ) . 'modules/class-pos-module.php'; require_once plugin_dir_path( __FILE__ ) . 'modules/notes/class-notes-module.php'; diff --git a/phpcs.xml b/phpcs.xml index d4fc377..0a06a6e 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -8,6 +8,7 @@ */node_modules/* */build/* */tests/* + modules/perplexity/class.perplexity-module.php diff --git a/tests/bootstrap.php b/tests/bootstrap.php index f6f60a2..416029a 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -37,3 +37,16 @@ function _manually_load_plugin() { // Start up the WP testing environment. require "{$_tests_dir}/includes/bootstrap.php"; + +add_filter( + 'pre_http_request', + function( $response, $parsed_args, $url ) { + if ( false === strpos( $url, 'api.openai.com' ) ) { + return $response; + } + + return new WP_Error( 'mock_http_blocked', 'External HTTP disabled during unit tests.' ); + }, + 10, + 3 +); diff --git a/tests/unit/AIPodcastModuleTest.php b/tests/unit/AIPodcastModuleTest.php new file mode 100644 index 0000000..446fb37 --- /dev/null +++ b/tests/unit/AIPodcastModuleTest.php @@ -0,0 +1,60 @@ +module = new POS_AI_Podcast_Module( $openai, $eleven_labs ); + + $this->token_user_id = self::factory()->user->create( + array( + 'role' => 'editor', + 'user_email' => 'podcast-user@example.com', + ) + ); + update_user_meta( $this->token_user_id, 'pos_ai-podcast_token', 'podcast-token' ); + + rest_get_server(); + do_action( 'rest_api_init' ); + } + + public function test_permission_callback_accepts_valid_token() { + $routes = rest_get_server()->get_routes(); + $this->assertArrayHasKey( '/pos/v1/ai-podcast', $routes ); + + $endpoint = $routes['/pos/v1/ai-podcast'][0]; + $permission_callback = $endpoint['permission_callback']; + + $request = new WP_REST_Request( 'GET', '/pos/v1/ai-podcast' ); + $request->set_param( 'token', 'podcast-token' ); + + wp_set_current_user( 0 ); + $this->assertTrue( $permission_callback( $request ) ); + $this->assertSame( $this->token_user_id, get_current_user_id() ); + } + + public function test_permission_callback_rejects_invalid_token() { + $routes = rest_get_server()->get_routes(); + $endpoint = $routes['/pos/v1/ai-podcast'][0]; + $permission_callback = $endpoint['permission_callback']; + + $request = new WP_REST_Request( 'GET', '/pos/v1/ai-podcast' ); + $request->set_param( 'token', 'invalid' ); + + $this->assertFalse( $permission_callback( $request ) ); + } +} + diff --git a/tests/unit/CapabilitiesTest.php b/tests/unit/CapabilitiesTest.php new file mode 100644 index 0000000..48b3237 --- /dev/null +++ b/tests/unit/CapabilitiesTest.php @@ -0,0 +1,42 @@ +remove_cap( 'use_personalos' ); + } + + $admin = get_role( 'administrator' ); + if ( $admin ) { + $admin->remove_cap( 'use_personalos' ); + $admin->remove_cap( 'admin_personalos' ); + } + } + + public function test_editor_gets_use_capability() { + POS::ensure_capabilities(); + $editor = get_role( 'editor' ); + + $this->assertNotEmpty( $editor ); + $this->assertTrue( $editor->has_cap( 'use_personalos' ) ); + $this->assertFalse( $editor->has_cap( 'admin_personalos' ) ); + } + + public function test_admin_gets_admin_capability() { + POS::ensure_capabilities(); + $admin = get_role( 'administrator' ); + + $this->assertNotEmpty( $admin ); + $this->assertTrue( $admin->has_cap( 'use_personalos' ) ); + $this->assertTrue( $admin->has_cap( 'admin_personalos' ) ); + } +} + diff --git a/tests/unit/EvernoteModuleSyncTest.php b/tests/unit/EvernoteModuleSyncTest.php new file mode 100644 index 0000000..773ec57 --- /dev/null +++ b/tests/unit/EvernoteModuleSyncTest.php @@ -0,0 +1,66 @@ +getMockBuilder( Evernote_Module::class ) + ->setConstructorArgs( array( $notes_module ) ) + ->onlyMethods( + array( + 'register_sync', + 'register_meta', + 'connect', + 'get_user_ids_with_setting', + 'get_setting', + 'sync_user', + ) + ) + ->getMock(); + + $module->method( 'register_sync' ); + $module->method( 'register_meta' ); + $module->method( 'connect' )->willReturn( true ); + + $user_one = self::factory()->user->create( array( 'role' => 'editor' ) ); + $user_two = self::factory()->user->create( array( 'role' => 'editor' ) ); + + $module->method( 'get_user_ids_with_setting' ) + ->with( 'token' ) + ->willReturn( array( $user_one, $user_two ) ); + + $module->method( 'get_setting' )->willReturnMap( + array( + array( 'token', $user_one, 'token-one' ), + array( 'active', $user_one, true ), + array( 'synced_notebooks', $user_one, array( 'nb-one' ) ), + array( 'token', $user_two, 'token-two' ), + array( 'active', $user_two, true ), + array( 'synced_notebooks', $user_two, array( 'nb-two' ) ), + array( 'token', null, null ), + array( 'active', null, false ), + array( 'synced_notebooks', null, array() ), + ) + ); + + $module_ref = $module; + $seen = array(); + + $module->expects( $this->exactly( 2 ) ) + ->method( 'sync_user' ) + ->willReturnCallback( + function( $user_id ) use ( &$seen, $module_ref, $user_one, $user_two ) { + $seen[] = $user_id; + $expected = ( $user_id === $user_one ) ? array( 'nb-one' ) : array( 'nb-two' ); + $this->assertSame( $expected, $module_ref->synced_notebooks ); + $this->assertSame( $user_id, get_current_user_id(), 'run_for_user should switch current user' ); + } + ); + + $module->sync(); + + $this->assertSame( array( $user_one, $user_two ), $seen ); + } +} + diff --git a/tests/unit/EvernoteModuleTest.php b/tests/unit/EvernoteModuleTest.php index 9ceda8a..5a8cdce 100644 --- a/tests/unit/EvernoteModuleTest.php +++ b/tests/unit/EvernoteModuleTest.php @@ -121,6 +121,8 @@ public function test_create_note_from_evernote() { $module = \POS::$modules[2]; + $user_id = self::factory()->user->create( array( 'role' => 'editor' ) ); + wp_set_current_user( $user_id ); $note = new \EDAM\Types\Note(); $note->title = 'Evernote'; @@ -137,6 +139,8 @@ public function test_create_note_from_evernote() { $this->assertEquals( $note->guid, $updated_note->post_name ); $this->assertEquals( 'Evernote', $updated_note->post_title ); $this->assertStringContainsString( 'First Test paragraph', $updated_note->post_content ); + $this->assertSame( 'private', $updated_note->post_status ); + $this->assertSame( $user_id, (int) $updated_note->post_author ); $assigned_taxonomies = wp_get_object_terms( $post_id, 'notebook', array( 'fields' => 'ids' ) ); $this->assertContains( $this->test_tag['term_id'], $assigned_taxonomies ); $this->assertContains( $term['term_id'], $assigned_taxonomies ); diff --git a/tests/unit/IMAPModuleTest.php b/tests/unit/IMAPModuleTest.php index bb4a0ba..c4c4906 100644 --- a/tests/unit/IMAPModuleTest.php +++ b/tests/unit/IMAPModuleTest.php @@ -153,4 +153,101 @@ public function test_authentication_results_from_imap_host_trusted() { delete_option( 'imap_imap_host' ); } + + public function test_match_recipient_user_ids_returns_unique_ids() { + $user_id = self::factory()->user->create( + array( + 'user_email' => 'recipient@example.com', + ) + ); + + $addresses = array( 'recipient@example.com', 'other@example.com', 'recipient@example.com' ); + $reflection = new ReflectionMethod( IMAP_Module::class, 'match_recipient_user_ids' ); + $reflection->setAccessible( true ); + + $result = $reflection->invoke( $this->module, $addresses, array() ); + + $this->assertSame( array( $user_id ), $result ); + } + + public function test_extract_recipient_addresses_from_headers() { + $header = new stdClass(); + $header->to = array( + (object) array( + 'mailbox' => 'alice', + 'host' => 'example.com', + ), + ); + $header->cc = array( + (object) array( + 'mailbox' => 'bob', + 'host' => 'example.com', + ), + ); + $delivered_raw = "Delivered-To: carol@example.com\r\n"; + + $reflection = new ReflectionMethod( IMAP_Module::class, 'extract_recipient_addresses' ); + $reflection->setAccessible( true ); + + $result = $reflection->invoke( $this->module, $header, $delivered_raw ); + $this->assertEquals( array( 'alice@example.com', 'bob@example.com' ), $result ); + + $header->to = null; + $header->cc = null; + $result = $reflection->invoke( $this->module, $header, $delivered_raw ); + $this->assertEquals( array( 'carol@example.com' ), $result ); + } + + public function test_dispatch_email_action_passes_user_ids() { + $user_one = self::factory()->user->create(); + $user_two = self::factory()->user->create(); + + $email_data = array( + 'id' => 1, + 'subject' => 'Subject', + 'from' => 'sender@example.com', + 'body' => 'Body', + 'matched_user_ids' => array( $user_one, $user_two ), + ); + + wp_set_current_user( 0 ); + $calls = array(); + $callback = function( $passed_email_data, $module, $user_id ) use ( &$calls ) { + $calls[] = array( + 'user_id' => $user_id, + 'matched_user_id' => isset( $passed_email_data['matched_user_id'] ) ? $passed_email_data['matched_user_id'] : null, + 'current_user_id' => get_current_user_id(), + ); + }; + + add_action( 'pos_imap_new_email', $callback, 5, 3 ); + + $reflection = new ReflectionMethod( IMAP_Module::class, 'dispatch_email_action' ); + $reflection->setAccessible( true ); + + $reflection->invoke( $this->module, 'pos_imap_new_email', $email_data, array( $user_one, $user_two ) ); + + remove_action( 'pos_imap_new_email', $callback, 5 ); + + $this->assertCount( 2, $calls ); + $this->assertSame( $user_one, $calls[0]['user_id'] ); + $this->assertSame( $user_one, $calls[0]['matched_user_id'] ); + $this->assertSame( $user_one, $calls[0]['current_user_id'] ); + $this->assertSame( $user_two, $calls[1]['user_id'] ); + $this->assertSame( $user_two, $calls[1]['matched_user_id'] ); + $this->assertSame( $user_two, $calls[1]['current_user_id'] ); + $this->assertSame( 0, get_current_user_id(), 'Current user should be restored after dispatch' ); + + $calls = array(); + wp_set_current_user( 0 ); + $callback = function( $passed_email_data, $module, $user_id ) use ( &$calls ) { + $calls[] = $user_id; + }; + add_action( 'pos_imap_new_email', $callback, 5, 3 ); + $reflection->invoke( $this->module, 'pos_imap_new_email', $email_data, array() ); + remove_action( 'pos_imap_new_email', $callback, 5 ); + + $this->assertEquals( array( 0 ), $calls ); + $this->assertSame( 0, get_current_user_id(), 'Current user should remain unchanged when no match' ); + } } diff --git a/tests/unit/ModuleTokenMappingTest.php b/tests/unit/ModuleTokenMappingTest.php new file mode 100644 index 0000000..547ee4f --- /dev/null +++ b/tests/unit/ModuleTokenMappingTest.php @@ -0,0 +1,50 @@ +user->create( + array( + 'role' => 'editor', + 'user_email' => 'token-editor@example.com', + ) + ); + update_user_meta( $user_id, 'pos_token-helper_secret', 'shared-secret' ); + + $user = $module->find_user_for_setting_token( 'secret', 'shared-secret' ); + + $this->assertInstanceOf( WP_User::class, $user ); + $this->assertSame( $user_id, $user->ID ); + } + + public function test_find_user_for_setting_token_rejects_missing_or_invalid() { + $module = new POS_Token_Helper_Test_Module(); + + $this->assertNull( $module->find_user_for_setting_token( 'secret', 'aa' ), 'Tokens shorter than three chars should be rejected.' ); + + $user_id = self::factory()->user->create( + array( + 'role' => 'subscriber', + 'user_email' => 'token-subscriber@example.com', + ) + ); + update_user_meta( $user_id, 'pos_token-helper_secret', 'subscriber-secret' ); + + $this->assertNull( $module->find_user_for_setting_token( 'secret', 'subscriber-secret' ), 'Subscriber lacks use_personalos capability.' ); + $this->assertNull( $module->find_user_for_setting_token( 'secret', 'non-existent' ), 'Unknown tokens return null.' ); + } +} + diff --git a/tests/unit/NotesModuleTest.php b/tests/unit/NotesModuleTest.php new file mode 100644 index 0000000..92a2607 --- /dev/null +++ b/tests/unit/NotesModuleTest.php @@ -0,0 +1,75 @@ +module = POS::get_module_by_id( 'notes' ); + wp_set_current_user( 1 ); + } + + public function test_create_defaults_to_private_status() { + $post_id = $this->module->create( 'Test Note', 'Test content' ); + + $this->assertNotEmpty( $post_id ); + $this->assertSame( 'private', get_post_status( $post_id ) ); + } + + public function test_autopublish_converts_draft_to_private() { + $post_id = wp_insert_post( + array( + 'post_type' => 'notes', + 'post_status' => 'draft', + 'post_title' => 'Draft note', + ) + ); + + $post = get_post( $post_id ); + $this->module->autopublish_drafts( $post_id, $post, true ); + + $this->assertSame( 'private', get_post_status( $post_id ) ); + } + + public function test_notebook_widget_posts_respect_current_user() { + $term = wp_insert_term( 'Star Notebook', 'notebook', array( 'slug' => 'star-notebook' ) ); + update_term_meta( $term['term_id'], 'flag', 'star' ); + + $editor_one = self::factory()->user->create( array( 'role' => 'editor' ) ); + $editor_two = self::factory()->user->create( array( 'role' => 'editor' ) ); + + $note_one = wp_insert_post( + array( + 'post_type' => 'notes', + 'post_status' => 'private', + 'post_author' => $editor_one, + 'post_title' => 'User One Note', + ) + ); + wp_set_post_terms( $note_one, array( $term['term_id'] ), 'notebook' ); + + $note_two = wp_insert_post( + array( + 'post_type' => 'notes', + 'post_status' => 'private', + 'post_author' => $editor_two, + 'post_title' => 'User Two Note', + ) + ); + wp_set_post_terms( $note_two, array( $term['term_id'] ), 'notebook' ); + + $method = new ReflectionMethod( Notes_Module::class, 'get_notebook_widget_posts' ); + $method->setAccessible( true ); + + wp_set_current_user( $editor_one ); + $posts = $method->invoke( $this->module, 'notes', 'star-notebook' ); + $this->assertCount( 1, $posts, 'Non-admin should only see their own notes' ); + $this->assertSame( $editor_one, (int) $posts[0]->post_author ); + + $admin_id = self::factory()->user->create( array( 'role' => 'administrator' ) ); + wp_set_current_user( $admin_id ); + $posts = $method->invoke( $this->module, 'notes', 'star-notebook' ); + $this->assertCount( 2, $posts, 'Admins should see all notes' ); + } +} + diff --git a/tests/unit/NotesRestPermissionsTest.php b/tests/unit/NotesRestPermissionsTest.php new file mode 100644 index 0000000..011c23c --- /dev/null +++ b/tests/unit/NotesRestPermissionsTest.php @@ -0,0 +1,78 @@ +editor_one = self::factory()->user->create( array( 'role' => 'editor' ) ); + $this->editor_two = self::factory()->user->create( array( 'role' => 'editor' ) ); + $this->admin_id = self::factory()->user->create( array( 'role' => 'administrator' ) ); + + $this->note_id = wp_insert_post( + array( + 'post_type' => 'notes', + 'post_status' => 'private', + 'post_author' => $this->editor_one, + 'post_title' => 'Private Note', + 'post_content'=> 'Secret', + ) + ); + } + + public function test_editor_can_create_note_via_rest() { + wp_set_current_user( $this->editor_one ); + $request = new WP_REST_Request( 'POST', '/pos/v1/notes' ); + $request->set_body_params( + array( + 'title' => 'API Note', + 'content' => 'Body', + 'status' => 'private', + ) + ); + + $response = rest_do_request( $request ); + $this->assertEquals( 201, $response->get_status() ); + } + + public function test_subscriber_cannot_create_note_via_rest() { + $subscriber = self::factory()->user->create( array( 'role' => 'subscriber' ) ); + wp_set_current_user( $subscriber ); + + $request = new WP_REST_Request( 'POST', '/pos/v1/notes' ); + $request->set_body_params( + array( + 'title' => 'Blocked', + 'content' => 'Nope', + 'status' => 'private', + ) + ); + + $response = rest_do_request( $request ); + $this->assertEquals( 403, $response->get_status() ); + } + + public function test_rest_read_permissions_follow_capabilities() { + wp_set_current_user( $this->editor_one ); + $request = new WP_REST_Request( 'GET', '/pos/v1/notes/' . $this->note_id ); + $response = rest_do_request( $request ); + $this->assertEquals( 200, $response->get_status(), 'Author should read own note' ); + + wp_set_current_user( $this->editor_two ); + $request = new WP_REST_Request( 'GET', '/pos/v1/notes/' . $this->note_id ); + $response = rest_do_request( $request ); + $this->assertEquals( 403, $response->get_status(), 'Other editors should not read private note' ); + + wp_set_current_user( $this->admin_id ); + $request = new WP_REST_Request( 'GET', '/pos/v1/notes/' . $this->note_id ); + $response = rest_do_request( $request ); + $this->assertEquals( 200, $response->get_status(), 'Admins should read all notes' ); + } +} + diff --git a/tests/unit/OllamaServerTest.php b/tests/unit/OllamaServerTest.php index 0358b60..67bd497 100644 --- a/tests/unit/OllamaServerTest.php +++ b/tests/unit/OllamaServerTest.php @@ -13,6 +13,7 @@ class OllamaServerTest extends WP_UnitTestCase { private $ollama_server; private $mock_openai_module; + private $token_user_id; public function set_up(): void { parent::set_up(); @@ -21,10 +22,22 @@ public function set_up(): void { $this->mock_openai_module = $this->createMock( OpenAI_Module::class ); $this->mock_openai_module->settings = array(); - // Mock get_setting method to return test token + // Mock get_setting method to return test token for settings UI. $this->mock_openai_module->method( 'get_setting' ) - ->with( 'ollama_auth_token' ) - ->willReturn( 'test-token-123' ); + ->willReturnMap( + array( + array( 'ollama_auth_token', 'test-token-123' ), + ) + ); + + $this->token_user_id = self::factory()->user->create( + array( + 'role' => 'editor', + 'user_email' => 'ollama-user@example.com', + ) + ); + update_user_meta( $this->token_user_id, 'pos_openai_ollama_auth_token', 'test-token-123' ); + wp_set_current_user( $this->token_user_id ); // Mock get_chat_prompts to return test prompts with personalos:4o model $this->mock_openai_module->method( 'get_chat_prompts' ) @@ -126,6 +139,15 @@ public function test_get_tags_invalid_token() { $this->assertFalse( $result ); } + public function test_check_permission_sets_current_user() { + wp_set_current_user( 0 ); + $request = new WP_REST_Request( 'GET', '/ollama/v1/api/tags' ); + $request->set_param( 'token', 'test-token-123' ); + + $this->assertTrue( $this->ollama_server->check_permission( $request ) ); + $this->assertSame( $this->token_user_id, get_current_user_id() ); + } + /** * Test POST /api/show endpoint with valid model. * diff --git a/tests/unit/OpenAIEmailResponderTest.php b/tests/unit/OpenAIEmailResponderTest.php index 100b6a8..647bd88 100644 --- a/tests/unit/OpenAIEmailResponderTest.php +++ b/tests/unit/OpenAIEmailResponderTest.php @@ -900,4 +900,35 @@ function( MockObject $openai_module ) { // Should skip email because filter returned null (overwrites the lookup result) $this->assertCount( 0, $this->imap_spy->sent ); } + + public function test_handle_new_email_uses_matched_user_ids() { + $email_data = $this->load_email_fixture( + 'original_msg.eml', + array( + 'from' => 'sender@external.test', + 'matched_user_ids' => array( $this->sender_user_id ), + 'recipients' => array( 'recipient@example.com' ), + ) + ); + + $this->create_responder( + function( MockObject $openai_module ) { + $openai_module + ->expects( $this->once() ) + ->method( 'complete_responses' ) + ->willReturn( + array( + array( + 'role' => 'assistant', + 'content' => 'Hello matched user!', + ), + ) + ); + } + ); + + $this->responder->handle_new_email( $email_data, $this->imap_spy, $this->sender_user_id ); + + $this->assertCount( 1, $this->imap_spy->sent ); + } } diff --git a/tests/unit/ReadwiseModuleSyncTest.php b/tests/unit/ReadwiseModuleSyncTest.php new file mode 100644 index 0000000..2ae37c7 --- /dev/null +++ b/tests/unit/ReadwiseModuleSyncTest.php @@ -0,0 +1,103 @@ +getMockBuilder( Readwise::class ) + ->setConstructorArgs( array( $notes_module ) ) + ->onlyMethods( + array( + 'register_sync', + 'register_block', + 'setup_default_notebook', + 'get_user_ids_with_setting', + 'get_setting', + 'sync_user', + ) + ) + ->getMock(); + + $module->method( 'register_sync' ); + $module->method( 'register_block' ); + $module->method( 'setup_default_notebook' ); + + $user_one = self::factory()->user->create( array( 'role' => 'editor' ) ); + $user_two = self::factory()->user->create( array( 'role' => 'editor' ) ); + + $module->method( 'get_user_ids_with_setting' ) + ->with( 'token' ) + ->willReturn( array( $user_one, $user_two ) ); + + $module->method( 'get_setting' )->willReturnMap( + array( + array( 'token', $user_one, 'token-one' ), + array( 'token', $user_two, 'token-two' ), + array( 'token', null, null ), + ) + ); + + $module->expects( $this->exactly( 2 ) ) + ->method( 'sync_user' ) + ->willReturnCallback( + function( $token, $user_id ) use ( $user_one, $user_two ) { + $expected_token = ( $user_id === $user_one ) ? 'token-one' : 'token-two'; + $this->assertSame( $expected_token, $token ); + $this->assertSame( $user_id, get_current_user_id(), 'run_for_user should switch context' ); + } + ); + + $module->sync(); + } + + public function test_sync_book_creates_private_note_for_current_user() { + $notes_module = POS::get_module_by_id( 'notes' ); + + $module = $this->getMockBuilder( Readwise::class ) + ->setConstructorArgs( array( $notes_module ) ) + ->onlyMethods( array( 'register_sync', 'register_block' ) ) + ->getMock(); + $module->method( 'register_sync' ); + $module->method( 'register_block' ); + + $module->setup_default_notebook(); + + $user_id = self::factory()->user->create( array( 'role' => 'editor' ) ); + wp_set_current_user( $user_id ); + + $book = (object) array( + 'title' => 'Test Book', + 'user_book_id'=> 123, + 'category' => 'books', + 'author' => 'Tester', + 'source_url' => 'https://example.com', + 'book_tags' => array(), + 'summary' => 'Summary', + 'highlights' => array( + (object) array( + 'readwise_url' => 'https://example.com/highlight', + 'text' => 'Highlight text', + 'created_at' => '2025-01-01T00:00:00Z', + ), + ), + ); + + $module->sync_book( $book ); + + $posts = get_posts( + array( + 'post_type' => 'notes', + 'meta_key' => 'readwise_id', + 'meta_value' => 123, + 'post_status' => 'private', + ) + ); + + $this->assertNotEmpty( $posts ); + $post = $posts[0]; + $this->assertSame( 'private', $post->post_status ); + $this->assertSame( $user_id, (int) $post->post_author ); + } +} + diff --git a/tests/unit/SettingScopeTest.php b/tests/unit/SettingScopeTest.php new file mode 100644 index 0000000..3fb46a0 --- /dev/null +++ b/tests/unit/SettingScopeTest.php @@ -0,0 +1,47 @@ +notes_module = POS::get_module_by_id( 'notes' ); + } + + public function test_user_scoped_setting_stores_in_user_meta() { + $user_id = self::factory()->user->create(); + wp_set_current_user( $user_id ); + + $this->notes_module->settings['test_user_setting'] = array( + 'type' => 'text', + 'name' => 'Test User Setting', + 'scope' => 'user', + ); + + $this->notes_module->update_setting( 'test_user_setting', 'user-value' ); + + $this->assertSame( + 'user-value', + get_user_meta( $user_id, 'pos_notes_test_user_setting', true ) + ); + $this->assertSame( 'user-value', $this->notes_module->get_setting( 'test_user_setting' ) ); + } + + public function test_global_scoped_setting_stores_in_options() { + $this->notes_module->settings['test_global_setting'] = array( + 'type' => 'text', + 'name' => 'Test Global Setting', + 'scope' => 'global', + 'default' => 'default-value', + ); + + $this->notes_module->update_setting( 'test_global_setting', 'global-value' ); + + $this->assertSame( + 'global-value', + get_option( $this->notes_module->get_setting_option_name( 'test_global_setting' ) ) + ); + $this->assertSame( 'global-value', $this->notes_module->get_setting( 'test_global_setting' ) ); + } +} + diff --git a/tests/unit/SettingsTest.php b/tests/unit/SettingsTest.php index d36af6a..a9ccdbe 100644 --- a/tests/unit/SettingsTest.php +++ b/tests/unit/SettingsTest.php @@ -176,7 +176,9 @@ public function test_text_sanitization() { // Get the sanitize callback for a text setting $text_setting = $wp_registered_settings['test_module_1_api_key']; - $this->assertEquals( 'sanitize_text_field', $text_setting['sanitize_callback'] ); + $this->assertIsCallable( $text_setting['sanitize_callback'] ); + $sanitize_callback = $text_setting['sanitize_callback']; + $this->assertEquals( 'example', $sanitize_callback( ' example ' ) ); } /** diff --git a/tests/unit/TodoModuleTest.php b/tests/unit/TodoModuleTest.php index 4d36bf7..b634b91 100644 --- a/tests/unit/TodoModuleTest.php +++ b/tests/unit/TodoModuleTest.php @@ -193,5 +193,16 @@ public function test_recurring_todo() { $this->assertContains( 'inbox', $notebooks, 'Recurring todo is in inbox' ); $this->assertContains( 'test', $notebooks, 'Recurring todo is in test' ); } + + public function test_module_create_defaults_to_private() { + $post_id = $this->module->create( + array( + 'post_title' => 'Created via module', + ), + array( 'inbox' ) + ); + + $this->assertSame( 'private', get_post_status( $post_id ) ); + } }