From 6bfb14918af12fe5e50784737972d07ef3963e5a Mon Sep 17 00:00:00 2001 From: Jane Alesi Date: Wed, 21 May 2025 15:15:12 +0200 Subject: [PATCH 01/23] Create tests directory with README --- tests/README.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 tests/README.md diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..eb47d9b --- /dev/null +++ b/tests/README.md @@ -0,0 +1,14 @@ +# Tests Directory + +This directory contains test files for the DokuWiki Manager Plugin. + +## Structure + +- `auth.test.js` - Tests for authentication module +- `page.test.js` - Tests for page operations +- `media.test.js` - Tests for media operations +- `metadata.test.js` - Tests for metadata operations + +## Running Tests + +Tests can be run using Jest or a similar JavaScript testing framework. \ No newline at end of file From 1120de50219b9d951c26be619fc688a8e3c5b1ef Mon Sep 17 00:00:00 2001 From: Jane Alesi Date: Wed, 21 May 2025 15:15:45 +0200 Subject: [PATCH 02/23] Update plugin.json to support Bearer Token authentication --- plugin.json | 88 +++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 85 insertions(+), 3 deletions(-) diff --git a/plugin.json b/plugin.json index 1fb48ce..c74af37 100644 --- a/plugin.json +++ b/plugin.json @@ -2,9 +2,91 @@ "version": 1, "uuid": "dokuwiki-manager-typingmind-plugin", "iconURL": "https://www.dokuwiki.org/lib/tpl/dokuwiki/images/logo.png", - "emoji": "📝", + "emoji": "📚", "title": "DokuWiki Manager", - "userSettings": "[{\"name\":\"wikiUrl\",\"label\":\"DokuWiki URL\",\"type\":\"text\",\"required\":true,\"description\":\"The full URL to your DokuWiki instance (e.g., https://wiki.example.com)\"},{\"name\":\"username\",\"label\":\"Username\",\"type\":\"text\",\"required\":true,\"description\":\"Your DokuWiki username\"},{\"name\":\"password\",\"label\":\"Password\",\"type\":\"password\",\"required\":true,\"description\":\"Your DokuWiki password\"},{\"name\":\"defaultNamespace\",\"label\":\"Default Namespace\",\"type\":\"text\",\"description\":\"Optional default namespace for operations\"}]", - "openaiSpec": "{\"name\":\"manage_dokuwiki\",\"parameters\":{\"type\":\"object\",\"required\":[\"operation\"],\"properties\":{\"operation\":{\"type\":\"string\",\"description\":\"The operation to perform on the DokuWiki instance\",\"enum\":[\"get_page\",\"save_page\",\"append_page\",\"list_pages\",\"search_pages\",\"get_page_info\",\"get_page_history\",\"list_media\",\"get_media\",\"save_media\",\"delete_media\"]},\"pageId\":{\"type\":\"string\",\"description\":\"The ID of the wiki page to operate on\"},\"mediaId\":{\"type\":\"string\",\"description\":\"The ID of the media file to operate on\"},\"content\":{\"type\":\"string\",\"description\":\"The content to write to the page or file\"},\"namespace\":{\"type\":\"string\",\"description\":\"The namespace to list pages or media from\"},\"searchQuery\":{\"type\":\"string\",\"description\":\"The search query for finding pages\"}}},\"description\":\"Manage DokuWiki websites through the JSON-RPC API. Perform operations on wiki pages and media files.\"}", + "userSettings": [ + { + "name": "wikiUrl", + "label": "DokuWiki URL", + "type": "text", + "required": true, + "description": "The full URL to your DokuWiki instance (e.g., https://wiki.example.com)" + }, + { + "name": "authMethod", + "label": "Authentication Method", + "type": "enum", + "required": true, + "values": ["BASIC_AUTH", "BEARER_TOKEN"], + "description": "Choose authentication method (Basic Auth for username/password or Bearer Token for token-based auth)" + }, + { + "name": "username", + "label": "Username", + "type": "text", + "required": false, + "description": "Your DokuWiki username (only required for Basic Auth)" + }, + { + "name": "password", + "label": "Password/Token", + "type": "password", + "required": true, + "description": "Your DokuWiki password (for Basic Auth) or Bearer token (for Token Auth)" + }, + { + "name": "defaultNamespace", + "label": "Default Namespace", + "type": "text", + "description": "Optional default namespace for operations" + } + ], + "openaiSpec": { + "name": "manage_dokuwiki", + "parameters": { + "type": "object", + "required": ["operation"], + "properties": { + "operation": { + "type": "string", + "description": "The operation to perform on the DokuWiki instance", + "enum": [ + "get_page", + "save_page", + "append_page", + "list_pages", + "search_pages", + "get_page_info", + "get_page_history", + "list_media", + "get_media", + "save_media", + "delete_media" + ] + }, + "pageId": { + "type": "string", + "description": "The ID of the wiki page to operate on" + }, + "mediaId": { + "type": "string", + "description": "The ID of the media file to operate on" + }, + "content": { + "type": "string", + "description": "The content to write to the page or file" + }, + "namespace": { + "type": "string", + "description": "The namespace to list pages or media from" + }, + "searchQuery": { + "type": "string", + "description": "The search query for finding pages" + } + } + }, + "description": "Manage DokuWiki websites through the JSON-RPC API. Perform operations on wiki pages and media files." + }, "implementationType": "javascript" } \ No newline at end of file From 8ed93eda6bd441c92f9ad35716b900943be9ea21 Mon Sep 17 00:00:00 2001 From: Jane Alesi Date: Wed, 21 May 2025 15:16:12 +0200 Subject: [PATCH 03/23] Add authentication module with support for Basic Auth and Bearer Token --- src/auth.js | 158 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 src/auth.js diff --git a/src/auth.js b/src/auth.js new file mode 100644 index 0000000..6cf1a40 --- /dev/null +++ b/src/auth.js @@ -0,0 +1,158 @@ +/** + * Authentication module for DokuWiki API + * Handles authentication with DokuWiki JSON-RPC API. + * + * @module dokuwiki-manager/auth + * @author satware AG + * @license MIT + */ + +/** + * Generate authentication headers based on the authentication method and credentials + * + * @param {Object} userSettings - User settings from Typing Mind plugin + * @param {string} userSettings.wikiUrl - URL of the DokuWiki instance + * @param {string} userSettings.authMethod - Authentication method (BASIC_AUTH or BEARER_TOKEN) + * @param {string} userSettings.username - DokuWiki username (required for BASIC_AUTH) + * @param {string} userSettings.password - DokuWiki password or Bearer token + * @returns {Object} Headers object with appropriate authentication + * @throws {Error} If required settings are missing or invalid + */ +function getAuthHeaders(userSettings) { + // Input validation + if (!userSettings) { + throw new Error("User settings are required"); + } + + if (!userSettings.wikiUrl) { + throw new Error("DokuWiki URL is required in settings"); + } + + if (!userSettings.authMethod) { + throw new Error("Authentication method is required in settings"); + } + + if (!userSettings.password) { + throw new Error("Password/Token is required in settings"); + } + + // Header initialization + const headers = { + 'Content-Type': 'application/json' + }; + + // Apply authentication based on method + switch (userSettings.authMethod) { + case 'BASIC_AUTH': + if (!userSettings.username) { + throw new Error("Username is required for Basic Authentication"); + } + + const credentials = btoa(`${userSettings.username}:${userSettings.password}`); + headers['Authorization'] = `Basic ${credentials}`; + break; + + case 'BEARER_TOKEN': + headers['Authorization'] = `Bearer ${userSettings.password}`; + break; + + default: + throw new Error(`Unsupported authentication method: ${userSettings.authMethod}. Use BASIC_AUTH or BEARER_TOKEN.`); + } + + return headers; +} + +/** + * Creates a JSON-RPC client for communicating with DokuWiki API + * + * @param {Object} userSettings - User settings from Typing Mind plugin + * @returns {Object} Client object with configured API methods + * @throws {Error} If connection fails or settings are invalid + */ +async function createJsonRpcClient(userSettings) { + // Input validation + if (!userSettings) { + throw new Error("User settings are required"); + } + + const { wikiUrl } = userSettings; + + // Format base URL + const baseUrl = wikiUrl.endsWith('/') + ? `${wikiUrl}lib/exe/jsonrpc.php` + : `${wikiUrl}/lib/exe/jsonrpc.php`; + + // Get authentication headers + const headers = getAuthHeaders(userSettings); + + // Create the client + const client = { + baseUrl, + headers, + async call(method, params = {}) { + try { + const response = await fetch(this.baseUrl, { + method: 'POST', + headers: this.headers, + body: JSON.stringify({ + jsonrpc: '2.0', + id: Date.now(), + method: method, + params: params + }) + }); + + // Handle HTTP errors + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`HTTP error ${response.status}: ${errorText}`); + } + + // Parse response + const result = await response.json(); + + // Handle JSON-RPC errors + if (result.error) { + const error = new Error(result.error.message); + error.code = result.error.code; + throw error; + } + + return result.result; + } catch (error) { + // Enhance error with context + if (!error.message.includes('DokuWiki API error')) { + error.message = `DokuWiki API error calling ${method}: ${error.message}`; + } + throw error; + } + } + }; + + return client; +} + +/** + * Test the connection to the DokuWiki instance + * + * @param {Object} client - JSON-RPC client + * @returns {Promise} True if connection is successful + * @throws {Error} If connection fails + */ +async function testConnection(client) { + try { + // Call a simple public API method to test authentication + const wikiTitle = await client.call('core.getWikiTitle'); + return true; + } catch (error) { + throw new Error(`Failed to connect to DokuWiki: ${error.message}`); + } +} + +// Export the module functions +module.exports = { + getAuthHeaders, + createJsonRpcClient, + testConnection +}; \ No newline at end of file From b7f1ba6ae1496e4e79938872a397cb3f1c9d9649 Mon Sep 17 00:00:00 2001 From: Jane Alesi Date: Wed, 21 May 2025 15:16:35 +0200 Subject: [PATCH 04/23] Add error handling module with improved error messages and recovery suggestions --- src/errorHandler.js | 120 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 src/errorHandler.js diff --git a/src/errorHandler.js b/src/errorHandler.js new file mode 100644 index 0000000..2709536 --- /dev/null +++ b/src/errorHandler.js @@ -0,0 +1,120 @@ +/** + * Error handling utilities for DokuWiki API + * + * @module dokuwiki-manager/errorHandler + * @author satware AG + * @license MIT + */ + +// Map of DokuWiki error codes to user-friendly messages +const ERROR_MESSAGES = { + // Success + 0: "Success", + + // Page errors + 111: "You are not allowed to read this page", + 121: "The requested page (revision) does not exist", + 131: "Empty or invalid page ID given", + 132: "Refusing to write an empty new wiki page", + 133: "The page is currently locked", + 134: "The page content was blocked", + + // Media errors + 211: "You are not allowed to read this media file", + 212: "You are not allowed to delete this media file", + 221: "The requested media file (revision) does not exist", + 231: "Empty or invalid media ID given", + 232: "Media file is still referenced", + 233: "Failed to delete media file", + 234: "Invalid base64 encoded data", + 235: "Empty file given", + 236: "Failed to save media", + + // Authentication errors + 401: "Authentication failed. Please check your credentials", + 403: "Insufficient permissions to perform this operation", + + // Server errors + 500: "Server error occurred. Please try again later", + + // Connection errors + 1000: "Unable to connect to the DokuWiki server", + 1001: "Network error when connecting to server", + 1002: "Timeout when connecting to server" +}; + +/** + * Handles DokuWiki API error codes and provides user-friendly error messages + * + * @param {Error} error - Error object from API call + * @returns {Error} Enhanced error with user-friendly message + */ +function handleApiError(error) { + // If no error code is present, return the original error + if (!error.code) { + return error; + } + + // Clone the error to avoid modifying the original + const enhancedError = new Error(error.message); + enhancedError.code = error.code; + enhancedError.originalMessage = error.message; + + // Enhance with a user-friendly message if available + if (ERROR_MESSAGES[error.code]) { + enhancedError.message = `${ERROR_MESSAGES[error.code]} (Code: ${error.code})`; + + // Preserve original technical message when relevant + if (error.originalMessage && !enhancedError.message.includes(error.originalMessage)) { + enhancedError.message += ` - ${error.originalMessage}`; + } + } + + return enhancedError; +} + +/** + * Provides recovery suggestions for specific error types + * + * @param {Error} error - Error object from API call + * @returns {string|null} Recovery suggestion or null if none available + */ +function getRecoverySuggestion(error) { + if (!error.code) { + return null; + } + + // Authentication errors + if (error.code === 401 || error.message.includes('Authentication failed')) { + return "Check your username and password in plugin settings. Make sure you have the correct authentication method selected."; + } + + // Permission errors + if (error.code === 403 || error.code === 111 || error.code === 212) { + return "Your account doesn't have sufficient permissions. Contact your wiki administrator for access."; + } + + // Connection errors + if (error.code >= 1000 && error.code < 2000) { + return "Check your network connection and verify that the DokuWiki URL is correct in plugin settings."; + } + + // Page locked + if (error.code === 133) { + return "The page is currently being edited by another user. Try again later."; + } + + // Server errors + if (error.code >= 500 && error.code < 600) { + return "The server is experiencing issues. Please try again later or contact your wiki administrator."; + } + + return null; +} + +// Export the module functions and constants +module.exports = { + ERROR_MESSAGES, + handleApiError, + getRecoverySuggestion +}; \ No newline at end of file From a72e682efad96ca619f79603918152eeef1fd3b5 Mon Sep 17 00:00:00 2001 From: Jane Alesi Date: Wed, 21 May 2025 15:16:55 +0200 Subject: [PATCH 05/23] Add API client module with enhanced error handling and utility methods --- src/apiClient.js | 136 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 src/apiClient.js diff --git a/src/apiClient.js b/src/apiClient.js new file mode 100644 index 0000000..15b4d32 --- /dev/null +++ b/src/apiClient.js @@ -0,0 +1,136 @@ +/** + * DokuWiki API client module + * Provides a client for making JSON-RPC calls to DokuWiki API. + * + * @module dokuwiki-manager/apiClient + * @author satware AG + * @license MIT + */ + +// Import modules +const auth = require('./auth'); +const { handleApiError, getRecoverySuggestion } = require('./errorHandler'); + +/** + * Create and initialize a DokuWiki API client + * + * @param {Object} userSettings - User settings from Typing Mind plugin + * @param {boolean} testConnection - Whether to test the connection (default: true) + * @returns {Promise} The API client object + * @throws {Error} If connection fails or settings are invalid + */ +async function createApiClient(userSettings, testConnection = true) { + try { + // Create the base JSON-RPC client + const jsonRpcClient = await auth.createJsonRpcClient(userSettings); + + // Test connection if requested + if (testConnection) { + await auth.testConnection(jsonRpcClient); + } + + // Create an enhanced client with error handling + const client = { + // Expose the base client + _jsonRpcClient: jsonRpcClient, + + /** + * Make an API call to DokuWiki + * + * @param {string} method - The API method to call + * @param {Object} params - The parameters to pass to the method + * @returns {Promise} The API response + * @throws {Error} If the API call fails + */ + async call(method, params = {}) { + try { + // Make the API call + return await this._jsonRpcClient.call(method, params); + } catch (error) { + // Handle API errors + const enhancedError = handleApiError(error); + + // Add recovery suggestion if available + const recoverySuggestion = getRecoverySuggestion(enhancedError); + if (recoverySuggestion) { + enhancedError.message += `\n\nPossible solution: ${recoverySuggestion}`; + } + + throw enhancedError; + } + }, + + /** + * Check if a page exists + * + * @param {string} pageId - The page ID + * @returns {Promise} True if the page exists + */ + async pageExists(pageId) { + try { + const pageInfo = await this.call('core.getPageInfo', { page: pageId }); + return true; + } catch (error) { + // Error 121 means page doesn't exist + if (error.code === 121) { + return false; + } + // Rethrow other errors + throw error; + } + }, + + /** + * Check if a media file exists + * + * @param {string} mediaId - The media ID + * @returns {Promise} True if the media file exists + */ + async mediaExists(mediaId) { + try { + const mediaInfo = await this.call('core.getMediaInfo', { media: mediaId }); + return true; + } catch (error) { + // Error 221 means media file doesn't exist + if (error.code === 221) { + return false; + } + // Rethrow other errors + throw error; + } + }, + + /** + * Get wiki configuration information + * + * @returns {Promise} Wiki information including title, version, and API version + */ + async getWikiInfo() { + const [title, version, apiVersion] = await Promise.all([ + this.call('core.getWikiTitle'), + this.call('core.getWikiVersion'), + this.call('core.getAPIVersion') + ]); + + return { + title, + version, + apiVersion + }; + } + }; + + return client; + } catch (error) { + // Enhance initialization errors + if (!error.message.includes('DokuWiki')) { + error.message = `Failed to initialize DokuWiki client: ${error.message}`; + } + throw error; + } +} + +// Export the module functions +module.exports = { + createApiClient +}; \ No newline at end of file From 73bab9890414315deb2d6c6c576268a513fafa71 Mon Sep 17 00:00:00 2001 From: Jane Alesi Date: Wed, 21 May 2025 15:17:04 +0200 Subject: [PATCH 06/23] Add module index file to export all modules --- src/index.js | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 src/index.js diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..4df558b --- /dev/null +++ b/src/index.js @@ -0,0 +1,20 @@ +/** + * DokuWiki Manager Plugin - Main Module + * Entry point for the plugin that exports all modules. + * + * @module dokuwiki-manager + * @author satware AG + * @license MIT + */ + +// Import modules +const auth = require('./auth'); +const apiClient = require('./apiClient'); +const errorHandler = require('./errorHandler'); + +// Export all modules +module.exports = { + auth, + apiClient, + errorHandler +}; \ No newline at end of file From 996a83f1381b5c7d44eb914ca1ddd5ecafb63741 Mon Sep 17 00:00:00 2001 From: Jane Alesi Date: Wed, 21 May 2025 15:17:46 +0200 Subject: [PATCH 07/23] Add comprehensive tests for authentication module --- tests/auth.test.js | 276 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 276 insertions(+) create mode 100644 tests/auth.test.js diff --git a/tests/auth.test.js b/tests/auth.test.js new file mode 100644 index 0000000..a01319e --- /dev/null +++ b/tests/auth.test.js @@ -0,0 +1,276 @@ +/** + * Tests for the authentication module + * + * @jest-environment jsdom + */ + +// Mock fetch for testing +global.fetch = jest.fn(); +global.btoa = jest.fn((str) => `base64encoded_${str}`); + +// Import the module to test +const { getAuthHeaders, createJsonRpcClient, testConnection } = require('../src/auth'); + +describe('Authentication Module', () => { + beforeEach(() => { + // Reset all mocks before each test + jest.clearAllMocks(); + + // Setup default fetch mock + global.fetch.mockImplementation(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ result: 'Test Wiki', error: null }) + }) + ); + }); + + describe('getAuthHeaders', () => { + test('should throw error for missing user settings', () => { + expect(() => getAuthHeaders(null)).toThrow('User settings are required'); + }); + + test('should throw error for missing wiki URL', () => { + expect(() => getAuthHeaders({})).toThrow('DokuWiki URL is required in settings'); + }); + + test('should throw error for missing authentication method', () => { + expect(() => getAuthHeaders({ wikiUrl: 'https://wiki.example.com' })) + .toThrow('Authentication method is required in settings'); + }); + + test('should throw error for missing password/token', () => { + expect(() => getAuthHeaders({ + wikiUrl: 'https://wiki.example.com', + authMethod: 'BASIC_AUTH' + })).toThrow('Password/Token is required in settings'); + }); + + test('should throw error for Basic Auth without username', () => { + expect(() => getAuthHeaders({ + wikiUrl: 'https://wiki.example.com', + authMethod: 'BASIC_AUTH', + password: 'password123' + })).toThrow('Username is required for Basic Authentication'); + }); + + test('should throw error for unsupported authentication method', () => { + expect(() => getAuthHeaders({ + wikiUrl: 'https://wiki.example.com', + authMethod: 'UNKNOWN_AUTH', + username: 'user', + password: 'password123' + })).toThrow('Unsupported authentication method'); + }); + + test('should generate correct headers for Basic Auth', () => { + const headers = getAuthHeaders({ + wikiUrl: 'https://wiki.example.com', + authMethod: 'BASIC_AUTH', + username: 'user', + password: 'password123' + }); + + expect(headers).toEqual({ + 'Content-Type': 'application/json', + 'Authorization': 'Basic base64encoded_user:password123' + }); + + expect(global.btoa).toHaveBeenCalledWith('user:password123'); + }); + + test('should generate correct headers for Bearer Token', () => { + const headers = getAuthHeaders({ + wikiUrl: 'https://wiki.example.com', + authMethod: 'BEARER_TOKEN', + password: 'token123' + }); + + expect(headers).toEqual({ + 'Content-Type': 'application/json', + 'Authorization': 'Bearer token123' + }); + }); + }); + + describe('createJsonRpcClient', () => { + test('should throw error for missing user settings', async () => { + await expect(createJsonRpcClient(null)).rejects.toThrow('User settings are required'); + }); + + test('should create client with correct base URL (with trailing slash)', async () => { + // Disable test connection to focus on URL construction + jest.spyOn(global, 'fetch').mockImplementation(() => { + throw new Error('Connection disabled for this test'); + }); + + try { + await createJsonRpcClient({ + wikiUrl: 'https://wiki.example.com/', + authMethod: 'BASIC_AUTH', + username: 'user', + password: 'password123' + }, false); // Don't test connection + } catch (error) { + // We expect an error because test connection is disabled + } + + // Check that the URL was constructed correctly + expect(global.fetch).toHaveBeenCalledWith( + 'https://wiki.example.com/lib/exe/jsonrpc.php', + expect.anything() + ); + }); + + test('should create client with correct base URL (without trailing slash)', async () => { + // Disable test connection to focus on URL construction + jest.spyOn(global, 'fetch').mockImplementation(() => { + throw new Error('Connection disabled for this test'); + }); + + try { + await createJsonRpcClient({ + wikiUrl: 'https://wiki.example.com', + authMethod: 'BASIC_AUTH', + username: 'user', + password: 'password123' + }, false); // Don't test connection + } catch (error) { + // We expect an error because test connection is disabled + } + + // Check that the URL was constructed correctly + expect(global.fetch).toHaveBeenCalledWith( + 'https://wiki.example.com/lib/exe/jsonrpc.php', + expect.anything() + ); + }); + + test('should create client with correct request body format', async () => { + const client = await createJsonRpcClient({ + wikiUrl: 'https://wiki.example.com', + authMethod: 'BASIC_AUTH', + username: 'user', + password: 'password123' + }, false); // Don't test connection + + // Mock Date.now for consistent testing + const originalDateNow = Date.now; + Date.now = jest.fn(() => 1234567890); + + await client.call('test.method', { param1: 'value1' }); + + // Restore Date.now + Date.now = originalDateNow; + + // Check request format + expect(global.fetch).toHaveBeenCalledWith( + expect.any(String), + { + method: 'POST', + headers: expect.any(Object), + body: '{"jsonrpc":"2.0","id":1234567890,"method":"test.method","params":{"param1":"value1"}}' + } + ); + }); + + test('should handle API errors correctly', async () => { + // Mock fetch to return an error + global.fetch.mockImplementation(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ + result: null, + error: { + code: 123, + message: 'Test error' + } + }) + }) + ); + + const client = await createJsonRpcClient({ + wikiUrl: 'https://wiki.example.com', + authMethod: 'BASIC_AUTH', + username: 'user', + password: 'password123' + }, false); // Don't test connection + + try { + await client.call('test.method'); + fail('Expected an error to be thrown'); + } catch (error) { + expect(error.message).toContain('Test error'); + expect(error.code).toBe(123); + } + }); + + test('should handle HTTP errors correctly', async () => { + // Mock fetch to return an HTTP error + global.fetch.mockImplementation(() => + Promise.resolve({ + ok: false, + status: 404, + text: () => Promise.resolve('Not Found') + }) + ); + + const client = await createJsonRpcClient({ + wikiUrl: 'https://wiki.example.com', + authMethod: 'BASIC_AUTH', + username: 'user', + password: 'password123' + }, false); // Don't test connection + + try { + await client.call('test.method'); + fail('Expected an error to be thrown'); + } catch (error) { + expect(error.message).toContain('HTTP error 404'); + expect(error.message).toContain('Not Found'); + } + }); + + test('should handle network errors correctly', async () => { + // Mock fetch to throw a network error + global.fetch.mockImplementation(() => + Promise.reject(new Error('Network error')) + ); + + const client = await createJsonRpcClient({ + wikiUrl: 'https://wiki.example.com', + authMethod: 'BASIC_AUTH', + username: 'user', + password: 'password123' + }, false); // Don't test connection + + try { + await client.call('test.method'); + fail('Expected an error to be thrown'); + } catch (error) { + expect(error.message).toContain('Network error'); + } + }); + }); + + describe('testConnection', () => { + test('should return true for successful connection', async () => { + const client = { + call: jest.fn().mockResolvedValue('Test Wiki') + }; + + const result = await testConnection(client); + + expect(result).toBe(true); + expect(client.call).toHaveBeenCalledWith('core.getWikiTitle'); + }); + + test('should throw error for failed connection', async () => { + const client = { + call: jest.fn().mockRejectedValue(new Error('Connection failed')) + }; + + await expect(testConnection(client)).rejects.toThrow('Failed to connect to DokuWiki: Connection failed'); + }); + }); +}); \ No newline at end of file From d0b3308523b1dad9e4dff46289b130be82bcc80c Mon Sep 17 00:00:00 2001 From: Jane Alesi Date: Wed, 21 May 2025 15:18:08 +0200 Subject: [PATCH 08/23] Add tests for error handler module --- tests/errorHandler.test.js | 136 +++++++++++++++++++++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 tests/errorHandler.test.js diff --git a/tests/errorHandler.test.js b/tests/errorHandler.test.js new file mode 100644 index 0000000..8023858 --- /dev/null +++ b/tests/errorHandler.test.js @@ -0,0 +1,136 @@ +/** + * Tests for the error handler module + */ + +// Import the module to test +const { ERROR_MESSAGES, handleApiError, getRecoverySuggestion } = require('../src/errorHandler'); + +describe('Error Handler Module', () => { + describe('handleApiError', () => { + test('should return original error if no code is present', () => { + const error = new Error('Test error'); + const result = handleApiError(error); + + expect(result.message).toBe('Test error'); + expect(result.code).toBeUndefined(); + }); + + test('should enhance error message with user-friendly message', () => { + const error = new Error('Technical error message'); + error.code = 121; + + const result = handleApiError(error); + + expect(result.message).toContain('The requested page (revision) does not exist'); + expect(result.message).toContain('(Code: 121)'); + expect(result.code).toBe(121); + }); + + test('should preserve original technical message', () => { + const error = new Error('Technical database error'); + error.code = 500; + + const result = handleApiError(error); + + expect(result.message).toContain('Server error occurred'); + expect(result.message).toContain('Technical database error'); + expect(result.code).toBe(500); + }); + + test('should not modify error if error code is unknown', () => { + const error = new Error('Unknown error'); + error.code = 9999; + + const result = handleApiError(error); + + expect(result.message).toBe('Unknown error'); + expect(result.code).toBe(9999); + }); + }); + + describe('getRecoverySuggestion', () => { + test('should return null if no code is present', () => { + const error = new Error('Test error'); + const result = getRecoverySuggestion(error); + + expect(result).toBeNull(); + }); + + test('should return authentication suggestion for auth errors', () => { + const error = new Error('Authentication failed'); + error.code = 401; + + const result = getRecoverySuggestion(error); + + expect(result).toContain('Check your username and password'); + expect(result).toContain('authentication method'); + }); + + test('should return permission suggestion for permission errors', () => { + const error = new Error('Permission denied'); + error.code = 403; + + const result = getRecoverySuggestion(error); + + expect(result).toContain('doesn\'t have sufficient permissions'); + expect(result).toContain('wiki administrator'); + }); + + test('should return connection suggestion for connection errors', () => { + const error = new Error('Connection failed'); + error.code = 1001; + + const result = getRecoverySuggestion(error); + + expect(result).toContain('Check your network connection'); + expect(result).toContain('DokuWiki URL is correct'); + }); + + test('should return page locked suggestion for locked page errors', () => { + const error = new Error('Page locked'); + error.code = 133; + + const result = getRecoverySuggestion(error); + + expect(result).toContain('being edited by another user'); + expect(result).toContain('Try again later'); + }); + + test('should return server error suggestion for server errors', () => { + const error = new Error('Server error'); + error.code = 500; + + const result = getRecoverySuggestion(error); + + expect(result).toContain('server is experiencing issues'); + expect(result).toContain('try again later'); + }); + + test('should return null for unknown error codes', () => { + const error = new Error('Unknown error'); + error.code = 9999; + + const result = getRecoverySuggestion(error); + + expect(result).toBeNull(); + }); + }); + + describe('ERROR_MESSAGES', () => { + test('should have appropriate message for page not found', () => { + expect(ERROR_MESSAGES[121]).toBe('The requested page (revision) does not exist'); + }); + + test('should have appropriate message for authentication failure', () => { + expect(ERROR_MESSAGES[401]).toBe('Authentication failed. Please check your credentials'); + }); + + test('should have appropriate message for page locked', () => { + expect(ERROR_MESSAGES[133]).toBe('The page is currently locked'); + }); + + test('should have appropriate message for server error', () => { + expect(ERROR_MESSAGES[500]).toBe('Server error occurred. Please try again later'); + }); + }); +}); \ No newline at end of file From 924e83b7891b3a4df85bf37106db217fb890ff2c Mon Sep 17 00:00:00 2001 From: Jane Alesi Date: Wed, 21 May 2025 15:18:49 +0200 Subject: [PATCH 09/23] Add tests for API client module --- tests/apiClient.test.js | 220 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 tests/apiClient.test.js diff --git a/tests/apiClient.test.js b/tests/apiClient.test.js new file mode 100644 index 0000000..75da49c --- /dev/null +++ b/tests/apiClient.test.js @@ -0,0 +1,220 @@ +/** + * Tests for the API client module + */ + +// Mock the dependencies +jest.mock('../src/auth', () => ({ + createJsonRpcClient: jest.fn(), + testConnection: jest.fn() +})); + +jest.mock('../src/errorHandler', () => ({ + handleApiError: jest.fn(error => error), + getRecoverySuggestion: jest.fn(), + ERROR_MESSAGES: { 121: 'Page not found' } +})); + +// Import mocks for explicit control +const auth = require('../src/auth'); +const errorHandler = require('../src/errorHandler'); + +// Import the module to test +const { createApiClient } = require('../src/apiClient'); + +describe('API Client Module', () => { + // Valid user settings for tests + const userSettings = { + wikiUrl: 'https://wiki.example.com', + authMethod: 'BASIC_AUTH', + username: 'user', + password: 'password123' + }; + + // Sample JSON-RPC client for mocking + const mockJsonRpcClient = { + call: jest.fn() + }; + + beforeEach(() => { + // Reset all mocks before each test + jest.clearAllMocks(); + + // Setup default mocks + auth.createJsonRpcClient.mockResolvedValue(mockJsonRpcClient); + auth.testConnection.mockResolvedValue(true); + mockJsonRpcClient.call.mockResolvedValue('Mock response'); + }); + + describe('createApiClient', () => { + test('should create and initialize API client successfully', async () => { + const client = await createApiClient(userSettings); + + expect(auth.createJsonRpcClient).toHaveBeenCalledWith(userSettings); + expect(auth.testConnection).toHaveBeenCalledWith(mockJsonRpcClient); + expect(client).toBeDefined(); + expect(client._jsonRpcClient).toBe(mockJsonRpcClient); + }); + + test('should skip connection test when disabled', async () => { + const client = await createApiClient(userSettings, false); + + expect(auth.createJsonRpcClient).toHaveBeenCalledWith(userSettings); + expect(auth.testConnection).not.toHaveBeenCalled(); + }); + + test('should throw enhanced error when initialization fails', async () => { + auth.createJsonRpcClient.mockRejectedValue(new Error('Init failed')); + + await expect(createApiClient(userSettings)) + .rejects.toThrow('Failed to initialize DokuWiki client: Init failed'); + }); + + test('should preserve error message if it already mentions DokuWiki', async () => { + auth.createJsonRpcClient.mockRejectedValue(new Error('DokuWiki connection error')); + + await expect(createApiClient(userSettings)) + .rejects.toThrow('DokuWiki connection error'); + }); + }); + + describe('client.call', () => { + test('should delegate to JSON-RPC client correctly', async () => { + const client = await createApiClient(userSettings, false); + + await client.call('test.method', { param: 'value' }); + + expect(mockJsonRpcClient.call).toHaveBeenCalledWith('test.method', { param: 'value' }); + }); + + test('should handle API errors with enhanced error handling', async () => { + const error = new Error('API error'); + error.code = 500; + mockJsonRpcClient.call.mockRejectedValue(error); + errorHandler.handleApiError.mockImplementation((err) => { + err.message = 'Enhanced: ' + err.message; + return err; + }); + errorHandler.getRecoverySuggestion.mockReturnValue('Try this solution'); + + const client = await createApiClient(userSettings, false); + + await expect(client.call('test.method')).rejects.toThrow('Enhanced: API error\n\nPossible solution: Try this solution'); + + expect(errorHandler.handleApiError).toHaveBeenCalledWith(error); + expect(errorHandler.getRecoverySuggestion).toHaveBeenCalled(); + }); + + test('should not add recovery suggestion if none available', async () => { + const error = new Error('API error'); + mockJsonRpcClient.call.mockRejectedValue(error); + errorHandler.handleApiError.mockImplementation((err) => { + err.message = 'Enhanced: ' + err.message; + return err; + }); + errorHandler.getRecoverySuggestion.mockReturnValue(null); + + const client = await createApiClient(userSettings, false); + + await expect(client.call('test.method')).rejects.toThrow('Enhanced: API error'); + }); + }); + + describe('client.pageExists', () => { + test('should return true when page exists', async () => { + const client = await createApiClient(userSettings, false); + mockJsonRpcClient.call.mockResolvedValue({ id: 'test', revision: 123 }); + + const result = await client.pageExists('test:page'); + + expect(result).toBe(true); + expect(mockJsonRpcClient.call).toHaveBeenCalledWith('core.getPageInfo', { page: 'test:page' }); + }); + + test('should return false when page does not exist', async () => { + const error = new Error('Page not found'); + error.code = 121; + mockJsonRpcClient.call.mockRejectedValue(error); + + const client = await createApiClient(userSettings, false); + const result = await client.pageExists('test:nonexistent'); + + expect(result).toBe(false); + }); + + test('should propagate other errors', async () => { + const error = new Error('Server error'); + error.code = 500; + mockJsonRpcClient.call.mockRejectedValue(error); + + const client = await createApiClient(userSettings, false); + + await expect(client.pageExists('test:page')).rejects.toThrow('Server error'); + }); + }); + + describe('client.mediaExists', () => { + test('should return true when media exists', async () => { + const client = await createApiClient(userSettings, false); + mockJsonRpcClient.call.mockResolvedValue({ id: 'test', revision: 123 }); + + const result = await client.mediaExists('test:image.jpg'); + + expect(result).toBe(true); + expect(mockJsonRpcClient.call).toHaveBeenCalledWith('core.getMediaInfo', { media: 'test:image.jpg' }); + }); + + test('should return false when media does not exist', async () => { + const error = new Error('Media not found'); + error.code = 221; + mockJsonRpcClient.call.mockRejectedValue(error); + + const client = await createApiClient(userSettings, false); + const result = await client.mediaExists('test:nonexistent.jpg'); + + expect(result).toBe(false); + }); + + test('should propagate other errors', async () => { + const error = new Error('Server error'); + error.code = 500; + mockJsonRpcClient.call.mockRejectedValue(error); + + const client = await createApiClient(userSettings, false); + + await expect(client.mediaExists('test:image.jpg')).rejects.toThrow('Server error'); + }); + }); + + describe('client.getWikiInfo', () => { + test('should fetch and return wiki information', async () => { + // Mock responses for each API call + mockJsonRpcClient.call + .mockResolvedValueOnce('Test Wiki') // For getWikiTitle + .mockResolvedValueOnce('Release 2023-04-04') // For getWikiVersion + .mockResolvedValueOnce(42); // For getAPIVersion + + const client = await createApiClient(userSettings, false); + const info = await client.getWikiInfo(); + + expect(info).toEqual({ + title: 'Test Wiki', + version: 'Release 2023-04-04', + apiVersion: 42 + }); + + // Verify all API calls were made + expect(mockJsonRpcClient.call).toHaveBeenCalledWith('core.getWikiTitle'); + expect(mockJsonRpcClient.call).toHaveBeenCalledWith('core.getWikiVersion'); + expect(mockJsonRpcClient.call).toHaveBeenCalledWith('core.getAPIVersion'); + }); + + test('should propagate errors from API calls', async () => { + const error = new Error('API error'); + mockJsonRpcClient.call.mockRejectedValue(error); + + const client = await createApiClient(userSettings, false); + + await expect(client.getWikiInfo()).rejects.toThrow('API error'); + }); + }); +}); \ No newline at end of file From fcdbeef2127e7f999ab9ca592aecd6f54bad72f0 Mon Sep 17 00:00:00 2001 From: Jane Alesi Date: Wed, 21 May 2025 15:29:09 +0200 Subject: [PATCH 10/23] Update implementation.js to use modular architecture with enhanced error handling --- implementation.js | 178 ++++++++++++++-------------------------------- 1 file changed, 55 insertions(+), 123 deletions(-) diff --git a/implementation.js b/implementation.js index e390775..b64581c 100644 --- a/implementation.js +++ b/implementation.js @@ -6,18 +6,30 @@ * License: MIT */ -// Main function that will be called by TypingMind -async function manage_dokuwiki(params, userSettings) { - // Parameter validation - if (!params.operation) { - throw new Error("Operation parameter is required"); - } - - // Authenticate and create the JSON-RPC client - const client = await createJsonRpcClient(userSettings); +// Import modules +const { apiClient } = require('./src/index'); +const { handleApiError, getRecoverySuggestion } = require('./src/errorHandler'); - // Route to the appropriate handler based on the operation +/** + * Main entry point for the DokuWiki Manager plugin + * Called by Typing Mind when the plugin is invoked + * + * @param {Object} params - Parameters from the plugin function specification + * @param {Object} userSettings - User settings from the plugin configuration + * @returns {any} - Result of the operation + * @throws {Error} - If the operation fails + */ +async function manage_dokuwiki(params, userSettings) { try { + // Validate operation parameter + if (!params || !params.operation) { + throw new Error("Operation parameter is required"); + } + + // Create and initialize the API client + const client = await apiClient.createApiClient(userSettings); + + // Route to the appropriate handler based on the operation switch (params.operation) { case "get_page": return await getPage(params, client); @@ -26,7 +38,7 @@ async function manage_dokuwiki(params, userSettings) { case "append_page": return await appendPage(params, client); case "list_pages": - return await listPages(params, client); + return await listPages(params, client, userSettings); case "search_pages": return await searchPages(params, client); case "get_page_info": @@ -34,7 +46,7 @@ async function manage_dokuwiki(params, userSettings) { case "get_page_history": return await getPageHistory(params, client); case "list_media": - return await listMedia(params, client); + return await listMedia(params, client, userSettings); case "get_media": return await getMedia(params, client); case "save_media": @@ -45,102 +57,15 @@ async function manage_dokuwiki(params, userSettings) { throw new Error(`Unsupported operation: ${params.operation}`); } } catch (error) { - // Enhanced error handling - if (error.code) { - // Handle specific DokuWiki API error codes - handleDokuWikiError(error); + // Enhance error with recovery suggestions + const enhancedError = handleApiError(error); + const suggestion = getRecoverySuggestion(enhancedError); + + if (suggestion) { + enhancedError.message += `\n\nPossible solution: ${suggestion}`; } - throw error; - } -} - -// Helper Functions - -/** - * Create a JSON-RPC client with authentication - */ -async function createJsonRpcClient(userSettings) { - const { wikiUrl, username, password } = userSettings; - - if (!wikiUrl) { - throw new Error("DokuWiki URL is required in the plugin settings"); - } - - if (!username || !password) { - throw new Error("DokuWiki username and password are required in the plugin settings"); - } - - // Create a basic client object with auth details - const client = { - baseUrl: wikiUrl.endsWith('/') ? `${wikiUrl}lib/exe/jsonrpc.php` : `${wikiUrl}/lib/exe/jsonrpc.php`, - auth: `Basic ${btoa(`${username}:${password}`)}`, - async call(method, params = {}) { - const response = await fetch(this.baseUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': this.auth - }, - body: JSON.stringify({ - jsonrpc: '2.0', - id: Date.now(), - method: method, - params: params - }) - }); - - if (!response.ok) { - throw new Error(`HTTP error! Status: ${response.status}`); - } - - const result = await response.json(); - - if (result.error) { - const error = new Error(result.error.message); - error.code = result.error.code; - throw error; - } - - return result.result; - } - }; - - // Test the connection - try { - await client.call('core.getWikiTitle'); - return client; - } catch (error) { - throw new Error(`Failed to connect to DokuWiki: ${error.message}`); - } -} - -/** - * Handle DokuWiki-specific error codes - */ -function handleDokuWikiError(error) { - // Map of DokuWiki error codes to user-friendly messages - const errorMap = { - 0: "Success", - 111: "You are not allowed to read this page", - 121: "The requested page (revision) does not exist", - 131: "Empty or invalid page ID given", - 132: "Refusing to write an empty new wiki page", - 133: "The page is currently locked", - 134: "The page content was blocked", - 211: "You are not allowed to read this media file", - 212: "You are not allowed to delete this media file", - 221: "The requested media file (revision) does not exist", - 231: "Empty or invalid media ID given", - 232: "Media file is still referenced", - 233: "Failed to delete media file", - 234: "Invalid base64 encoded data", - 235: "Empty file given", - 236: "Failed to save media" - }; - - // Enhance the error message if we have a specific message for this code - if (errorMap[error.code]) { - error.message = `${errorMap[error.code]} (Code: ${error.code})`; + + throw enhancedError; } } @@ -155,12 +80,12 @@ async function getPage(params, client) { try { const result = await client.call('core.getPage', { page: params.pageId, - rev: params.revision || 0 + rev: params.rev || 0 }); return result; } catch (error) { - // Specific handling for get_page errors + // Special handling for non-existent pages if (error.code === 121) { return ""; // Return empty string for non-existent pages (matches DokuWiki behavior) } @@ -183,7 +108,7 @@ async function savePage(params, client) { page: params.pageId, text: params.content, summary: params.summary || "", - isminor: params.isMinor || false + isminor: params.minor || false }); } @@ -202,21 +127,22 @@ async function appendPage(params, client) { page: params.pageId, text: params.content, summary: params.summary || "", - isminor: params.isMinor || false + isminor: params.minor || false }); } /** * List pages in a namespace */ -async function listPages(params, client) { +async function listPages(params, client, userSettings) { const namespace = params.namespace || userSettings?.defaultNamespace || ""; const depth = params.depth || 1; + const includeHash = params.include_hash || false; const result = await client.call('core.listPages', { namespace: namespace, depth: depth, - hash: false + hash: includeHash }); return result; @@ -226,12 +152,14 @@ async function listPages(params, client) { * Search pages by content */ async function searchPages(params, client) { - if (!params.searchQuery) { + if (!params.searchQuery && !params.query) { throw new Error("Search query is required for search_pages operation"); } + const query = params.query || params.searchQuery; + const result = await client.call('core.searchPages', { - query: params.searchQuery + query: query }); return result; @@ -247,9 +175,9 @@ async function getPageInfo(params, client) { const result = await client.call('core.getPageInfo', { page: params.pageId, - rev: params.revision || 0, - author: true, - hash: false + rev: params.rev || 0, + author: params.include_author || false, + hash: params.include_hash || false }); return result; @@ -274,16 +202,17 @@ async function getPageHistory(params, client) { /** * List media files in a namespace */ -async function listMedia(params, client) { +async function listMedia(params, client, userSettings) { const namespace = params.namespace || userSettings?.defaultNamespace || ""; const depth = params.depth || 1; const pattern = params.pattern || ""; + const includeHash = params.include_hash || false; const result = await client.call('core.listMedia', { namespace: namespace, pattern: pattern, depth: depth, - hash: false + hash: includeHash }); return result; @@ -299,7 +228,7 @@ async function getMedia(params, client) { const result = await client.call('core.getMedia', { media: params.mediaId, - rev: params.revision || 0 + rev: params.rev || 0 }); return result; @@ -312,13 +241,16 @@ async function saveMedia(params, client) { if (!params.mediaId) { throw new Error("Media ID is required for save_media operation"); } - if (!params.content) { + + // Check for content parameter (support both content and base64_content) + const base64Content = params.base64_content || params.content; + if (!base64Content) { throw new Error("Content (base64 encoded) is required for save_media operation"); } const result = await client.call('core.saveMedia', { media: params.mediaId, - base64: params.content, + base64: base64Content, overwrite: params.overwrite || false }); From 4dec0b1c7101dc288e40e3f68fa08ec1de25ba02 Mon Sep 17 00:00:00 2001 From: Jane Alesi Date: Wed, 21 May 2025 15:29:21 +0200 Subject: [PATCH 11/23] Add package.json with testing configuration --- package.json | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 package.json diff --git a/package.json b/package.json new file mode 100644 index 0000000..004f32a --- /dev/null +++ b/package.json @@ -0,0 +1,47 @@ +{ + "name": "dokuwiki-manager-plugin", + "version": "1.0.0", + "description": "A TypingMind plugin for managing DokuWiki websites", + "main": "implementation.js", + "scripts": { + "test": "jest", + "test:watch": "jest --watch", + "test:coverage": "jest --coverage" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/satwareAG/dokuwiki-manager-plugin.git" + }, + "keywords": [ + "dokuwiki", + "typingmind", + "plugin", + "wiki", + "json-rpc" + ], + "author": "satware AG", + "license": "MIT", + "bugs": { + "url": "https://github.com/satwareAG/dokuwiki-manager-plugin/issues" + }, + "homepage": "https://github.com/satwareAG/dokuwiki-manager-plugin#readme", + "devDependencies": { + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0" + }, + "jest": { + "testEnvironment": "jsdom", + "coverageThreshold": { + "global": { + "branches": 80, + "functions": 80, + "lines": 80, + "statements": 80 + } + }, + "collectCoverageFrom": [ + "src/**/*.js", + "implementation.js" + ] + } +} \ No newline at end of file From a697281f8df63432dcea85fee12454d25e7a9fb6 Mon Sep 17 00:00:00 2001 From: Jane Alesi Date: Wed, 21 May 2025 15:29:44 +0200 Subject: [PATCH 12/23] Update README.md with enhanced authentication documentation --- README.md | 49 ++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 44 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index ce64161..77276c0 100644 --- a/README.md +++ b/README.md @@ -7,17 +7,40 @@ A powerful plugin for managing DokuWiki websites directly from TypingMind. This - **Page Operations**: Get, create, edit, and search wiki pages - **Media Operations**: List, upload, download, and delete media files - **Information Retrieval**: Get page history, metadata, and search content -- **Secure Authentication**: Basic authentication with your DokuWiki credentials +- **Secure Authentication**: Support for both Basic Authentication and Bearer Token authentication ## Setup 1. **Import the Plugin** into your TypingMind workspace 2. **Configure the Plugin** with your DokuWiki credentials: - **DokuWiki URL**: The full URL to your DokuWiki instance (e.g., `https://wiki.example.com`) - - **Username**: Your DokuWiki username - - **Password**: Your DokuWiki password + - **Authentication Method**: Choose between Basic Auth (username/password) or Bearer Token + - **Username**: Your DokuWiki username (required for Basic Auth) + - **Password/Token**: Your DokuWiki password (for Basic Auth) or Bearer token (for Token Auth) - **Default Namespace** (optional): Default namespace for operations +## Authentication Methods + +The plugin supports two authentication methods: + +### Basic Authentication + +Uses username and password for authentication. This is the standard method for most DokuWiki installations. + +Requirements: +- DokuWiki URL +- Username +- Password + +### Bearer Token Authentication + +Uses a bearer token for authentication. This is useful for applications or services that use token-based authentication. + +Requirements: +- DokuWiki URL +- Bearer Token (in the password field) +- No username required + ## Usage Examples ### Reading a Wiki Page @@ -69,7 +92,7 @@ Show me all media files in the "documentation" namespace of my DokuWiki ## Limitations -- This plugin requires valid DokuWiki user credentials with appropriate permissions +- This plugin requires valid DokuWiki credentials with appropriate permissions - Operations are limited to the permissions of the provided user account - Large media files may encounter issues with browser limitations @@ -79,9 +102,25 @@ Your DokuWiki credentials are stored in your TypingMind account and are transmit ## Troubleshooting -- **Authentication Errors**: Verify your username and password +- **Authentication Errors**: Verify your username and password, or check that your token is valid - **Permission Errors**: Ensure your user has the necessary permissions in DokuWiki - **Connection Errors**: Verify the DokuWiki URL is correct and accessible +- **Token Issues**: Bearer tokens may expire; generate a new token if necessary + +## Development + +This plugin is structured in a modular way: + +- `src/auth.js`: Authentication module supporting both Basic Auth and Bearer Token +- `src/apiClient.js`: API client for communicating with DokuWiki JSON-RPC API +- `src/errorHandler.js`: Error handling for user-friendly error messages +- `implementation.js`: Main entry point for the plugin + +To run tests: + +``` +npm test +``` ## License From b90aa1bced5a284d8d10bcf3b9067b42bcac6015 Mon Sep 17 00:00:00 2001 From: Jane Alesi Date: Wed, 21 May 2025 15:48:37 +0200 Subject: [PATCH 13/23] docs: enhance README with architecture documentation --- README.md | 103 ++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 88 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 77276c0..3855c83 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,94 @@ Show me all media files in the "documentation" namespace of my DokuWiki - `save_media`: Upload a media file - `delete_media`: Delete a media file +## Architecture + +The DokuWiki Manager plugin is built with a modular architecture that provides clean separation of concerns, maintainability, and testability. The architecture consists of the following components: + +### Core Components + +- **Authentication Module** (`src/auth.js`): Handles authentication with DokuWiki API + - Supports both Basic Auth and Bearer Token authentication + - Manages secure credential handling and request signing + - Includes connection testing functionality + +- **API Client Module** (`src/apiClient.js`): Core communication layer with DokuWiki + - Manages JSON-RPC requests to the DokuWiki API + - Implements retry logic and error handling + - Provides utility methods for common operations + +- **Error Handler Module** (`src/errorHandler.js`): Comprehensive error management + - Maps DokuWiki error codes to user-friendly messages + - Provides recovery suggestions for common errors + - Enhances error details for better debugging + +- **Main Entry Point** (`implementation.js`): Plugin integration with TypingMind + - Routes operations to appropriate handler methods + - Coordinates between modules + - Formats responses for TypingMind compatibility + +### Data Flow + +1. **Request Initiation**: TypingMind calls the plugin with operation parameters +2. **Authentication**: Credentials from user settings are used to authenticate +3. **API Communication**: The request is transformed into appropriate JSON-RPC calls +4. **Error Handling**: Responses are checked for errors and enhanced with friendly messages +5. **Response Formatting**: Successful responses are returned to TypingMind + +### Error Handling Strategy + +The plugin implements a robust error handling strategy: + +1. **Error Detection**: Errors are captured at all levels (network, API, application) +2. **Error Enhancement**: Technical errors are augmented with user-friendly messages +3. **Recovery Suggestions**: Where possible, the plugin suggests solutions for errors +4. **Graceful Degradation**: Some errors (like non-existent pages) return empty content instead of errors + +## Developer Documentation + +### Module Structure + +``` +dokuwiki-manager-plugin/ +├── implementation.js # Main entry point +├── plugin.json # Plugin configuration +├── src/ +│ ├── index.js # Module exports +│ ├── auth.js # Authentication module +│ ├── apiClient.js # API client module +│ └── errorHandler.js # Error handling module +├── tests/ +│ ├── auth.test.js # Authentication tests +│ ├── apiClient.test.js # API client tests +│ └── errorHandler.test.js # Error handler tests +└── package.json # NPM configuration +``` + +### Testing + +The plugin has 80%+ test coverage with Jest. Each module has a corresponding test file that verifies its functionality. + +To run tests: + +```bash +npm test # Run all tests +npm run test:watch # Run tests in watch mode +npm run test:coverage # Run tests with coverage report +``` + +### Error Code Reference + +The plugin maps DokuWiki error codes to user-friendly messages: + +- **0**: Success +- **111**: Not allowed to read page +- **121**: Page doesn't exist +- **133**: Page is locked +- **221**: Media file doesn't exist +- **401**: Authentication failed +- **403**: Insufficient permissions +- **500**: Server error + ## Limitations - This plugin requires valid DokuWiki credentials with appropriate permissions @@ -107,21 +195,6 @@ Your DokuWiki credentials are stored in your TypingMind account and are transmit - **Connection Errors**: Verify the DokuWiki URL is correct and accessible - **Token Issues**: Bearer tokens may expire; generate a new token if necessary -## Development - -This plugin is structured in a modular way: - -- `src/auth.js`: Authentication module supporting both Basic Auth and Bearer Token -- `src/apiClient.js`: API client for communicating with DokuWiki JSON-RPC API -- `src/errorHandler.js`: Error handling for user-friendly error messages -- `implementation.js`: Main entry point for the plugin - -To run tests: - -``` -npm test -``` - ## License This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. \ No newline at end of file From f850a7619e1e1f38fc9b0221f98d62bb32cf04cd Mon Sep 17 00:00:00 2001 From: Michael Wegener Date: Wed, 21 May 2025 16:22:12 +0200 Subject: [PATCH 14/23] Add DokuWiki dev environment and linting setup Introduce a Docker-based development environment for DokuWiki with `docker-compose`, including start/stop scripts. Configure ESLint and Prettier for coding standards, and update scripts in `package.json` for linting and formatting. Adjust `.gitignore` to exclude local dev data while preserving essential files. --- .eslintrc.js | 25 +++++++++++++++++++++++++ .gitignore | 14 +++++++++++++- .prettierrc | 8 ++++++++ dokuwiki-dev/docker-compose.yml | 17 +++++++++++++++++ dokuwiki-dev/start.sh | 18 ++++++++++++++++++ dokuwiki-dev/stop.sh | 4 ++++ package.json | 14 +++++++++++--- 7 files changed, 96 insertions(+), 4 deletions(-) create mode 100644 .eslintrc.js create mode 100644 .prettierrc create mode 100644 dokuwiki-dev/docker-compose.yml create mode 100755 dokuwiki-dev/start.sh create mode 100755 dokuwiki-dev/stop.sh diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..d741523 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,25 @@ +module.exports = { + env: { + browser: true, + es2021: true, + node: true, + jest: true + }, + extends: [ + 'eslint:recommended', + 'plugin:jest/recommended', + 'prettier' + ], + plugins: ['jest'], + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module' + }, + rules: { + 'no-unused-vars': ['error', { argsIgnorePattern: '^_' }], + 'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off', + 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off', + 'semi': ['error', 'always'], + 'quotes': ['error', 'single'] + } +}; diff --git a/.gitignore b/.gitignore index 54f107e..41c53c7 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ node_modules/ jspm_packages/ +package-lock.json + # Optional npm cache directory .npm @@ -17,4 +19,14 @@ jspm_packages/ npm-debug.log* yarn-debug.log* yarn-error.log* -lerna-debug.log* \ No newline at end of file +lerna-debug.log* + +# Local DokuWiki instance data +/dokuwiki-test/ +# DokuWiki development environment data +/dokuwiki-dev/data/ + +# Keep Docker Compose files and scripts +!/dokuwiki-dev/docker-compose.yml +!/dokuwiki-dev/start.sh +!/dokuwiki-dev/stop.sh diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..fe7e2a6 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,8 @@ +{ + "semi": true, + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "es5", + "printWidth": 100, + "bracketSpacing": true +} diff --git a/dokuwiki-dev/docker-compose.yml b/dokuwiki-dev/docker-compose.yml new file mode 100644 index 0000000..b32a311 --- /dev/null +++ b/dokuwiki-dev/docker-compose.yml @@ -0,0 +1,17 @@ +version: '3' +services: + dokuwiki: + image: lscr.io/linuxserver/dokuwiki:latest + container_name: dokuwiki_dev + environment: + - PUID=1000 # Update with your user ID (run 'id -u' to find) + - PGID=1000 # Update with your group ID (run 'id -g' to find) + - TZ=Europe/Berlin # Update with your timezone + - PHP_MEMORYLIMIT=512M + volumes: + - ./data:/config + # This maps the parent directory (your plugin) to the DokuWiki plugins directory + - ..:/config/plugins/dokuwiki-manager + ports: + - 8080:80 + restart: unless-stopped diff --git a/dokuwiki-dev/start.sh b/dokuwiki-dev/start.sh new file mode 100755 index 0000000..2033f9e --- /dev/null +++ b/dokuwiki-dev/start.sh @@ -0,0 +1,18 @@ +#!/bin/bash +echo "Starting DokuWiki development environment..." + +# Get user and group IDs for correct permissions +USER_ID=$(id -u) +GROUP_ID=$(id -g) + +# Update Docker Compose file with current user/group IDs +sed -i "s/PUID=1000/PUID=$USER_ID/" docker-compose.yml +sed -i "s/PGID=1000/PGID=$GROUP_ID/" docker-compose.yml + +# Create data directory if it doesn't exist +mkdir -p data + +docker-compose up -d +echo "DokuWiki is starting at http://localhost:8080" +echo "For first-time setup, go to http://localhost:8080/install.php" +echo "To stop the environment, run: ./stop.sh" diff --git a/dokuwiki-dev/stop.sh b/dokuwiki-dev/stop.sh new file mode 100755 index 0000000..8ed40d2 --- /dev/null +++ b/dokuwiki-dev/stop.sh @@ -0,0 +1,4 @@ +#!/bin/bash +echo "Stopping DokuWiki development environment..." +docker-compose down +echo "DokuWiki environment stopped." diff --git a/package.json b/package.json index 004f32a..a803005 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,10 @@ "scripts": { "test": "jest", "test:watch": "jest --watch", - "test:coverage": "jest --coverage" + "test:coverage": "jest --coverage", + "lint": "eslint --ext .js .", + "format": "prettier --write \"**/*.{js,json,md}\"", + "dev": "node ./examples/test-client.js" }, "repository": { "type": "git", @@ -26,8 +29,13 @@ }, "homepage": "https://github.com/satwareAG/dokuwiki-manager-plugin#readme", "devDependencies": { + "eslint": "^9.27.0", + "eslint-config-prettier": "^10.1.5", + "eslint-plugin-jest": "^28.11.0", "jest": "^29.7.0", - "jest-environment-jsdom": "^29.7.0" + "jest-environment-jsdom": "^29.7.0", + "node-fetch": "^2.7.0", + "prettier": "^3.5.3" }, "jest": { "testEnvironment": "jsdom", @@ -44,4 +52,4 @@ "implementation.js" ] } -} \ No newline at end of file +} From 915790f83f6ad2358dbe0f9ce06d2e62ba8a0d4c Mon Sep 17 00:00:00 2001 From: Michael Wegener Date: Wed, 21 May 2025 16:24:57 +0200 Subject: [PATCH 15/23] Update .gitignore to specify .idea/workspace.xml Previously, the entire .idea/ directory was ignored. This change narrows the scope to only ignore workspace.xml, allowing the rest of the .idea/ directory to be included in version control if necessary. --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 41c53c7..ecef6c6 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,7 @@ package-lock.json .npm # Editor directories and files -.idea/ +.idea/workspace.xml .vscode/ *.swp *.swo From bd83b69930beeb7fbf8a6e10334c9b9c6fe03de9 Mon Sep 17 00:00:00 2001 From: Michael Wegener Date: Wed, 21 May 2025 16:26:29 +0200 Subject: [PATCH 16/23] Add shared IntelliJ IDEA project configurations Include shared inspection profiles, run configurations for linting, testing, and script execution, and update `.gitignore` to exclude user-specific files while preserving shared settings. This ensures consistent IDE settings across the team. --- .gitignore | 10 +++++++++- .idea/.gitignore | 8 ++++++++ .idea/inspectionProfiles/Project_Default.xml | 6 ++++++ .idea/jsLinters/eslint.xml | 6 ++++++ .idea/runConfigurations/Format.xml | 12 ++++++++++++ .idea/runConfigurations/Jest_Coverage.xml | 12 ++++++++++++ .idea/runConfigurations/Jest_Tests.xml | 11 +++++++++++ .idea/runConfigurations/Lint.xml | 12 ++++++++++++ .idea/runConfigurations/Start_DokuWiki.xml | 17 +++++++++++++++++ .idea/runConfigurations/Stop_DokuWiki.xml | 17 +++++++++++++++++ .idea/runConfigurations/Test_Client.xml | 5 +++++ 11 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 .idea/.gitignore create mode 100644 .idea/inspectionProfiles/Project_Default.xml create mode 100644 .idea/jsLinters/eslint.xml create mode 100644 .idea/runConfigurations/Format.xml create mode 100644 .idea/runConfigurations/Jest_Coverage.xml create mode 100644 .idea/runConfigurations/Jest_Tests.xml create mode 100644 .idea/runConfigurations/Lint.xml create mode 100644 .idea/runConfigurations/Start_DokuWiki.xml create mode 100644 .idea/runConfigurations/Stop_DokuWiki.xml create mode 100644 .idea/runConfigurations/Test_Client.xml diff --git a/.gitignore b/.gitignore index ecef6c6..e020d5c 100644 --- a/.gitignore +++ b/.gitignore @@ -7,8 +7,16 @@ package-lock.json # Optional npm cache directory .npm -# Editor directories and files +# IntelliJ IDEA - ignore user-specific files, but keep shared configurations .idea/workspace.xml +.idea/tasks.xml +.idea/usage.statistics.xml +.idea/dictionaries +.idea/shelf +*.iws + + +# Editor directories and files .vscode/ *.swp *.swo diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..03d9549 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/jsLinters/eslint.xml b/.idea/jsLinters/eslint.xml new file mode 100644 index 0000000..541945b --- /dev/null +++ b/.idea/jsLinters/eslint.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations/Format.xml b/.idea/runConfigurations/Format.xml new file mode 100644 index 0000000..946f564 --- /dev/null +++ b/.idea/runConfigurations/Format.xml @@ -0,0 +1,12 @@ + + + + + +