diff --git a/src/js/components/Import/FromFileUpload/FileUploadForm.tsx b/src/js/components/Import/FromFileUpload/FileUploadForm.tsx new file mode 100644 index 00000000..f2c8b7b3 --- /dev/null +++ b/src/js/components/Import/FromFileUpload/FileUploadForm.tsx @@ -0,0 +1,201 @@ +import React, { useState, useRef, useEffect } from 'react' +import { __ } from '@wordpress/i18n' +import { Button } from '../../common/Button' +import { + DuplicateActionSelector, + DragDropUploadArea, + SelectedFilesList, + SnippetSelectionTable, + ImportResultDisplay +} from './components' +import { ImportCard } from '../shared' +import { + useFileSelection, + useSnippetSelection, + useImportWorkflow +} from './hooks' + +type DuplicateAction = 'ignore' | 'replace' | 'skip' +type Step = 'upload' | 'select' + +export const FileUploadForm: React.FC = () => { + const [duplicateAction, setDuplicateAction] = useState('ignore') + const [currentStep, setCurrentStep] = useState('upload') + const selectSectionRef = useRef(null) + + const fileSelection = useFileSelection() + const importWorkflow = useImportWorkflow() + const snippetSelection = useSnippetSelection(importWorkflow.availableSnippets) + + useEffect(() => { + if (currentStep === 'select' && selectSectionRef.current) { + selectSectionRef.current.scrollIntoView({ + behavior: 'smooth', + block: 'start' + }) + } + }, [currentStep]) + + const handleFileSelect = (files: FileList | null) => { + fileSelection.handleFileSelect(files) + importWorkflow.clearUploadResult() + } + + const handleParseFiles = async () => { + if (!fileSelection.selectedFiles) return + + const success = await importWorkflow.parseFiles(fileSelection.selectedFiles) + if (success) { + snippetSelection.clearSelection() + setCurrentStep('select') + } + } + + const handleImportSelected = async () => { + const snippetsToImport = snippetSelection.getSelectedSnippets() + await importWorkflow.importSnippets(snippetsToImport, duplicateAction) + } + + const handleBackToUpload = () => { + setCurrentStep('upload') + fileSelection.clearFiles() + snippetSelection.clearSelection() + importWorkflow.resetWorkflow() + } + + const isUploadDisabled = !fileSelection.selectedFiles || + fileSelection.selectedFiles.length === 0 || + importWorkflow.isUploading + + const isImportDisabled = snippetSelection.selectedSnippets.size === 0 || + importWorkflow.isImporting + + return ( +
+
+

{__('Upload one or more Code Snippets export files and the snippets will be imported.', 'code-snippets')}

+ +

+ {__('Afterward, you will need to visit the ', 'code-snippets')} + + {__('All Snippets', 'code-snippets')} + + {__(' page to activate the imported snippets.', 'code-snippets')} +

+ + {currentStep === 'upload' && ( + <> + + {(!importWorkflow.uploadResult || !importWorkflow.uploadResult.success) && ( + <> + + + +

{__('Choose Files', 'code-snippets')}

+

+ {__('Choose one or more Code Snippets (.xml or .json) files to parse and preview.', 'code-snippets')} +

+ + + + {fileSelection.selectedFiles && fileSelection.selectedFiles.length > 0 && ( + + )} + +
+ +
+
+ + )} + + )} + + {currentStep === 'select' && importWorkflow.availableSnippets.length > 0 && !importWorkflow.uploadResult?.success && ( + +
+ +
+
+
+

{__('Available Snippets', 'code-snippets')} ({importWorkflow.availableSnippets.length})

+

+ {__('Select the snippets you want to import:', 'code-snippets')} +

+
+
+ + +
+
+ + + +
+ + +
+
+ )} + + {importWorkflow.uploadResult && ( + + )} +
+
+ ) +} diff --git a/src/js/components/Import/FromFileUpload/components/DragDropUploadArea.tsx b/src/js/components/Import/FromFileUpload/components/DragDropUploadArea.tsx new file mode 100644 index 00000000..15ead294 --- /dev/null +++ b/src/js/components/Import/FromFileUpload/components/DragDropUploadArea.tsx @@ -0,0 +1,66 @@ +import React from 'react' +import { __ } from '@wordpress/i18n' +import { useDragAndDrop } from '../hooks/useDragAndDrop' + +interface DragDropUploadAreaProps { + fileInputRef: React.RefObject + onFileSelect: (files: FileList | null) => void + disabled?: boolean +} + +export const DragDropUploadArea: React.FC = ({ + fileInputRef, + onFileSelect, + disabled = false +}) => { + const { dragOver, handleDragOver, handleDragLeave, handleDrop } = useDragAndDrop({ + onFilesDrop: onFileSelect + }) + + const handleClick = () => { + if (!disabled) { + fileInputRef.current?.click() + } + } + + return ( + <> +
+
📁
+

+ {__('Drag and drop files here, or click to browse', 'code-snippets')} +

+

+ {__('Supports JSON and XML files', 'code-snippets')} +

+
+ + onFileSelect(e.target.files)} + style={{ display: 'none' }} + disabled={disabled} + /> + + ) +} diff --git a/src/js/components/Import/FromFileUpload/components/DuplicateActionSelector.tsx b/src/js/components/Import/FromFileUpload/components/DuplicateActionSelector.tsx new file mode 100644 index 00000000..cd8a28b8 --- /dev/null +++ b/src/js/components/Import/FromFileUpload/components/DuplicateActionSelector.tsx @@ -0,0 +1,70 @@ +import React from 'react' +import { __ } from '@wordpress/i18n' +import { ImportCard } from '../../shared' + +type DuplicateAction = 'ignore' | 'replace' | 'skip' + +interface DuplicateActionSelectorProps { + value: DuplicateAction + onChange: (action: DuplicateAction) => void +} + +export const DuplicateActionSelector: React.FC = ({ + value, + onChange +}) => { + return ( + +

{__('Duplicate Snippets', 'code-snippets')}

+

+ {__('What should happen if an existing snippet is found with an identical name to an imported snippet?', 'code-snippets')} +

+ +
+
+ + + + + +
+
+
+ ) +} diff --git a/src/js/components/Import/FromFileUpload/components/ImportResultDisplay.tsx b/src/js/components/Import/FromFileUpload/components/ImportResultDisplay.tsx new file mode 100644 index 00000000..38bd186e --- /dev/null +++ b/src/js/components/Import/FromFileUpload/components/ImportResultDisplay.tsx @@ -0,0 +1,74 @@ +import React from 'react' +import { __ } from '@wordpress/i18n' +import { ImportCard } from '../../shared' + +interface ImportResult { + success: boolean + message: string + imported?: number + warnings?: string[] +} + +interface ImportResultDisplayProps { + result: ImportResult +} + +export const ImportResultDisplay: React.FC = ({ result }) => { + return ( + +
+
+ + {result.success ? '✓' : '✕'} + +
+
+

+ {result.success + ? __('Import Successful!', 'code-snippets') + : __('Import Failed', 'code-snippets') + } +

+

+ {result.message} +

+ + {result.success && ( +

+ {__('Go to ', 'code-snippets')} + + {__('All Snippets', 'code-snippets')} + + {__(' to activate your imported snippets.', 'code-snippets')} +

+ )} + + {result.warnings && result.warnings.length > 0 && ( +
+

+ {__('Warnings:', 'code-snippets')} +

+
    + {result.warnings.map((warning, index) => ( +
  • + {warning} +
  • + ))} +
+
+ )} +
+
+
+ ) +} diff --git a/src/js/components/Import/FromFileUpload/components/SelectedFilesList.tsx b/src/js/components/Import/FromFileUpload/components/SelectedFilesList.tsx new file mode 100644 index 00000000..0cc70c49 --- /dev/null +++ b/src/js/components/Import/FromFileUpload/components/SelectedFilesList.tsx @@ -0,0 +1,65 @@ +import React from 'react' +import { __ } from '@wordpress/i18n' +import { formatFileSize } from '../utils/fileUtils' + +interface SelectedFilesListProps { + files: FileList + onRemoveFile: (index: number) => void +} + +export const SelectedFilesList: React.FC = ({ + files, + onRemoveFile +}) => { + return ( +
+

+ {__('Selected Files:', 'code-snippets')} ({files.length}) +

+
+ {Array.from(files).map((file, index) => ( +
+
+ 📄 +
+
{file.name}
+
+ {formatFileSize(file.size)} +
+
+
+ +
+ ))} +
+
+ ) +} diff --git a/src/js/components/Import/FromFileUpload/components/SnippetSelectionTable.tsx b/src/js/components/Import/FromFileUpload/components/SnippetSelectionTable.tsx new file mode 100644 index 00000000..f31d468f --- /dev/null +++ b/src/js/components/Import/FromFileUpload/components/SnippetSelectionTable.tsx @@ -0,0 +1,90 @@ +import React from 'react' +import { __ } from '@wordpress/i18n' +import type { ImportableSnippet } from '../../../../hooks/useFileUploadAPI' + +interface SnippetSelectionTableProps { + snippets: ImportableSnippet[] + selectedSnippets: Set + isAllSelected: boolean + onSnippetToggle: (snippetId: number | string) => void + onSelectAll: () => void +} + +export const SnippetSelectionTable: React.FC = ({ + snippets, + selectedSnippets, + isAllSelected, + onSnippetToggle, + onSelectAll +}) => { + const getTypeColor = (type: string): string => { + switch (type) { + case 'css': return '#9B59B6' + case 'js': return '#FFEB3B' + case 'html': return '#EF6A36' + default: return '#1D97C6' + } + } + + const truncateDescription = (description: string | undefined): string => { + const desc = description || __('No description', 'code-snippets') + return desc.length > 50 ? desc.substring(0, 50) + '...' : desc + } + + return ( + + + + + + + + + + + + {snippets.map(snippet => ( + + + + + + + + ))} + +
+ + {__('Name', 'code-snippets')}{__('Type', 'code-snippets')}{__('Description', 'code-snippets')}{__('Tags', 'code-snippets')}
+ onSnippetToggle(snippet.table_data.id)} + /> + + {snippet.table_data.title} + {snippet.source_file && ( +
+ from {snippet.source_file} +
+ )} +
+ + {snippet.table_data.type} + + + {truncateDescription(snippet.table_data.description)} + {snippet.table_data.tags || '—'}
+ ) +} diff --git a/src/js/components/Import/FromOtherPlugins/ImportForm.tsx b/src/js/components/Import/FromOtherPlugins/ImportForm.tsx new file mode 100644 index 00000000..383e2aed --- /dev/null +++ b/src/js/components/Import/FromOtherPlugins/ImportForm.tsx @@ -0,0 +1,142 @@ +import React, { useState } from 'react' +import { __ } from '@wordpress/i18n' +import { + ImporterSelector, + ImportOptions, + SimpleSnippetTable, + StatusDisplay +} from './components' +import { ImportCard } from '../shared' +import { + useImporterSelection, + useSnippetImport, + useImportSnippetSelection +} from './hooks' + +export const ImportForm: React.FC = () => { + const [autoAddTags, setAutoAddTags] = useState(false) + + const importerSelection = useImporterSelection() + const snippetImport = useSnippetImport() + const snippetSelection = useImportSnippetSelection(snippetImport.snippets) + + const handleImporterChange = async (newImporter: string) => { + importerSelection.handleImporterChange(newImporter) + snippetSelection.clearSelection() + snippetImport.resetAll() + + if (newImporter) { + await snippetImport.loadSnippets(newImporter) + } + } + + const handleImport = async () => { + const selectedIds = Array.from(snippetSelection.selectedSnippets) + const success = await snippetImport.importSnippets( + importerSelection.selectedImporter, + selectedIds, + autoAddTags, + importerSelection.tagValue + ) + + if (success) { + snippetSelection.clearSelection() + } + } + + if (importerSelection.isLoading) { + return ( +
+

{__('Loading importers...', 'code-snippets')}

+
+ ) + } + + if (importerSelection.error) { + return ( +
+
+

{__('Error loading importers:', 'code-snippets')} {importerSelection.error}

+
+
+ ) + } + + return ( +
+
+

{__('If you are using another Snippets plugin, you can import all existing snippets to your Code Snippets library.', 'code-snippets')}

+ + + + {snippetImport.snippetsError && ( + + )} + + {snippetImport.importError && ( + + )} + + {snippetImport.importSuccess.length > 0 && ( + + )} + + {importerSelection.selectedImporter && + !snippetImport.isLoadingSnippets && + !snippetImport.snippetsError && + snippetImport.snippets.length === 0 && + snippetImport.importSuccess.length === 0 && ( + +
+
📭
+

+ {__('No snippets found', 'code-snippets')} +

+

+ {__('No snippets were found for the selected plugin. Make sure the plugin is installed and has snippets configured.', 'code-snippets')} +

+
+
+ )} + + {snippetImport.snippets.length > 0 && ( + <> + + + + + )} +
+
+ ) +} diff --git a/src/js/components/Import/FromOtherPlugins/components/ImportOptions.tsx b/src/js/components/Import/FromOtherPlugins/components/ImportOptions.tsx new file mode 100644 index 00000000..66fd1b02 --- /dev/null +++ b/src/js/components/Import/FromOtherPlugins/components/ImportOptions.tsx @@ -0,0 +1,52 @@ +import React from 'react' +import { __ } from '@wordpress/i18n' +import { ImportCard } from '../../shared' + +interface ImportOptionsProps { + autoAddTags: boolean + tagValue: string + onAutoAddTagsChange: (enabled: boolean) => void + onTagValueChange: (value: string) => void +} + +export const ImportOptions: React.FC = ({ + autoAddTags, + tagValue, + onAutoAddTagsChange, + onTagValueChange +}) => { + return ( + +

{__('Import options', 'code-snippets')}

+ +
+ ) +} diff --git a/src/js/components/Import/FromOtherPlugins/components/ImporterSelector.tsx b/src/js/components/Import/FromOtherPlugins/components/ImporterSelector.tsx new file mode 100644 index 00000000..010358b9 --- /dev/null +++ b/src/js/components/Import/FromOtherPlugins/components/ImporterSelector.tsx @@ -0,0 +1,50 @@ +import React from 'react' +import { __ } from '@wordpress/i18n' +import type { Importer } from '../../../../hooks/useImportersAPI' +import { ImportCard } from '../../shared' + +interface ImporterSelectorProps { + importers: Importer[] + selectedImporter: string + onImporterChange: (importerName: string) => void + isLoading: boolean +} + +export const ImporterSelector: React.FC = ({ + importers, + selectedImporter, + onImporterChange, + isLoading +}) => { + return ( + + + + {isLoading && ( +

+ {__('Loading snippets...', 'code-snippets')} +

+ )} +
+ ) +} diff --git a/src/js/components/Import/FromOtherPlugins/components/SimpleSnippetTable.tsx b/src/js/components/Import/FromOtherPlugins/components/SimpleSnippetTable.tsx new file mode 100644 index 00000000..30e3a823 --- /dev/null +++ b/src/js/components/Import/FromOtherPlugins/components/SimpleSnippetTable.tsx @@ -0,0 +1,102 @@ +import React from 'react' +import { __ } from '@wordpress/i18n' +import { Button } from '../../../common/Button' +import type { ImportableSnippet } from '../../../../hooks/useImportersAPI' +import { ImportCard } from '../../shared' + +interface SimpleSnippetTableProps { + snippets: ImportableSnippet[] + selectedSnippets: Set + onSnippetToggle: (snippetId: number) => void + onSelectAll: () => void + onImport: () => void + isImporting: boolean +} + +export const SimpleSnippetTable: React.FC = ({ + snippets, + selectedSnippets, + onSnippetToggle, + onSelectAll, + onImport, + isImporting +}) => { + const isAllSelected = selectedSnippets.size === snippets.length && snippets.length > 0 + + return ( + +
+
+

{__('Available Snippets', 'code-snippets')} ({snippets.length})

+

{__('We found the following snippets.', 'code-snippets')}

+
+
+ + +
+
+ + + + + + + + + + + {snippets.map(snippet => ( + + + + + + ))} + +
+ + {__('Snippet Name', 'code-snippets')}{__('ID', 'code-snippets')}
+ onSnippetToggle(snippet.table_data.id)} + /> + {snippet.table_data.title}{snippet.table_data.id}
+ +
+ + +
+
+ ) +} diff --git a/src/js/components/Import/FromOtherPlugins/components/StatusDisplay.tsx b/src/js/components/Import/FromOtherPlugins/components/StatusDisplay.tsx new file mode 100644 index 00000000..e5d0e919 --- /dev/null +++ b/src/js/components/Import/FromOtherPlugins/components/StatusDisplay.tsx @@ -0,0 +1,55 @@ +import React from 'react' +import { __ } from '@wordpress/i18n' +import { ImportCard } from '../../shared' + +interface StatusDisplayProps { + type: 'error' | 'success' + title: string + message: string + showSnippetsLink?: boolean +} + +export const StatusDisplay: React.FC = ({ + type, + title, + message, + showSnippetsLink = false +}) => { + const isError = type === 'error' + + return ( + +
+ + {isError ? '✕' : '✓'} + +
+
+

+ {title} +

+

+ {message} + {showSnippetsLink && ( + <> + {' '} + + {__('Code Snippets Library', 'code-snippets')} + . + + )} +

+
+
+ ) +} diff --git a/src/js/components/Import/ImportApp.tsx b/src/js/components/Import/ImportApp.tsx new file mode 100644 index 00000000..a000f562 --- /dev/null +++ b/src/js/components/Import/ImportApp.tsx @@ -0,0 +1,62 @@ +import React, { useState, useEffect } from 'react' +import { __ } from '@wordpress/i18n' +import { FileUploadForm } from './FromFileUpload/FileUploadForm' +import { ImportForm } from './FromOtherPlugins/ImportForm' +import { ImportSection } from './shared' + +type TabType = 'upload' | 'plugins' + +export const ImportApp: React.FC = () => { + const [activeTab, setActiveTab] = useState('upload') + + useEffect(() => { + const urlParams = new URLSearchParams(window.location.search) + const tabParam = urlParams.get('tab') as TabType + if (tabParam === 'plugins' || tabParam === 'upload') { + setActiveTab(tabParam) + } + }, []) + + const handleTabChange = (tab: TabType) => { + setActiveTab(tab) + + const url = new URL(window.location.href) + url.searchParams.set('tab', tab) + window.history.replaceState({}, '', url) + } + + return ( + + ) +} diff --git a/src/js/components/Import/shared/components/ImportCard.tsx b/src/js/components/Import/shared/components/ImportCard.tsx new file mode 100644 index 00000000..f2420de1 --- /dev/null +++ b/src/js/components/Import/shared/components/ImportCard.tsx @@ -0,0 +1,45 @@ +import React from 'react' +import classnames from 'classnames' +import type { HTMLAttributes } from 'react' + +export interface ImportCardProps extends Omit, 'className'> { + children: React.ReactNode + className?: string + variant?: 'default' | 'controls' +} + +export const ImportCard = React.forwardRef(({ + children, + className, + variant = 'default', + style, + ...props +}, ref) => { + const cardStyle: React.CSSProperties = { + backgroundColor: '#ffffff', + padding: '25px', + borderRadius: '5px', + border: '1px solid #e0e0e0', + marginBlockEnd: '10px', + width: '100%', + ...style + } + + return ( +
+ {children} +
+ ) +}) + +ImportCard.displayName = 'ImportCard' diff --git a/src/js/components/Import/shared/components/ImportSection.tsx b/src/js/components/Import/shared/components/ImportSection.tsx new file mode 100644 index 00000000..255fd2a3 --- /dev/null +++ b/src/js/components/Import/shared/components/ImportSection.tsx @@ -0,0 +1,33 @@ +import React from 'react' +import type { HTMLAttributes } from 'react' + +export interface ImportSectionProps extends Omit, 'style'> { + children: React.ReactNode + active?: boolean + className?: string + style?: React.CSSProperties +} + +export const ImportSection: React.FC = ({ + children, + active = false, + className, + style, + ...props +}) => { + const sectionStyle: React.CSSProperties = { + display: active ? 'block' : 'none', + paddingBlockStart: 0, + ...style + } + + return ( +
+ {children} +
+ ) +} diff --git a/src/php/Admin/Menus/Manage_Menu.php b/src/php/Admin/Menus/Manage_Menu.php index c3eb2266..3feea5b2 100644 --- a/src/php/Admin/Menus/Manage_Menu.php +++ b/src/php/Admin/Menus/Manage_Menu.php @@ -3,7 +3,6 @@ namespace Code_Snippets\Admin\Menus; use Code_Snippets\Admin\Contextual_Help; -use Code_Snippets\Model\Snippet; use Code_Snippets\Utils\Code_Highlighter; use function Code_Snippets\code_snippets; use function Code_Snippets\get_snippets; @@ -130,6 +129,7 @@ public function register_compact_menu() { return; } + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Value is matched to known classes. $sub = code_snippets()->get_menu_slug( isset( $_GET['sub'] ) ? sanitize_key( $_GET['sub'] ) : 'snippets' ); $classmap = array( diff --git a/src/php/Admin/Menus/Settings_Menu.php b/src/php/Admin/Menus/Settings_Menu.php index a8fdb707..588d6034 100644 --- a/src/php/Admin/Menus/Settings_Menu.php +++ b/src/php/Admin/Menus/Settings_Menu.php @@ -183,6 +183,7 @@ public function get_current_section( string $default_section = 'general' ): stri return $default_section; } + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Value is matched to registered sections. $active_tab = isset( $_REQUEST['section'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['section'] ) ) : $default_section; return isset( $sections[ $active_tab ] ) ? $active_tab : $default_section; } diff --git a/src/php/Core/load.php b/src/php/Core/load.php index c9a6d171..2a951f7b 100644 --- a/src/php/Core/load.php +++ b/src/php/Core/load.php @@ -8,7 +8,6 @@ namespace Code_Snippets; use Composer\Autoload\ClassLoader; -use function Code_Snippets\Utils\update_self_option; /** * The version number for this release of the plugin. @@ -46,23 +45,25 @@ */ const REST_API_NAMESPACE = 'code-snippets/v'; -// Load dependencies with Composer. - -$code_snippets_autoloader = require dirname( __DIR__, 2 ) . '/vendor/autoload.php'; +/** + * Load the Composer autoloader. + * + * After loading, remove any PSR-4 namespace mappings that do not start with our vendor prefix but have a corresponding + * prefixed version, as these are not removed by Imposter and would cause collisions with other plugins that use the same + * libraries. + * + * @var ClassLoader $autoloader Composer autoloader instance. + */ +$autoloader = require dirname( __DIR__, 2 ) . '/vendor/autoload.php'; -// Remove all original (non-prefixed) vendor namespace mappings to prevent collisions with other plugins. -// Since Imposter rewrites namespaces to Code_Snippets\Vendor\*, we need to remove the original PSR-4 -// mappings that Composer generates so other plugins can load their own copies of these libraries. -if ( $code_snippets_autoloader instanceof ClassLoader ) { - $prefixes = $code_snippets_autoloader->getPrefixesPsr4(); - $our_prefix = 'Code_Snippets\\Vendor\\'; +if ( $autoloader instanceof ClassLoader ) { + $vendor_prefix = __NAMESPACE__ . '\\Vendor\\'; - foreach ( $prefixes as $namespace => $paths ) { + foreach ( $autoloader->getPrefixesPsr4() as $namespace => $paths ) { // Remove any non-Code_Snippets namespace that has a corresponding prefixed version. - if ( strpos( $namespace, $our_prefix ) === false ) { - $prefixed_namespace = $our_prefix . $namespace; - if ( isset( $prefixes[ $prefixed_namespace ] ) ) { - $code_snippets_autoloader->setPsr4( $namespace, [] ); + if ( false === strpos( $namespace, $vendor_prefix ) ) { + if ( isset( $prefixes[ $vendor_prefix . $namespace ] ) ) { + $autoloader->setPsr4( $namespace, [] ); } } } diff --git a/src/php/Flat_Files/Snippet_Files.php b/src/php/Flat_Files/Snippet_Files.php index 9b584a8b..050e1588 100644 --- a/src/php/Flat_Files/Snippet_Files.php +++ b/src/php/Flat_Files/Snippet_Files.php @@ -573,7 +573,6 @@ private static function load_active_snippets_from_file( function ( $snippet ) use ( $scopes, $shared_ids ) { $active_value = isset( $snippet['active'] ) ? intval( $snippet['active'] ) : 0; - $is_active = DB::is_network_snippet_enabled( $active_value, intval( $snippet['id'] ), $shared_ids ); return ( $is_active || 'condition' === $snippet['scope'] ) && diff --git a/src/php/Model/Snippet.php b/src/php/Model/Snippet.php index 2dd9dd92..259fb385 100644 --- a/src/php/Model/Snippet.php +++ b/src/php/Model/Snippet.php @@ -491,7 +491,7 @@ public function format_modified( bool $include_html = true ): string { /** * Determine whether the current snippet type is pro-only. * - * @noinspection PhpUnused + * @noinspection PhpUnusedPrivateMethodInspection */ private function get_is_pro(): bool { return 'css' === $this->type || 'js' === $this->type || 'cond' === $this->type; diff --git a/src/php/REST_API/Import/File_Import_REST_Controller.php b/src/php/REST_API/Import/File_Import_REST_Controller.php index b4b59b26..3cb712d1 100644 --- a/src/php/REST_API/Import/File_Import_REST_Controller.php +++ b/src/php/REST_API/Import/File_Import_REST_Controller.php @@ -9,10 +9,8 @@ use WP_REST_Request; use WP_REST_Response; use WP_REST_Server; -use function Code_Snippets\code_snippets; use function Code_Snippets\get_snippets; use function Code_Snippets\save_snippet; -use const Code_Snippets\REST_API_NAMESPACE; /** * Manages the import of code snippets from uploaded files via REST API. diff --git a/src/php/REST_API/Import/Plugins/Header_Footer_Code_Manager_Plugin_Importer.php b/src/php/REST_API/Import/Plugins/Header_Footer_Code_Manager_Plugin_Importer.php index 4825eba6..2e06680a 100644 --- a/src/php/REST_API/Import/Plugins/Header_Footer_Code_Manager_Plugin_Importer.php +++ b/src/php/REST_API/Import/Plugins/Header_Footer_Code_Manager_Plugin_Importer.php @@ -2,14 +2,12 @@ namespace Code_Snippets\REST_API\Import\Plugins; -use Code_Snippets\Model\Snippet; - /** * Importer for the 'Header Footer Code Manager' plugin. */ class Header_Footer_Code_Manager_Plugin_Importer extends Plugin_Importer { - private const FIELD_MAPPINGS = [ + protected const FIELD_MAPPINGS = [ 'name' => 'name', 'snippet' => 'code', 'location' => 'scope', @@ -84,40 +82,6 @@ public function get_data( array $ids_to_import = [] ): array { return $snippets; } - /** - * Create a Snippet object from the source plugin's snippet data. - * - * @param array $snippet_data Source plugin's snippet data. - * @param bool $multisite Whether to create a multisite snippet. - * - * @return Snippet|null - */ - public function create_snippet( array $snippet_data, bool $multisite ): ?Snippet { - $snippet = new Snippet(); - $snippet['network'] = $multisite; - - foreach ( self::FIELD_MAPPINGS as $source_field => $target_field ) { - if ( ! isset( $snippet_data[ $source_field ] ) ) { - continue; - } - - $value = $this->transform_field_value( - $target_field, - $snippet_data[ $source_field ], - $snippet_data - ); - - $scope_not_supported = 'scope' === $target_field && null === $value; - if ( $scope_not_supported ) { - return null; - } - - $snippet->set_field( $target_field, $value ); - } - - return $snippet; - } - /** * Transform field value based on the target field. * @@ -127,7 +91,7 @@ public function create_snippet( array $snippet_data, bool $multisite ): ?Snippet * * @return string|null */ - private function transform_field_value( string $target_field, $value, array $snippet_data ): ?string { + protected function transform_field_value( string $target_field, $value, array $snippet_data ): ?string { if ( 'scope' === $target_field ) { return $this->transform_scope_value( $value, $snippet_data ); } diff --git a/src/php/REST_API/Import/Plugins/Insert_Headers_And_Footers_Plugin_Importer.php b/src/php/REST_API/Import/Plugins/Insert_Headers_And_Footers_Plugin_Importer.php index 4f86b5a0..db738e9b 100644 --- a/src/php/REST_API/Import/Plugins/Insert_Headers_And_Footers_Plugin_Importer.php +++ b/src/php/REST_API/Import/Plugins/Insert_Headers_And_Footers_Plugin_Importer.php @@ -10,7 +10,7 @@ */ class Insert_Headers_And_Footers_Plugin_Importer extends Plugin_Importer { - private const FIELD_MAPPINGS = [ + protected const FIELD_MAPPINGS = [ 'title' => 'name', 'note' => 'desc', 'code' => 'code', @@ -112,33 +112,10 @@ public function get_data( array $ids_to_import = [] ): array { public function create_snippet( array $snippet_data, bool $multisite ): ?Snippet { $code_type = $snippet_data['code_type'] ?? ''; $is_supported_code_type = in_array( $code_type, [ 'php', 'css', 'html', 'js' ], true ); - if ( ! $is_supported_code_type ) { - return null; - } - - $snippet = new Snippet(); - $snippet->network = $multisite; - - foreach ( self::FIELD_MAPPINGS as $source_field => $target_field ) { - if ( ! isset( $snippet_data[ $source_field ] ) ) { - continue; - } - - $value = $this->transform_field_value( - $target_field, - $snippet_data[ $source_field ], - $snippet_data - ); - - $scope_not_supported = 'scope' === $target_field && null === $value; - if ( $scope_not_supported ) { - return null; - } - - $snippet->set_field( $target_field, $value ); - } - return $snippet; + return $is_supported_code_type + ? parent::create_snippet( $snippet_data, $multisite ) + : null; } /** @@ -150,7 +127,7 @@ public function create_snippet( array $snippet_data, bool $multisite ): ?Snippet * * @return mixed */ - private function transform_field_value( string $target_field, $value, array $snippet_data ) { + protected function transform_field_value( string $target_field, $value, array $snippet_data ) { if ( 'scope' === $target_field ) { return $this->transform_scope_value( $value, $snippet_data ); } diff --git a/src/php/REST_API/Import/Plugins/Insert_PHP_Code_Snippet_Plugin_Importer.php b/src/php/REST_API/Import/Plugins/Insert_PHP_Code_Snippet_Plugin_Importer.php index 4a529588..e4ae4376 100644 --- a/src/php/REST_API/Import/Plugins/Insert_PHP_Code_Snippet_Plugin_Importer.php +++ b/src/php/REST_API/Import/Plugins/Insert_PHP_Code_Snippet_Plugin_Importer.php @@ -2,14 +2,12 @@ namespace Code_Snippets\REST_API\Import\Plugins; -use Code_Snippets\Model\Snippet; - /** * Importer for the 'Insert PHP Code Snippet' plugin. */ class Insert_PHP_Code_Snippet_Plugin_Importer extends Plugin_Importer { - private const FIELD_MAPPINGS = [ + protected const FIELD_MAPPINGS = [ 'title' => 'name', 'content' => 'code', 'insertionLocationType' => 'scope', @@ -84,40 +82,6 @@ public function get_data( array $ids_to_import = [] ): array { return $snippets; } - /** - * Create a new snippet from the provided snippet data. - * - * @param array $snippet_data Snippet data. - * @param bool $multisite Whether to create a network-wide snippet. - * - * @return Snippet|null - */ - public function create_snippet( array $snippet_data, bool $multisite ): ?Snippet { - $snippet = new Snippet(); - $snippet->network = $multisite; - - foreach ( self::FIELD_MAPPINGS as $source_field => $target_field ) { - if ( ! isset( $snippet_data[ $source_field ] ) ) { - continue; - } - - $value = $this->transform_field_value( - $target_field, - $snippet_data[ $source_field ], - $snippet_data - ); - - $scope_not_supported = 'scope' === $target_field && null === $value; - if ( $scope_not_supported ) { - return null; - } - - $snippet->set_field( $target_field, $value ); - } - - return $snippet; - } - /** * Transform field value to match Code Snippets format. * @@ -127,7 +91,7 @@ public function create_snippet( array $snippet_data, bool $multisite ): ?Snippet * * @return string|null */ - private function transform_field_value( string $target_field, $value, array $snippet_data ): ?string { + protected function transform_field_value( string $target_field, $value, array $snippet_data ): ?string { if ( 'scope' === $target_field ) { return $this->transform_scope_value( $value, $snippet_data ); } diff --git a/src/php/REST_API/Import/Plugins/Plugin_Importer.php b/src/php/REST_API/Import/Plugins/Plugin_Importer.php index 20f2dddf..9bc8296d 100644 --- a/src/php/REST_API/Import/Plugins/Plugin_Importer.php +++ b/src/php/REST_API/Import/Plugins/Plugin_Importer.php @@ -36,6 +36,13 @@ abstract class Plugin_Importer { ], ]; + /** + * Mapping of source plugin fields to Snippet object fields. + * + * @var array + */ + protected const FIELD_MAPPINGS = []; + /** * Get the name of the importer, used in REST API routes. * @@ -67,7 +74,50 @@ abstract public function get_data( array $ids_to_import = [] ): array; * * @return Snippet|null */ - abstract public function create_snippet( array $snippet_data, bool $multisite ): ?Snippet; + /** + * Create a new snippet from the provided snippet data. + * + * @param array $snippet_data Snippet data. + * @param bool $multisite Whether to create a network-wide snippet. + * + * @return Snippet|null + */ + public function create_snippet( array $snippet_data, bool $multisite ): ?Snippet { + $snippet = new Snippet(); + $snippet->network = $multisite; + + foreach ( self::FIELD_MAPPINGS as $source_field => $target_field ) { + if ( ! isset( $snippet_data[ $source_field ] ) ) { + continue; + } + + $value = $this->transform_field_value( + $target_field, + $snippet_data[ $source_field ], + $snippet_data + ); + + $scope_not_supported = 'scope' === $target_field && null === $value; + if ( $scope_not_supported ) { + return null; + } + + $snippet->set_field( $target_field, $value ); + } + + return $snippet; + } + + /** + * Transform field value based on the target field. + * + * @param string $target_field Target field name. + * @param mixed $value Original value. + * @param array $snippet_data Snippet data. + * + * @return mixed|null + */ + abstract protected function transform_field_value( string $target_field, $value, array $snippet_data ); /** * Check if the source plugin is active. @@ -119,7 +169,7 @@ public function transform( array $data, bool $multisite, bool $auto_add_tags = f * * @return WP_REST_Response */ - public function import( WP_REST_Request $request ): array { + public function import( WP_REST_Request $request ): WP_REST_Response { $ids_to_import = $request->get_param( 'ids' ) ?? []; $multisite = $request->get_param( 'network' ) ?? false; $auto_add_tags = $request->get_param( 'auto_add_tags' ) ?? false; diff --git a/src/php/REST_API/Snippets/Recently_Active_REST_Controller.php b/src/php/REST_API/Snippets/Recently_Active_REST_Controller.php index f969d58f..92a422c5 100644 --- a/src/php/REST_API/Snippets/Recently_Active_REST_Controller.php +++ b/src/php/REST_API/Snippets/Recently_Active_REST_Controller.php @@ -6,10 +6,8 @@ use WP_REST_Request; use WP_REST_Response; use WP_REST_Server; -use function Code_Snippets\code_snippets; use function Code_Snippets\Utils\delete_self_option; use function Code_Snippets\Utils\get_self_option; -use const Code_Snippets\REST_API_NAMESPACE; /** * Controller for fetching and clearing list of recently active snippets. diff --git a/src/php/REST_API/Snippets/Snippets_REST_Controller.php b/src/php/REST_API/Snippets/Snippets_REST_Controller.php index f667549a..1109cf48 100644 --- a/src/php/REST_API/Snippets/Snippets_REST_Controller.php +++ b/src/php/REST_API/Snippets/Snippets_REST_Controller.php @@ -8,12 +8,10 @@ use Code_Snippets\Model\Snippet; use Code_Snippets\REST_API\REST_Collection_Controller; use WP_Error; -use WP_REST_Controller; use WP_REST_Request; use WP_REST_Response; use WP_REST_Server; use function Code_Snippets\activate_snippet; -use function Code_Snippets\code_snippets; use function Code_Snippets\deactivate_snippet; use function Code_Snippets\delete_snippet; use function Code_Snippets\get_snippet; @@ -21,7 +19,6 @@ use function Code_Snippets\restore_snippet; use function Code_Snippets\save_snippet; use function Code_Snippets\trash_snippet; -use const Code_Snippets\REST_API_NAMESPACE; /** * Allows fetching snippet data through the WordPress REST API. diff --git a/src/php/Settings/Version_Switch.php b/src/php/Settings/Version_Switch.php index 6d770bcd..59aa7f08 100644 --- a/src/php/Settings/Version_Switch.php +++ b/src/php/Settings/Version_Switch.php @@ -228,7 +228,7 @@ public static function perform_version_install( string $download_url ) { * * phpcs:disable WordPress.PHP.DevelopmentFunctions.error_log_print_r */ - public static function extract_handler_messages( $update_handler, $upgrader ): string { + public static function extract_handler_messages( ?WP_Upgrader_Skin $update_handler, ?Plugin_Upgrader $upgrader ): string { $handler_messages = ''; if ( isset( $update_handler ) ) { diff --git a/src/php/Utils/editor.php b/src/php/Utils/editor.php index e04a50ce..e8861f6d 100644 --- a/src/php/Utils/editor.php +++ b/src/php/Utils/editor.php @@ -8,7 +8,6 @@ namespace Code_Snippets\Utils; use Code_Snippets\Settings\Settings_Fields; -use function Code_Snippets\code_snippets; use function Code_Snippets\Settings\get_setting; use function Code_Snippets\Settings\get_settings_values; use const Code_Snippets\PLUGIN_FILE; diff --git a/src/php/class-plugin.php b/src/php/class-plugin.php new file mode 100644 index 00000000..27c5962c --- /dev/null +++ b/src/php/class-plugin.php @@ -0,0 +1,436 @@ +version = $version; + $this->file = $file; + + wp_cache_add_global_groups( CACHE_GROUP ); + + add_filter( 'code_snippets/execute_snippets', array( $this, 'disable_snippet_execution' ), 5 ); + + if ( isset( $_REQUEST['snippets-safe-mode'] ) ) { + add_filter( 'home_url', array( $this, 'add_safe_mode_query_var' ) ); + add_filter( 'admin_url', array( $this, 'add_safe_mode_query_var' ) ); + } + + add_action( 'rest_api_init', [ $this, 'init_rest_api' ] ); + add_action( 'allowed_redirect_hosts', [ $this, 'allow_code_snippets_redirect' ] ); + } + + /** + * Initialise classes and include files + */ + public function load_plugin() { + $includes_path = __DIR__; + + // Database operation functions. + $this->db = new DB(); + + // Snippet operation functions. + require_once $includes_path . '/snippet-ops.php'; + $this->evaluate_content = new Evaluate_Content( $this->db ); + $this->evaluate_functions = new Evaluate_Functions( $this->db ); + + // CodeMirror editor functions. + require_once $includes_path . '/editor.php'; + + // General Administration functions. + if ( is_admin() ) { + $this->admin = new Admin(); + } + + // Settings component. + require_once $includes_path . '/settings/settings-fields.php'; + require_once $includes_path . '/settings/editor-preview.php'; + require_once $includes_path . '/settings/class-version-switch.php'; + require_once $includes_path . '/settings/settings.php'; + + // Cloud List Table shared functions. + require_once $includes_path . '/cloud/list-table-shared-ops.php'; + + // Snippet files. + $this->snippet_handler_registry = new Snippet_Handler_Registry( [ + 'php' => new Php_Snippet_Handler(), + 'html' => new Html_Snippet_Handler(), + ] ); + + $fs = new WordPress_File_System_Adapter(); + + $config_repo = new Snippet_Config_Repository( $fs ); + + ( new Snippet_Files( $this->snippet_handler_registry, $fs, $config_repo ) )->register_hooks(); + + $this->front_end = new Front_End(); + $this->cloud_api = new Cloud_API(); + + $upgrade = new Upgrade( $this->version, $this->db ); + add_action( 'plugins_loaded', array( $upgrade, 'run' ), 0 ); + $this->licensing = new Licensing(); + + // Importers. + new Plugins_Import_Manager(); + new Files_Import_Manager(); + + // Initialize promotions. + new Promotions\Elementor_Pro(); + } + + /** + * Register custom REST API controllers. + * + * @return void + */ + public function init_rest_api() { + $snippets_controller = new Snippets_REST_Controller(); + $snippets_controller->register_routes(); + } + + /** + * Disable snippet execution if the necessary query var is set. + * + * @param bool $execute_snippets Current filter value. + * + * @return bool New filter value. + */ + public function disable_snippet_execution( bool $execute_snippets ): bool { + return ! empty( $_REQUEST['snippets-safe-mode'] ) && $this->current_user_can() ? false : $execute_snippets; + } + + /** + * Determine whether the menu is full or compact. + * + * @return bool + */ + public function is_compact_menu(): bool { + return ! is_network_admin() && apply_filters( 'code_snippets_compact_menu', false ); + } + + /** + * Fetch the admin menu slug for a menu. + * + * @param string $menu Name of menu to retrieve the slug for. + * + * @return string The menu's slug. + */ + public function get_menu_slug( string $menu = '' ): string { + $add = array( 'single', 'add', 'add-new', 'add-snippet', 'new-snippet', 'add-new-snippet' ); + $edit = array( 'edit', 'edit-snippet' ); + $import = array( 'import', 'import-snippets', 'import-code-snippets' ); + $settings = array( 'settings', 'snippets-settings' ); + $cloud = array( 'cloud', 'cloud-snippets' ); + $welcome = array( 'welcome', 'getting-started', 'code-snippets' ); + + if ( in_array( $menu, $edit, true ) ) { + return 'edit-snippet'; + } elseif ( in_array( $menu, $add, true ) ) { + return 'add-snippet'; + } elseif ( in_array( $menu, $import, true ) ) { + return 'import-code-snippets'; + } elseif ( in_array( $menu, $settings, true ) ) { + return 'snippets-settings'; + } elseif ( in_array( $menu, $cloud, true ) ) { + return 'snippets&type=cloud'; + } elseif ( in_array( $menu, $welcome, true ) ) { + return 'code-snippets-welcome'; + } else { + return 'snippets'; + } + } + + /** + * Fetch the URL to a snippets admin menu. + * + * @param string $menu Name of menu to retrieve the URL to. + * @param string $context URL scheme to use. + * + * @return string The menu's URL. + */ + public function get_menu_url( string $menu = '', string $context = 'self' ): string { + $slug = $this->get_menu_slug( $menu ); + + if ( $this->is_compact_menu() && 'network' !== $context ) { + $base_slug = $this->get_menu_slug(); + $url = 'tools.php?page=' . $base_slug; + + if ( $slug !== $base_slug ) { + $url .= '&sub=' . $slug; + } + } else { + $url = 'admin.php?page=' . $slug; + } + + if ( 'network' === $context ) { + return network_admin_url( $url ); + } elseif ( 'admin' === $context ) { + return admin_url( $url ); + } else { + return self_admin_url( $url ); + } + } + + /** + * Fetch the admin menu slug for a snippets admin menu. + * + * @param integer $snippet_id Snippet ID. + * @param string $context URL scheme to use. + * + * @return string The URL to the edit snippet page for that snippet. + */ + public function get_snippet_edit_url( int $snippet_id, string $context = 'self' ): string { + return add_query_arg( + 'id', + absint( $snippet_id ), + $this->get_menu_url( 'edit', $context ) + ); + } + + /** + * Allow redirecting to the Code Snippets site. + * + * @param array $hosts Allowed hosts. + * + * @return array Modified allowed hosts. + */ + public function allow_code_snippets_redirect( array $hosts ): array { + $hosts[] = 'codesnippets.pro'; + $hosts[] = 'snipco.de'; + return $hosts; + } + + /** + * Determine whether the current user can perform actions on snippets. + * + * @return boolean Whether the current user has the required capability. + * + * @since 2.8.6 + */ + public function current_user_can(): bool { + return current_user_can( $this->get_cap() ); + } + + /** + * Retrieve the name of the capability required to manage sub-site snippets. + * + * @return string + */ + public function get_cap_name(): string { + return apply_filters( 'code_snippets_cap', 'manage_options' ); + } + + /** + * Retrieve the name of the capability required to manage network snippets. + * + * @return string + */ + public function get_network_cap_name(): string { + return apply_filters( 'code_snippets_network_cap', 'manage_network_options' ); + } + + /** + * Determine if a subsite user menu is enabled via *Network Settings > Enable administration menus*. + * + * @return bool + */ + public function is_subsite_menu_enabled(): bool { + if ( ! is_multisite() ) { + return true; + } + + $menu_perms = get_site_option( 'menu_items', array() ); + return ! empty( $menu_perms['snippets'] ); + } + + /** + * Determine if the current user should have the network snippets capability. + * + * @return bool + */ + public function user_can_manage_network_snippets(): bool { + return is_super_admin() || current_user_can( $this->get_network_cap_name() ); + } + + /** + * Determine whether the current request originates in the network admin. + * + * @return bool + */ + public function is_network_context(): bool { + return is_network_admin(); + } + + /** + * Get the required capability to perform a certain action on snippets. + * Does not check if the user has this capability or not. + * + * If multisite, adjusts the capability based on whether the user is viewing + * the network dashboard or a subsite and whether the menu is enabled for subsites. + * + * @return string The capability required to manage snippets. + * + * @since 2.0 + */ + public function get_cap(): string { + if ( is_multisite() && $this->is_network_context() ) { + return $this->get_network_cap_name(); + } + + if ( is_multisite() && ! $this->is_subsite_menu_enabled() ) { + return $this->get_network_cap_name(); + } + + return $this->get_cap_name(); + } + + /** + * Inject the safe mode query var into URLs + * + * @param string $url Original URL. + * + * @return string Modified URL. + */ + public function add_safe_mode_query_var( string $url ): string { + return isset( $_REQUEST['snippets-safe-mode'] ) ? + add_query_arg( 'snippets-safe-mode', (bool) $_REQUEST['snippets-safe-mode'], $url ) : + $url; + } + + /** + * Retrieve a list of available snippet types and their labels. + * + * @return array Snippet types. + */ + public static function get_types(): array { + return apply_filters( + 'code_snippets_types', + array( + 'php' => __( 'Functions', 'code-snippets' ), + 'html' => __( 'Content', 'code-snippets' ), + 'css' => __( 'Styles', 'code-snippets' ), + 'js' => __( 'Scripts', 'code-snippets' ), + 'cloud' => __( 'Codevault', 'code-snippets' ), + 'cloud_search' => __( 'Cloud Search', 'code-snippets' ), + 'bundles' => __( 'Bundles', 'code-snippets' ), + ) + ); + } + + /** + * Localise a plugin script to provide the CODE_SNIPPETS object. + * + * @param string $handle Script handle. + * + * @return void + */ + public function localize_script( string $handle ) { + wp_localize_script( + $handle, + 'CODE_SNIPPETS', + [ + 'isLicensed' => $this->licensing->is_licensed(), + 'isCloudConnected' => Cloud_API::is_cloud_connection_available(), + 'restAPI' => [ + 'base' => esc_url_raw( rest_url() ), + 'snippets' => esc_url_raw( rest_url( Snippets_REST_Controller::get_base_route() ) ), + 'nonce' => wp_create_nonce( 'wp_rest' ), + 'localToken' => $this->cloud_api->get_local_token(), + ], + 'urls' => [ + 'plugin' => esc_url_raw( plugins_url( '', PLUGIN_FILE ) ), + 'manage' => esc_url_raw( $this->get_menu_url() ), + 'edit' => esc_url_raw( $this->get_menu_url( 'edit' ) ), + 'addNew' => esc_url_raw( $this->get_menu_url( 'add' ) ), + ], + ] + ); + } +} diff --git a/src/php/snippet-ops.php b/src/php/snippet-ops.php index 1ab516ed..c34d80da 100644 --- a/src/php/snippet-ops.php +++ b/src/php/snippet-ops.php @@ -857,14 +857,14 @@ function update_snippet_fields( int $snippet_id, array $fields, ?bool $network = /** * Evaluate a snippet by loading it from the filesystem. * - * @param $code - * @param $file - * @param int $id - * @param bool $force + * @param string $code Snippet code. + * @param string $file Snippet filename. + * @param int $id Snippet ID. + * @param bool $force Force snippet execution, even if save mode is active. * * @return bool|Throwable|null */ -function execute_snippet_from_flat_file( $code, $file, int $id = 0, bool $force = false ) { +function execute_snippet_from_flat_file( string $code, string $file, int $id = 0, bool $force = false ) { if ( ! is_file( $file ) ) { execute_snippet( $code, $id, $force ); return true;