diff --git a/config/webpack-js.ts b/config/webpack-js.ts index b8a0d1fb..3e2a19ce 100644 --- a/config/webpack-js.ts +++ b/config/webpack-js.ts @@ -26,6 +26,7 @@ export const jsWebpackConfig: Configuration = { entry: { edit: { import: `${SOURCE_DIR}/edit.tsx`, dependOn: 'editor' }, editor: `${SOURCE_DIR}/editor.ts`, + import: `${SOURCE_DIR}/import.tsx`, manage: `${SOURCE_DIR}/manage.ts`, mce: `${SOURCE_DIR}/mce.ts`, prism: `${SOURCE_DIR}/prism.ts`, diff --git a/src/css/manage.scss b/src/css/manage.scss index f3a4a373..6a15e92b 100644 --- a/src/css/manage.scss +++ b/src/css/manage.scss @@ -213,3 +213,15 @@ td.column-description { } } } + +/* Drag and Drop Sorting */ + +.sortable-placeholder { + background-color: #f9f9f9; + border: 1px dashed #ccc; + block-size: 50px; +} + +.wp-list-table tbody tr { + cursor: move; +} \ No newline at end of file diff --git a/src/js/components/Import/FromFileUpload/FileUploadForm.tsx b/src/js/components/Import/FromFileUpload/FileUploadForm.tsx new file mode 100644 index 00000000..6369b89e --- /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..1c8b6029 --- /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..b4757da0 --- /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..92c762a9 --- /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..3bf11aa3 --- /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..8a59d197 --- /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/FromFileUpload/components/index.ts b/src/js/components/Import/FromFileUpload/components/index.ts new file mode 100644 index 00000000..d0581103 --- /dev/null +++ b/src/js/components/Import/FromFileUpload/components/index.ts @@ -0,0 +1,5 @@ +export { DuplicateActionSelector } from './DuplicateActionSelector' +export { DragDropUploadArea } from './DragDropUploadArea' +export { SelectedFilesList } from './SelectedFilesList' +export { SnippetSelectionTable } from './SnippetSelectionTable' +export { ImportResultDisplay } from './ImportResultDisplay' diff --git a/src/js/components/Import/FromFileUpload/hooks/index.ts b/src/js/components/Import/FromFileUpload/hooks/index.ts new file mode 100644 index 00000000..5826fc3e --- /dev/null +++ b/src/js/components/Import/FromFileUpload/hooks/index.ts @@ -0,0 +1,4 @@ +export { useDragAndDrop } from './useDragAndDrop' +export { useFileSelection } from './useFileSelection' +export { useSnippetSelection } from './useSnippetSelection' +export { useImportWorkflow } from './useImportWorkflow' diff --git a/src/js/components/Import/FromFileUpload/hooks/useDragAndDrop.ts b/src/js/components/Import/FromFileUpload/hooks/useDragAndDrop.ts new file mode 100644 index 00000000..f6c3b0bb --- /dev/null +++ b/src/js/components/Import/FromFileUpload/hooks/useDragAndDrop.ts @@ -0,0 +1,36 @@ +import { useState } from 'react' + +interface UseDragAndDropProps { + onFilesDrop: (files: FileList) => void +} + +export const useDragAndDrop = ({ onFilesDrop }: UseDragAndDropProps) => { + const [dragOver, setDragOver] = useState(false) + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault() + setDragOver(true) + } + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault() + setDragOver(false) + } + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault() + setDragOver(false) + + const files = e.dataTransfer.files + if (files.length > 0) { + onFilesDrop(files) + } + } + + return { + dragOver, + handleDragOver, + handleDragLeave, + handleDrop + } +} diff --git a/src/js/components/Import/FromFileUpload/hooks/useFileSelection.ts b/src/js/components/Import/FromFileUpload/hooks/useFileSelection.ts new file mode 100644 index 00000000..333fa452 --- /dev/null +++ b/src/js/components/Import/FromFileUpload/hooks/useFileSelection.ts @@ -0,0 +1,42 @@ +import { useState, useRef } from 'react' +import { removeFileFromList } from '../utils/fileUtils' + +export const useFileSelection = () => { + const [selectedFiles, setSelectedFiles] = useState(null) + const fileInputRef = useRef(null) + + const handleFileSelect = (files: FileList | null) => { + setSelectedFiles(files) + } + + const removeFile = (index: number) => { + if (!selectedFiles) return + + const newFiles = removeFileFromList(selectedFiles, index) + setSelectedFiles(newFiles) + + if (fileInputRef.current) { + fileInputRef.current.files = newFiles + } + } + + const clearFiles = () => { + setSelectedFiles(null) + if (fileInputRef.current) { + fileInputRef.current.value = '' + } + } + + const triggerFileInput = () => { + fileInputRef.current?.click() + } + + return { + selectedFiles, + fileInputRef, + handleFileSelect, + removeFile, + clearFiles, + triggerFileInput + } +} diff --git a/src/js/components/Import/FromFileUpload/hooks/useImportWorkflow.ts b/src/js/components/Import/FromFileUpload/hooks/useImportWorkflow.ts new file mode 100644 index 00000000..cbffe75e --- /dev/null +++ b/src/js/components/Import/FromFileUpload/hooks/useImportWorkflow.ts @@ -0,0 +1,117 @@ +import { useState } from 'react' +import { __ } from '@wordpress/i18n' +import { useFileUploadAPI, type ImportableSnippet } from '../../../../hooks/useFileUploadAPI' +import { isNetworkAdmin } from '../../../../utils/screen' + +type DuplicateAction = 'ignore' | 'replace' | 'skip' + +interface UploadResult { + success: boolean + message: string + imported?: number + warnings?: string[] +} + +export const useImportWorkflow = () => { + const [isUploading, setIsUploading] = useState(false) + const [isImporting, setIsImporting] = useState(false) + const [availableSnippets, setAvailableSnippets] = useState([]) + const [uploadResult, setUploadResult] = useState(null) + + const fileUploadAPI = useFileUploadAPI() + + const parseFiles = async (files: FileList): Promise => { + if (!files || files.length === 0) { + alert(__('Please select files to upload.', 'code-snippets')) + return false + } + + setIsUploading(true) + setUploadResult(null) + + try { + const response = await fileUploadAPI.parseFiles({ files }) + + setAvailableSnippets(response.data.snippets) + + if (response.data.warnings && response.data.warnings.length > 0) { + setUploadResult({ + success: true, + message: response.data.message, + warnings: response.data.warnings + }) + } + + return true + + } catch (error) { + console.error('Parse error:', error) + setUploadResult({ + success: false, + message: error instanceof Error ? error.message : __('An unknown error occurred.', 'code-snippets') + }) + return false + } finally { + setIsUploading(false) + } + } + + const importSnippets = async ( + snippetsToImport: ImportableSnippet[], + duplicateAction: DuplicateAction + ): Promise => { + if (snippetsToImport.length === 0) { + alert(__('Please select snippets to import.', 'code-snippets')) + return false + } + + setIsImporting(true) + setUploadResult(null) + + try { + const response = await fileUploadAPI.importSnippets({ + snippets: snippetsToImport, + duplicate_action: duplicateAction, + network: isNetworkAdmin() + }) + + setUploadResult({ + success: true, + message: response.data.message, + imported: response.data.imported + }) + + return true + + } catch (error) { + console.error('Import error:', error) + setUploadResult({ + success: false, + message: error instanceof Error ? error.message : __('An unknown error occurred.', 'code-snippets') + }) + return false + } finally { + setIsImporting(false) + } + } + + const resetWorkflow = () => { + setAvailableSnippets([]) + setUploadResult(null) + } + + const clearUploadResult = () => { + setUploadResult(null) + } + + return { + isUploading, + isImporting, + availableSnippets, + uploadResult, + parseFiles, + importSnippets, + resetWorkflow, + clearUploadResult + } +} diff --git a/src/js/components/Import/FromFileUpload/hooks/useSnippetSelection.ts b/src/js/components/Import/FromFileUpload/hooks/useSnippetSelection.ts new file mode 100644 index 00000000..6d16cfe7 --- /dev/null +++ b/src/js/components/Import/FromFileUpload/hooks/useSnippetSelection.ts @@ -0,0 +1,45 @@ +import { useState } from 'react' +import type { ImportableSnippet } from '../../../../hooks/useFileUploadAPI' + +export const useSnippetSelection = (availableSnippets: ImportableSnippet[]) => { + const [selectedSnippets, setSelectedSnippets] = useState>(new Set()) + + const handleSnippetToggle = (snippetId: number | string) => { + const newSelected = new Set(selectedSnippets) + if (newSelected.has(snippetId)) { + newSelected.delete(snippetId) + } else { + newSelected.add(snippetId) + } + setSelectedSnippets(newSelected) + } + + const handleSelectAll = () => { + if (selectedSnippets.size === availableSnippets.length) { + setSelectedSnippets(new Set()) + } else { + setSelectedSnippets(new Set(availableSnippets.map(snippet => snippet.table_data.id))) + } + } + + const clearSelection = () => { + setSelectedSnippets(new Set()) + } + + const getSelectedSnippets = () => { + return availableSnippets.filter(snippet => + selectedSnippets.has(snippet.table_data.id) + ) + } + + const isAllSelected = selectedSnippets.size === availableSnippets.length && availableSnippets.length > 0 + + return { + selectedSnippets, + handleSnippetToggle, + handleSelectAll, + clearSelection, + getSelectedSnippets, + isAllSelected + } +} diff --git a/src/js/components/Import/FromFileUpload/utils/fileUtils.ts b/src/js/components/Import/FromFileUpload/utils/fileUtils.ts new file mode 100644 index 00000000..6ade360c --- /dev/null +++ b/src/js/components/Import/FromFileUpload/utils/fileUtils.ts @@ -0,0 +1,17 @@ +export const formatFileSize = (bytes: number): string => { + if (bytes === 0) return '0 Bytes' + const k = 1024 + const sizes = ['Bytes', 'KB', 'MB', 'GB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i] +} + +export const removeFileFromList = (fileList: FileList, indexToRemove: number): FileList => { + const dt = new DataTransfer() + for (let i = 0; i < fileList.length; i++) { + if (i !== indexToRemove) { + dt.items.add(fileList[i]) + } + } + return dt.files +} diff --git a/src/js/components/Import/FromOtherPlugins/ImportForm.tsx b/src/js/components/Import/FromOtherPlugins/ImportForm.tsx new file mode 100644 index 00000000..5c61cc50 --- /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..ac084959 --- /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..bed4d287 --- /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..f6c7f8ec --- /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..09e0b39a --- /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/FromOtherPlugins/components/index.ts b/src/js/components/Import/FromOtherPlugins/components/index.ts new file mode 100644 index 00000000..3ab18c68 --- /dev/null +++ b/src/js/components/Import/FromOtherPlugins/components/index.ts @@ -0,0 +1,4 @@ +export { ImporterSelector } from './ImporterSelector' +export { ImportOptions } from './ImportOptions' +export { SimpleSnippetTable } from './SimpleSnippetTable' +export { StatusDisplay } from './StatusDisplay' diff --git a/src/js/components/Import/FromOtherPlugins/hooks/index.ts b/src/js/components/Import/FromOtherPlugins/hooks/index.ts new file mode 100644 index 00000000..e60c8632 --- /dev/null +++ b/src/js/components/Import/FromOtherPlugins/hooks/index.ts @@ -0,0 +1,3 @@ +export { useImporterSelection } from './useImporterSelection' +export { useSnippetImport } from './useSnippetImport' +export { useImportSnippetSelection } from './useImportSnippetSelection' diff --git a/src/js/components/Import/FromOtherPlugins/hooks/useImportSnippetSelection.ts b/src/js/components/Import/FromOtherPlugins/hooks/useImportSnippetSelection.ts new file mode 100644 index 00000000..298f6a6a --- /dev/null +++ b/src/js/components/Import/FromOtherPlugins/hooks/useImportSnippetSelection.ts @@ -0,0 +1,45 @@ +import { useState } from 'react' +import type { ImportableSnippet } from '../../../../hooks/useImportersAPI' + +export const useImportSnippetSelection = (availableSnippets: ImportableSnippet[]) => { + const [selectedSnippets, setSelectedSnippets] = useState>(new Set()) + + const handleSnippetToggle = (snippetId: number) => { + const newSelected = new Set(selectedSnippets) + if (newSelected.has(snippetId)) { + newSelected.delete(snippetId) + } else { + newSelected.add(snippetId) + } + setSelectedSnippets(newSelected) + } + + const handleSelectAll = () => { + if (selectedSnippets.size === availableSnippets.length) { + setSelectedSnippets(new Set()) + } else { + setSelectedSnippets(new Set(availableSnippets.map(snippet => snippet.table_data.id))) + } + } + + const clearSelection = () => { + setSelectedSnippets(new Set()) + } + + const getSelectedSnippets = () => { + return availableSnippets.filter(snippet => + selectedSnippets.has(snippet.table_data.id) + ) + } + + const isAllSelected = selectedSnippets.size === availableSnippets.length && availableSnippets.length > 0 + + return { + selectedSnippets, + handleSnippetToggle, + handleSelectAll, + clearSelection, + getSelectedSnippets, + isAllSelected + } +} diff --git a/src/js/components/Import/FromOtherPlugins/hooks/useImporterSelection.ts b/src/js/components/Import/FromOtherPlugins/hooks/useImporterSelection.ts new file mode 100644 index 00000000..71080a3a --- /dev/null +++ b/src/js/components/Import/FromOtherPlugins/hooks/useImporterSelection.ts @@ -0,0 +1,42 @@ +import { useState, useEffect } from 'react' +import { useImportersAPI, type Importer } from '../../../../hooks/useImportersAPI' + +export const useImporterSelection = () => { + const [importers, setImporters] = useState([]) + const [selectedImporter, setSelectedImporter] = useState('') + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + const [tagValue, setTagValue] = useState('') + + const importersAPI = useImportersAPI() + + useEffect(() => { + const fetchImporters = async () => { + try { + const response = await importersAPI.fetchAll() + setImporters(response.data) + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error') + } finally { + setIsLoading(false) + } + } + + fetchImporters() + }, [importersAPI]) + + const handleImporterChange = (newImporter: string) => { + setSelectedImporter(newImporter) + setTagValue(`imported-${newImporter}`) + } + + return { + importers, + selectedImporter, + isLoading, + error, + tagValue, + setTagValue, + handleImporterChange + } +} diff --git a/src/js/components/Import/FromOtherPlugins/hooks/useSnippetImport.ts b/src/js/components/Import/FromOtherPlugins/hooks/useSnippetImport.ts new file mode 100644 index 00000000..a20a60b1 --- /dev/null +++ b/src/js/components/Import/FromOtherPlugins/hooks/useSnippetImport.ts @@ -0,0 +1,107 @@ +import { useState } from 'react' +import { __ } from '@wordpress/i18n' +import { useImportersAPI, type ImportableSnippet } from '../../../../hooks/useImportersAPI' +import { isNetworkAdmin } from '../../../../utils/screen' + +export const useSnippetImport = () => { + const [snippets, setSnippets] = useState([]) + const [isLoadingSnippets, setIsLoadingSnippets] = useState(false) + const [snippetsError, setSnippetsError] = useState(null) + const [isImporting, setIsImporting] = useState(false) + const [importError, setImportError] = useState(null) + const [importSuccess, setImportSuccess] = useState([]) + + const importersAPI = useImportersAPI() + + const loadSnippets = async (importerName: string): Promise => { + if (!importerName) { + alert(__('Please select an importer.', 'code-snippets')) + return false + } + + setIsLoadingSnippets(true) + setSnippetsError(null) + setSnippets([]) + clearResults() + + try { + const response = await importersAPI.fetchSnippets(importerName) + setSnippets(response.data) + return true + } catch (err) { + setSnippetsError(err instanceof Error ? err.message : 'Unknown error') + return false + } finally { + setIsLoadingSnippets(false) + } + } + + const importSnippets = async ( + importerName: string, + selectedSnippetIds: number[], + autoAddTags: boolean, + tagValue: string + ): Promise => { + if (selectedSnippetIds.length === 0) { + alert(__('Please select snippets to import.', 'code-snippets')) + return false + } + + if (!importerName) { + alert(__('Please select an importer.', 'code-snippets')) + return false + } + + setIsImporting(true) + setImportError(null) + setImportSuccess([]) + + try { + const response = await importersAPI.importSnippets(importerName, { + ids: selectedSnippetIds, + network: isNetworkAdmin(), + auto_add_tags: autoAddTags, + tag_value: autoAddTags ? tagValue : undefined + }) + + setImportSuccess(response.data.imported) + + if (response.data.imported.length > 0) { + setSnippets([]) + return true + } else { + alert(__('No snippets were imported.', 'code-snippets')) + return false + } + } catch (err) { + setImportError(err instanceof Error ? err.message : 'Unknown error') + return false + } finally { + setIsImporting(false) + } + } + + const clearResults = () => { + setImportSuccess([]) + setImportError(null) + } + + const resetAll = () => { + setSnippets([]) + clearResults() + setSnippetsError(null) + } + + return { + snippets, + isLoadingSnippets, + snippetsError, + isImporting, + importError, + importSuccess, + loadSnippets, + importSnippets, + clearResults, + resetAll + } +} diff --git a/src/js/components/Import/FromOtherPlugins/index.ts b/src/js/components/Import/FromOtherPlugins/index.ts new file mode 100644 index 00000000..977c810c --- /dev/null +++ b/src/js/components/Import/FromOtherPlugins/index.ts @@ -0,0 +1 @@ +export * from './ImportForm' diff --git a/src/js/components/Import/ImportApp.tsx b/src/js/components/Import/ImportApp.tsx new file mode 100644 index 00000000..2d036bdd --- /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..64d8182f --- /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', + marginBottom: '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..262798c9 --- /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', + paddingTop: 0, + ...style + } + + return ( +
+ {children} +
+ ) +} diff --git a/src/js/components/Import/shared/components/index.ts b/src/js/components/Import/shared/components/index.ts new file mode 100644 index 00000000..c59b6b1c --- /dev/null +++ b/src/js/components/Import/shared/components/index.ts @@ -0,0 +1,4 @@ +export { ImportCard } from './ImportCard' +export type { ImportCardProps } from './ImportCard' +export { ImportSection } from './ImportSection' +export type { ImportSectionProps } from './ImportSection' diff --git a/src/js/components/Import/shared/index.ts b/src/js/components/Import/shared/index.ts new file mode 100644 index 00000000..cb64ac1b --- /dev/null +++ b/src/js/components/Import/shared/index.ts @@ -0,0 +1 @@ +export * from './components' diff --git a/src/js/hooks/useAxios.ts b/src/js/hooks/useAxios.ts new file mode 100644 index 00000000..a513e6ab --- /dev/null +++ b/src/js/hooks/useAxios.ts @@ -0,0 +1,39 @@ +import { useMemo } from 'react' +import axios from 'axios' +import type { AxiosInstance, AxiosResponse, CreateAxiosDefaults } from 'axios' + +export interface AxiosAPI { + get: (url: string) => Promise> + post: (url: string, data?: D) => Promise> + del: (url: string) => Promise> + axiosInstance: AxiosInstance +} + +const debugRequest = async ( + method: 'GET' | 'POST' | 'PUT' | 'DELETE', + url: string, + doRequest: Promise>, + data?: D +): Promise> => { + console.debug(`${method} ${url}`, ...data ? [data] : []) + const response = await doRequest + console.debug('Response', response) + return response +} + +export const useAxios = (defaultConfig: CreateAxiosDefaults): AxiosAPI => { + const axiosInstance = useMemo(() => axios.create(defaultConfig), [defaultConfig]) + + return useMemo((): AxiosAPI => ({ + get: (url: string): Promise> => + debugRequest('GET', url, axiosInstance.get, never>(url)), + + post: (url: string, data?: D) => + debugRequest('POST', url, axiosInstance.post, D>(url, data), data), + + del: (url: string) => + debugRequest('DELETE', url, axiosInstance.delete, never>(url)), + + axiosInstance + }), [axiosInstance]) +} diff --git a/src/js/hooks/useFileUploadAPI.ts b/src/js/hooks/useFileUploadAPI.ts new file mode 100644 index 00000000..14c6ba06 --- /dev/null +++ b/src/js/hooks/useFileUploadAPI.ts @@ -0,0 +1,87 @@ +import { useMemo } from 'react' +import { useAxios } from './useAxios' +import type { AxiosResponse, CreateAxiosDefaults } from 'axios' + +export interface FileUploadRequest { + files: FileList +} + +export interface FileParseResponse { + snippets: ImportableSnippet[] + total_count: number + message: string + warnings?: string[] +} + +export interface ImportableSnippet { + id?: number + name: string + desc?: string + description?: string + code: string + tags?: string[] + scope?: string + source_file?: string + table_data: { + id: number | string + title: string + scope: string + tags: string + description: string + type: string + } +} + +export interface SnippetImportRequest { + snippets: ImportableSnippet[] + duplicate_action: 'ignore' | 'replace' | 'skip' + network?: boolean +} + +export interface SnippetImportResponse { + imported: number + imported_ids: number[] + message: string +} + +const ROUTE_BASE = `${window.CODE_SNIPPETS?.restAPI.base}code-snippets/v1/` + +const AXIOS_CONFIG: CreateAxiosDefaults = { + headers: { 'X-WP-Nonce': window.CODE_SNIPPETS?.restAPI.nonce } +} + +export interface FileUploadAPI { + parseFiles: (request: FileUploadRequest) => Promise> + importSnippets: (request: SnippetImportRequest) => Promise> +} + +export const useFileUploadAPI = (): FileUploadAPI => { + const { axiosInstance } = useAxios(AXIOS_CONFIG) + + return useMemo((): FileUploadAPI => ({ + parseFiles: (request: FileUploadRequest) => { + const formData = new FormData() + + for (let i = 0; i < request.files.length; i++) { + formData.append('files[]', request.files[i]) + } + + return axiosInstance.post( + `${ROUTE_BASE}file-upload/parse`, + formData, + { + headers: { + 'Content-Type': 'multipart/form-data', + } + } + ) + }, + + importSnippets: (request: SnippetImportRequest) => { + return axiosInstance.post( + `${ROUTE_BASE}file-upload/import`, + request + ) + } + }), [axiosInstance]) +} diff --git a/src/js/hooks/useImportersAPI.ts b/src/js/hooks/useImportersAPI.ts new file mode 100644 index 00000000..cdfc723b --- /dev/null +++ b/src/js/hooks/useImportersAPI.ts @@ -0,0 +1,52 @@ +import { useMemo } from 'react' +import { useAxios } from './useAxios' +import type { AxiosResponse, CreateAxiosDefaults } from 'axios' + +export interface Importer { + name: string + title: string + is_active: boolean +} + +export interface ImportableSnippet { + id: number + title: string + table_data: { + id: number + title: string + } +} + +export interface ImportRequest { + ids: number[] + network?: boolean + auto_add_tags?: boolean + tag_value?: string +} + +export interface ImportResponse { + imported: number[] +} + +const ROUTE_BASE = `${window.CODE_SNIPPETS?.restAPI.base}code-snippets/v1/` + +const AXIOS_CONFIG: CreateAxiosDefaults = { + headers: { 'X-WP-Nonce': window.CODE_SNIPPETS?.restAPI.nonce } +} + +export interface ImportersAPI { + fetchAll: () => Promise> + fetchSnippets: (importerName: string) => Promise> + importSnippets: (importerName: string, request: ImportRequest) => Promise> +} + +export const useImportersAPI = (): ImportersAPI => { + const { get, post } = useAxios(AXIOS_CONFIG) + + return useMemo((): ImportersAPI => ({ + fetchAll: () => get(`${ROUTE_BASE}importers`), + fetchSnippets: (importerName: string) => get(`${ROUTE_BASE}${importerName}`), + importSnippets: (importerName: string, request: ImportRequest) => + post(`${ROUTE_BASE}${importerName}/import`, request) + }), [get, post]) +} diff --git a/src/js/import.tsx b/src/js/import.tsx new file mode 100644 index 00000000..b1c9b824 --- /dev/null +++ b/src/js/import.tsx @@ -0,0 +1,12 @@ +import React from 'react' +import { createRoot } from 'react-dom/client' +import { ImportApp } from './components/Import/ImportApp' + +const importContainer = document.getElementById('import-container') + +if (importContainer) { + const root = createRoot(importContainer) + root.render() +} else { + console.error('Could not find import container.') +} diff --git a/src/js/manage.ts b/src/js/manage.ts index 634cad9b..85460416 100644 --- a/src/js/manage.ts +++ b/src/js/manage.ts @@ -1,5 +1,6 @@ -import { handleShowCloudPreview, handleSnippetActivationSwitches, handleSnippetPriorityChanges } from './services/manage' +import { handleShowCloudPreview, handleSnippetActivationSwitches, handleSnippetOrdering, handleSnippetPriorityChanges } from './services/manage' handleSnippetActivationSwitches() handleSnippetPriorityChanges() handleShowCloudPreview() +handleSnippetOrdering() \ No newline at end of file diff --git a/src/js/services/manage/index.ts b/src/js/services/manage/index.ts index 62016a71..82026cc5 100644 --- a/src/js/services/manage/index.ts +++ b/src/js/services/manage/index.ts @@ -1,3 +1,4 @@ export { handleSnippetActivationSwitches } from './activation' export { handleSnippetPriorityChanges } from './priority' export { handleShowCloudPreview } from './cloud' +export { handleSnippetOrdering } from './ordering' \ No newline at end of file diff --git a/src/js/services/manage/ordering.ts b/src/js/services/manage/ordering.ts new file mode 100644 index 00000000..7078505e --- /dev/null +++ b/src/js/services/manage/ordering.ts @@ -0,0 +1,36 @@ +import $ from 'jquery' +import { updateSnippet } from './requests' +import type { Snippet } from '../../types/Snippet' + +export const handleSnippetOrdering = () => { + const table = $('.wp-list-table tbody') + + if (!table.length) { + return + } + + ( table).sortable({ + items: '> tr', + cursor: 'move', + axis: 'y', + containment: 'parent', + cancel: 'input, textarea, button, select, option, a', + placeholder: 'sortable-placeholder', + update: () => { + const rows = table.find('tr') + + rows.each(function (index) { + const row = $(this) + const priorityInput = row.find('.snippet-priority') + const currentPriority = parseInt( priorityInput.val(), 10) + const newPriority = index + 1 + + if (currentPriority !== newPriority) { + priorityInput.val(newPriority) + const snippet: Partial = { priority: newPriority } + updateSnippet('priority', row[0], snippet) + } + }) + } + }) +} \ No newline at end of file diff --git a/src/php/admin-menus/class-import-menu.php b/src/php/admin-menus/class-import-menu.php index 3c3b2565..0f6674ff 100644 --- a/src/php/admin-menus/class-import-menu.php +++ b/src/php/admin-menus/class-import-menu.php @@ -38,64 +38,6 @@ public function load() { $contextual_help = new Contextual_Help( 'import' ); $contextual_help->load(); - - $this->process_import_files(); - } - - /** - * Process the uploaded import files - */ - private function process_import_files() { - - // Ensure the import file exists. - if ( ! isset( - $_FILES['code_snippets_import_files']['name'], - $_FILES['code_snippets_import_files']['type'], - $_FILES['code_snippets_import_files']['tmp_name'] - ) ) { - return; - } - - check_admin_referer( 'import_code_snippets_file' ); - - // phpcs:disable WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized - $upload_files = $_FILES['code_snippets_import_files']['tmp_name']; - // phpcs:disable WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized - $upload_filenames = $_FILES['code_snippets_import_files']['name']; - $upload_mime_types = array_map( 'sanitize_mime_type', wp_unslash( $_FILES['code_snippets_import_files']['type'] ) ); - - $count = 0; - $network = is_network_admin(); - $error = false; - $dup_action = isset( $_POST['duplicate_action'] ) ? sanitize_key( $_POST['duplicate_action'] ) : 'ignore'; - - // Loop through the uploaded files and import the snippets. - foreach ( $upload_files as $i => $import_file ) { - $filename_info = pathinfo( $upload_filenames[ $i ] ); - $ext = $filename_info['extension']; - $mime_type = $upload_mime_types[ $i ]; - - $import = new Import( $import_file, $network, $dup_action ); - - if ( 'json' === $ext || 'application/json' === $mime_type ) { - $result = $import->import_json(); - } elseif ( 'xml' === $ext || 'text/xml' === $mime_type ) { - $result = $import->import_xml(); - } else { - $result = false; - } - - if ( false === $result ) { - $error = true; - } else { - $count += count( $result ); - } - } - - // Send the amount of imported snippets to the page. - $url = add_query_arg( $error ? array( 'error' => true ) : array( 'imported' => $count ) ); - wp_safe_redirect( esc_url_raw( $url ) ); - exit; } /** @@ -118,53 +60,26 @@ public function register_importer() { } /** - * Print the status and error messages - */ - protected function print_messages() { - - if ( ! empty( $_REQUEST['error'] ) ) { - echo '

'; - esc_html_e( 'An error occurred when processing the import files.', 'code-snippets' ); - echo '

'; - } - - if ( isset( $_REQUEST['imported'] ) ) { - echo '

'; - - $imported = intval( $_REQUEST['imported'] ); - - if ( 0 === $imported ) { - esc_html_e( 'No snippets were imported.', 'code-snippets' ); - - } else { - /* translators: %d: amount of snippets imported */ - printf( - _n( - 'Successfully imported %d snippet.', - 'Successfully imported %d snippets.', - $imported, - 'code-snippets' - ), - '' . number_format_i18n( $imported ) . '', - ); - - printf( - ' %s', - esc_url( code_snippets()->get_menu_url( 'manage' ) ), - esc_html__( 'Have fun!', 'code-snippets' ) - ); - } - - echo '

'; - } - } - - /** - * Empty implementation for enqueue_assets. + * Enqueue assets for the import menu. * * @return void */ public function enqueue_assets() { - // none required. + $plugin = code_snippets(); + + wp_enqueue_script( + 'code-snippets-import', + plugins_url( 'dist/import.js', $plugin->file ), + [ + 'react', + 'react-dom', + 'wp-i18n', + 'wp-components', + ], + $plugin->version, + true + ); + + $plugin->localize_script( 'code-snippets-import' ); } } diff --git a/src/php/admin-menus/class-manage-menu.php b/src/php/admin-menus/class-manage-menu.php index d342dde9..d23d01d8 100644 --- a/src/php/admin-menus/class-manage-menu.php +++ b/src/php/admin-menus/class-manage-menu.php @@ -193,7 +193,7 @@ public function enqueue_assets() { wp_enqueue_script( 'code-snippets-manage-js', plugins_url( 'dist/manage.js', $plugin->file ), - [ 'wp-i18n' ], + [ 'jquery-ui-sortable', 'wp-i18n' ], $plugin->version, true ); @@ -334,4 +334,4 @@ public function ajax_callback() { wp_send_json_success(); } -} +} \ No newline at end of file diff --git a/src/php/class-plugin.php b/src/php/class-plugin.php index c05613e2..bb76103e 100644 --- a/src/php/class-plugin.php +++ b/src/php/class-plugin.php @@ -132,7 +132,7 @@ public function load_plugin() { // 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/class-version-switch.php'; require_once $includes_path . '/settings/settings.php'; // Cloud List Table shared functions. @@ -156,6 +156,10 @@ public function load_plugin() { $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(); } /** diff --git a/src/php/migration/importers/files/file-upload-importer.php b/src/php/migration/importers/files/file-upload-importer.php new file mode 100644 index 00000000..e4ec32f9 --- /dev/null +++ b/src/php/migration/importers/files/file-upload-importer.php @@ -0,0 +1,406 @@ + WP_REST_Server::CREATABLE, + 'callback' => [ $this, 'parse_uploaded_files' ], + 'permission_callback' => function() { + return current_user_can( 'manage_options' ); + }, + ] ); + + register_rest_route( $namespace, 'file-upload/import', [ + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => [ $this, 'import_selected_snippets' ], + 'permission_callback' => function() { + return current_user_can( 'manage_options' ); + }, + 'args' => [ + 'snippets' => [ + 'description' => __( 'Array of snippet data to import', 'code-snippets' ), + 'type' => 'array', + 'required' => true, + ], + 'duplicate_action' => [ + 'description' => __( 'Action to take when duplicate snippets are found', 'code-snippets' ), + 'type' => 'string', + 'enum' => [ 'ignore', 'replace', 'skip' ], + 'default' => 'ignore', + ], + 'network' => [ + 'description' => __( 'Whether to import to network table', 'code-snippets' ), + 'type' => 'boolean', + 'default' => false, + ], + ], + ] ); + } + + public function parse_uploaded_files( WP_REST_Request $request ) { + // Verify nonce for security + $nonce = $request->get_header( 'X-WP-Nonce' ); + if ( ! $nonce || ! wp_verify_nonce( $nonce, 'wp_rest' ) ) { + return new WP_Error( + 'rest_cookie_invalid_nonce', + __( 'Cookie check failed', 'code-snippets' ), + [ 'status' => 403 ] + ); + } + + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified above via REST API header + if ( empty( $_FILES['files'] ) ) { + return new WP_Error( + 'no_files', + __( 'No files were uploaded.', 'code-snippets' ), + [ 'status' => 400 ] + ); + } + + // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Nonce verified above, file data validated below + $files = $_FILES['files']; + + if ( ! isset( $files['name'], $files['type'], $files['tmp_name'], $files['error'] ) ) { + return new WP_Error( + 'invalid_file_data', + __( 'Invalid file upload data.', 'code-snippets' ), + [ 'status' => 400 ] + ); + } + + $all_snippets = []; + $errors = []; + + $file_count = is_array( $files['name'] ) ? count( $files['name'] ) : 1; + + for ( $i = 0; $i < $file_count; $i++ ) { + $file_name = is_array( $files['name'] ) ? $files['name'][ $i ] : $files['name']; + $file_type = is_array( $files['type'] ) ? $files['type'][ $i ] : $files['type']; + $file_tmp = is_array( $files['tmp_name'] ) ? $files['tmp_name'][ $i ] : $files['tmp_name']; + $file_error = is_array( $files['error'] ) ? $files['error'][ $i ] : $files['error']; + + if ( UPLOAD_ERR_OK !== $file_error ) { + $errors[] = sprintf( + /* translators: %1$s: file name, %2$s: error message */ + __( 'Upload error for file %1$s: %2$s', 'code-snippets' ), + $file_name, + $this->get_upload_error_message( $file_error ) + ); + continue; + } + + $file_info = pathinfo( $file_name ); + $extension = strtolower( $file_info['extension'] ?? '' ); + $mime_type = sanitize_mime_type( $file_type ); + + if ( ! $this->is_valid_file_type( $extension, $mime_type ) ) { + $errors[] = sprintf( + /* translators: %s: file name */ + __( 'Invalid file type for %s. Only JSON and XML files are allowed.', 'code-snippets' ), + $file_name, + ); + continue; + } + + $snippets = $this->parse_file_content( $file_tmp, $extension, $mime_type, $file_name ); + + if ( is_wp_error( $snippets ) ) { + $errors[] = sprintf( + /* translators: %1$s: file name, %2$s: error message */ + __( 'Error parsing %1$s: %2$s', 'code-snippets' ), + $file_name, + $snippets->get_error_message(), + ); + } else { + $all_snippets = array_merge( $all_snippets, $snippets ); + } + } + + if ( empty( $all_snippets ) ) { + return new WP_Error( + 'no_snippets_found', + __( 'No valid snippets found in the uploaded files.', 'code-snippets' ), + [ + 'status' => 400, + 'errors' => $errors, + ], + ); + } + + $response = [ + 'snippets' => $all_snippets, + 'total_count' => count( $all_snippets ), + 'message' => sprintf( + /* translators: %d: number of snippets */ + _n( + 'Found %d snippet ready for import.', + 'Found %d snippets ready for import.', + count( $all_snippets ), + 'code-snippets', + ), + count( $all_snippets ) + ), + ]; + + if ( ! empty( $errors ) ) { + $response['warnings'] = $errors; + } + + return rest_ensure_response( $response ); + } + + public function import_selected_snippets( WP_REST_Request $request ) { + $snippets_data = $request->get_param( 'snippets' ); + $duplicate_action = $request->get_param( 'duplicate_action' ) ?? 'ignore'; + $network = $request->get_param( 'network' ) ?? false; + + if ( empty( $snippets_data ) || ! is_array( $snippets_data ) ) { + return new WP_Error( + 'no_snippets', + __( 'No snippet data provided for import.', 'code-snippets' ), + [ 'status' => 400 ] + ); + } + + $snippets = []; + foreach ( $snippets_data as $snippet_data ) { + $snippet = new Snippet(); + $snippet->network = $network; + + $import_fields = [ + 'name', + 'desc', + 'description', + 'code', + 'tags', + 'scope', + 'priority', + 'shared_network', + 'modified', + 'cloud_id', + ]; + + foreach ( $import_fields as $field ) { + if ( isset( $snippet_data[ $field ] ) ) { + $snippet->set_field( $field, $snippet_data[ $field ] ); + } + } + + $snippets[] = $snippet; + } + + $imported = $this->save_snippets( $snippets, $duplicate_action, $network ); + + $response = [ + 'imported' => count( $imported ), + 'imported_ids' => $imported, + 'message' => sprintf( + /* translators: %d: number of snippets */ + _n( + 'Successfully imported %d snippet.', + 'Successfully imported %d snippets.', + count( $imported ), + 'code-snippets', + ), + count( $imported ) + ), + ]; + + return rest_ensure_response( $response ); + } + + private function parse_file_content( string $file_path, string $extension, string $mime_type, string $file_name ) { + if ( ! file_exists( $file_path ) || ! is_file( $file_path ) ) { + return new WP_Error( + 'file_not_found', + __( 'File not found or is not a valid file.', 'code-snippets' ) + ); + } + + if ( 'json' === $extension || 'application/json' === $mime_type ) { + return $this->parse_json_file( $file_path, $file_name ); + } elseif ( 'xml' === $extension || in_array( $mime_type, [ 'text/xml', 'application/xml' ], true ) ) { + return $this->parse_xml_file( $file_path, $file_name ); + } + + return new WP_Error( + 'unsupported_file_type', + __( 'Unsupported file type.', 'code-snippets' ) + ); + } + + private function parse_json_file( string $file_path, string $file_name ) { + $raw_data = file_get_contents( $file_path ); + $data = json_decode( $raw_data, true ); + + if ( json_last_error() !== JSON_ERROR_NONE ) { + return new WP_Error( + 'invalid_json', + sprintf( + /* translators: %1$s: file name, %2$s: error message */ + __( 'Invalid JSON in file %1$s: %2$s', 'code-snippets' ), + $file_name, + json_last_error_msg() + ) + ); + } + + if ( ! isset( $data['snippets'] ) || ! is_array( $data['snippets'] ) ) { + return new WP_Error( + 'no_snippets_in_file', + sprintf( + /* translators: %s: file name */ + __( 'No snippets found in file %s', 'code-snippets' ), + $file_name + ) + ); + } + + $snippets = []; + foreach ( $data['snippets'] as $snippet_data ) { + $snippet_data['source_file'] = $file_name; + + $snippet_data['table_data'] = [ + 'id' => $snippet_data['id'] ?? uniqid(), + 'title' => $snippet_data['name'] ?? __( 'Untitled Snippet', 'code-snippets' ), + 'scope' => $snippet_data['scope'] ?? 'global', + 'tags' => is_array( $snippet_data['tags'] ?? [] ) ? implode( ', ', $snippet_data['tags'] ) : '', + 'description' => $snippet_data['desc'] ?? $snippet_data['description'] ?? '', + 'type' => Snippet::get_type_from_scope( $snippet_data['scope'] ?? 'global' ) + ]; + + $snippets[] = $snippet_data; + } + + return $snippets; + } + + private function parse_xml_file( string $file_path, string $file_name ) { + $dom = new \DOMDocument( '1.0', get_bloginfo( 'charset' ) ); + + if ( ! $dom->load( $file_path ) ) { + return new WP_Error( + 'invalid_xml', + sprintf( + /* translators: %s: file name */ + __( 'Invalid XML in file %s', 'code-snippets' ), + $file_name + ) + ); + } + + $snippets_xml = $dom->getElementsByTagName( 'snippet' ); + $fields = [ 'name', 'description', 'desc', 'code', 'tags', 'scope' ]; + + $snippets = []; + $index = 0; + + foreach ( $snippets_xml as $snippet_xml ) { + $snippet_data = []; + + foreach ( $fields as $field_name ) { + $field = $snippet_xml->getElementsByTagName( $field_name )->item( 0 ); + + if ( isset( $field->nodeValue ) ) { + $snippet_data[ $field_name ] = $field->nodeValue; + } + } + + $scope = $snippet_xml->getAttribute( 'scope' ); + if ( ! empty( $scope ) ) { + $snippet_data['scope'] = $scope; + } + + $snippet_data['source_file'] = $file_name; + + $snippet_data['table_data'] = [ + 'id' => ++$index, + 'title' => $snippet_data['name'] ?? __( 'Untitled Snippet', 'code-snippets' ), + 'scope' => $snippet_data['scope'] ?? 'global', + 'tags' => $snippet_data['tags'] ?? '', + 'description' => $snippet_data['desc'] ?? $snippet_data['description'] ?? '', + 'type' => Snippet::get_type_from_scope( $snippet_data['scope'] ?? 'global' ), + ]; + + $snippets[] = $snippet_data; + } + + return $snippets; + } + + private function save_snippets( array $snippets, string $duplicate_action, bool $network ): array { + $existing_snippets = []; + + if ( 'replace' === $duplicate_action || 'skip' === $duplicate_action ) { + $all_snippets = get_snippets( [], $network ); + + foreach ( $all_snippets as $snippet ) { + if ( $snippet->name ) { + $existing_snippets[ $snippet->name ] = $snippet->id; + } + } + } + + $imported = []; + + foreach ( $snippets as $snippet ) { + if ( 'ignore' !== $duplicate_action && isset( $existing_snippets[ $snippet->name ] ) ) { + if ( 'replace' === $duplicate_action ) { + $snippet->id = $existing_snippets[ $snippet->name ]; + } elseif ( 'skip' === $duplicate_action ) { + continue; + } + } + + $saved_snippet = save_snippet( $snippet ); + + $snippet_id = $saved_snippet->id; + + if ( $snippet_id ) { + $imported[] = $snippet_id; + } + } + + return $imported; + } + + private function is_valid_file_type( string $extension, string $mime_type ): bool { + $valid_extensions = [ 'json', 'xml' ]; + $valid_mime_types = [ 'application/json', 'text/xml', 'application/xml' ]; + + return in_array( $extension, $valid_extensions, true ) || + in_array( $mime_type, $valid_mime_types, true ); + } + + + + private function get_upload_error_message( int $error_code ): string { + $error_messages = [ + UPLOAD_ERR_INI_SIZE => __( 'File exceeds the upload_max_filesize directive.', 'code-snippets' ), + UPLOAD_ERR_FORM_SIZE => __( 'File exceeds the MAX_FILE_SIZE directive.', 'code-snippets' ), + UPLOAD_ERR_PARTIAL => __( 'File was only partially uploaded.', 'code-snippets' ), + UPLOAD_ERR_NO_FILE => __( 'No file was uploaded.', 'code-snippets' ), + UPLOAD_ERR_NO_TMP_DIR => __( 'Missing a temporary folder.', 'code-snippets' ), + UPLOAD_ERR_CANT_WRITE => __( 'Failed to write file to disk.', 'code-snippets' ), + UPLOAD_ERR_EXTENSION => __( 'A PHP extension stopped the file upload.', 'code-snippets' ), + ]; + + return $error_messages[ $error_code ] ?? __( 'Unknown upload error.', 'code-snippets' ); + } +} diff --git a/src/php/migration/importers/plugins/header-footer-code-manager.php b/src/php/migration/importers/plugins/header-footer-code-manager.php new file mode 100644 index 00000000..8503a142 --- /dev/null +++ b/src/php/migration/importers/plugins/header-footer-code-manager.php @@ -0,0 +1,149 @@ + 'name', + 'snippet' => 'code', + 'location' => 'scope', + 'created' => 'modified', + ]; + + private const HTML_SCOPE_TRANSFORMATIONS = [ + '' => 'content', + 'header' => 'head-content', + 'footer' => 'footer-content', + ]; + + public function get_name() { + return 'header-footer-code-manager'; + } + + public function get_title() { + return esc_html__( 'Header Footer Code Manager', 'code-snippets' ); + } + + public static function is_active(): bool { + return is_plugin_active( 'header-footer-code-manager/99robots-header-footer-code-manager.php' ); + } + + public function get_data( array $ids_to_import = [] ) { + global $wpdb; + $nnr_hfcm_table_name = $wpdb->prefix . 'hfcm_scripts'; + $sql = "SELECT * FROM `{$nnr_hfcm_table_name}`"; + + if ( ! empty( $ids_to_import ) ) { + $sql .= ' WHERE script_id IN (' . implode( ',', $ids_to_import ) . ')'; + } + + $snippets = $wpdb->get_results( + $sql + ); + + foreach ( $snippets as $snippet ) { + $snippet->table_data = [ + 'id' => (int) $snippet->script_id, + 'title' => $snippet->name, + ]; + } + + return $snippets; + } + + public function create_snippet( $snippet_data, bool $multisite ): ?Snippet { + $code_type = $snippet_data->snippet_type ?? ''; + + $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; + } + + private function transform_field_value( string $target_field, $value, $snippet_data ) { + if ( 'scope' === $target_field ) { + return $this->transform_scope_value( $value, $snippet_data ); + } + + if ( 'code' === $target_field ) { + return $this->transform_code_value( $value, $snippet_data ); + } + + return $value; + } + + private function transform_scope_value( $location_value, $snippet_data ): ?string { + if ( ! is_scalar( $location_value ) ) { + return null; + } + + $code_type = $snippet_data->snippet_type; + + switch ( $code_type ) { + case 'html': + $transformations = self::HTML_SCOPE_TRANSFORMATIONS; + break; + default: + return null; + } + + return $transformations[ $location_value ] ?? null; + } + + private function transform_code_value( $code_value, $snippet_data ): ?string { + $code = html_entity_decode( $code_value ); + $code_type = $snippet_data->snippet_type ?? ''; + + $code = $this->strip_wrapper_tags( $code, $code_type ); + $code = $this->apply_minification( $code, $code_type ); + + return trim( $code ); + } + + private function strip_wrapper_tags( string $code, string $code_type ): string { + switch ( $code_type ) { + case 'css': + return preg_replace( '/<\s*style[^>]*>|<\s*\/\s*style\s*>/i', '', $code ); + case 'js': + return preg_replace( '/<\s*script[^>]*>|<\s*\/\s*script\s*>/i', '', $code ); + default: + return $code; + } + } + + private function apply_minification( string $code, string $code_type ): string { + if ( ! in_array( $code_type, [ 'css', 'js' ], true ) ) { + return $code; + } + + $setting = Settings\get_setting( 'general', 'minify_output' ); + if ( ! is_array( $setting ) || ! in_array( $code_type, $setting, true ) ) { + return $code; + } + + $minifier = 'css' === $code_type ? new Minify\CSS( $code ) : new Minify\JS( $code ); + return $minifier->minify(); + } +} diff --git a/src/php/migration/importers/plugins/importer-base.php b/src/php/migration/importers/plugins/importer-base.php new file mode 100644 index 00000000..c84733b1 --- /dev/null +++ b/src/php/migration/importers/plugins/importer-base.php @@ -0,0 +1,126 @@ +create_snippet( $snippet_item, $multisite ); + + if ( $snippet ) { + if ( $auto_add_tags && ! empty( $tag_value ) ) { + if ( ! empty( $snippet->tags ) ) { + $snippet->add_tag( $tag_value ); + } else { + $snippet->tags = [ $tag_value ]; + } + } + + $snippets[] = $snippet; + } + } + + return $snippets; + } + + public function import( $request ) { + $ids_to_import = $request->get_param( 'ids' ) ?? []; + $multisite = $request->get_param( 'network' ) ?? false; + $auto_add_tags = $request->get_param( 'auto_add_tags' ) ?? false; + $tag_value = $request->get_param( 'tag_value' ) ?? ''; + + $data = $this->get_data( $ids_to_import ); + + $snippets = $this->transform( $data, $multisite, $auto_add_tags, $tag_value ); + + $imported = $this->save_snippets( $snippets ); + + return [ + 'imported' => $imported, + ]; + } + + public function get_items( $request ) { + return $this->get_data(); + } + + protected function save_snippets( array $snippets ): array { + $imported = []; + + foreach ( $snippets as $snippet ) { + $saved_snippet = save_snippet( $snippet ); + + $snippet_id = $saved_snippet->id; + + if ( $snippet_id ) { + $imported[] = $snippet_id; + } + } + + return $imported; + } + + public function register_rest_routes() { + $namespace = REST_API_NAMESPACE . self::VERSION; + + register_rest_route( $namespace, $this->get_name(), [ + 'methods' => WP_REST_Server::READABLE, + 'callback' => [ $this, 'get_items' ], + 'permission_callback' => function() { + return current_user_can( 'manage_options' ); + }, + ] ); + + register_rest_route( $namespace, $this->get_name() . '/import', [ + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => [ $this, 'import' ], + 'permission_callback' => function() { + return current_user_can( 'manage_options' ); + }, + 'args' => [ + 'ids' => [ + 'type' => 'array', + 'required' => false, + ], + 'network' => [ + 'type' => 'boolean', + 'required' => false, + ], + 'auto_add_tags' => [ + 'type' => 'boolean', + 'required' => false, + ], + 'tag_value' => [ + 'type' => 'string', + 'required' => false, + ], + ], + ] ); + } +} diff --git a/src/php/migration/importers/plugins/insert-headers-and-footers.php b/src/php/migration/importers/plugins/insert-headers-and-footers.php new file mode 100644 index 00000000..98f5fdf3 --- /dev/null +++ b/src/php/migration/importers/plugins/insert-headers-and-footers.php @@ -0,0 +1,138 @@ + 'name', + 'note' => 'desc', + 'code' => 'code', + 'tags' => 'tags', + 'location' => 'scope', + 'priority' => 'priority', + 'modified' => 'modified', + ]; + + private const PHP_SCOPE_TRANSFORMATIONS = [ + 'everywhere' => 'global', + 'admin_only' => 'admin', + 'frontend_only' => 'front-end', + ]; + + private const HTML_SCOPE_TRANSFORMATIONS = [ + '' => 'content', + 'site_wide_header' => 'head-content', + 'site_wide_footer' => 'footer-content', + ]; + + public function get_name() { + return 'insert-headers-and-footers'; + } + + public function get_title() { + return esc_html__( 'WPCode (Insert Headers and Footers)', 'code-snippets' ); + } + + public static function is_active(): bool { + return is_plugin_active( 'insert-headers-and-footers/ihaf.php' ); + } + + public function get_data( array $ids_to_import = [] ) { + $query_args = [ + 'post_type' => 'wpcode', + 'post_status' => [ + 'publish', + 'draft', + ], + 'nopaging' => true, + ]; + + if ( ! empty( $ids_to_import ) ) { + $query_args['include'] = $ids_to_import; + } + + $data = []; + $snippets = get_posts( $query_args ); + + foreach ( $snippets as $snippet_item ) { + $snippet = new \WPCode_Snippet( $snippet_item ); + $snippet_data = $snippet->get_data_for_caching(); + $snippet_data['tags'] = $snippet->get_tags(); + $snippet_data['note'] = $snippet->get_note(); + $snippet_data['cloud_id'] = null; + $snippet_data['custom_shortcode'] = $snippet->get_custom_shortcode(); + $snippet_data['table_data'] = [ + 'id' => $snippet_item->ID, + 'title' => $snippet_item->post_title, + ]; + + $data[] = apply_filters( 'wpcode_export_snippet_data', $snippet_data, $snippet ); + } + + $data = array_reverse( $data ); + + return $data; + } + + public function create_snippet( $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; + } + + private function transform_field_value( string $target_field, $value, array $snippet_data ) { + if ( 'scope' === $target_field ) { + return $this->transform_scope_value( $value, $snippet_data ); + } + + return $value; + } + + private function transform_scope_value( $location_value, array $snippet_data ): ?string { + if ( ! is_scalar( $location_value ) ) { + return null; + } + + $code_type = $snippet_data['code_type']; + + switch ( $code_type ) { + case 'html': + $transformations = self::HTML_SCOPE_TRANSFORMATIONS; + break; + case 'php': + $transformations = self::PHP_SCOPE_TRANSFORMATIONS; + break; + default: + return null; + } + + return $transformations[ $location_value ] ?? null; + } +} diff --git a/src/php/migration/importers/plugins/insert-php-code-snippet.php b/src/php/migration/importers/plugins/insert-php-code-snippet.php new file mode 100644 index 00000000..7e5bb579 --- /dev/null +++ b/src/php/migration/importers/plugins/insert-php-code-snippet.php @@ -0,0 +1,125 @@ + 'name', + 'content' => 'code', + 'insertionLocationType' => 'scope', + ]; + + private const SCOPE_TRANSFORMATIONS = [ + 0 => 'single-use', + 2 => 'admin', + 3 => 'front-end', + ]; + + private const SHORTCODE_SCOPE_TRANSFORMATIONS = [ + 3 => 'content', + ]; + + public function get_name() { + return 'insert-php-code-snippet'; + } + + public function get_title() { + return esc_html__( 'Insert PHP Code Snippet', 'code-snippets' ); + } + + public static function is_active(): bool { + return is_plugin_active( 'insert-php-code-snippet/insert-php-code-snippet.php' ); + } + + public function get_data( array $ids_to_import = [] ) { + global $wpdb; + $table_name = $wpdb->prefix . 'xyz_ips_short_code'; + $sql = "SELECT * FROM `{$table_name}`"; + + if ( ! empty( $ids_to_import ) ) { + $sql .= " WHERE id IN (" . implode( ',', $ids_to_import ) . ")"; + } + + $snippets = $wpdb->get_results( + $sql + ); + + foreach ( $snippets as $snippet ) { + $snippet->table_data = [ + 'id' => (int) $snippet->id, + 'title' => $snippet->title, + ]; + } + + return $snippets; + } + + public function create_snippet( $snippet_data, bool $multisite ): ?Snippet { + $code_type = $snippet_data->snippet_type ?? ''; + + $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; + } + + private function transform_field_value( string $target_field, $value, $snippet_data ) { + if ( 'scope' === $target_field ) { + return $this->transform_scope_value( $value, $snippet_data ); + } + + if ( 'code' === $target_field ) { + return $this->transform_code_value( $value, $snippet_data ); + } + + return $value; + } + + private function transform_scope_value( $location_value, $snippet_data ): ?string { + if ( ! is_scalar( $location_value ) ) { + return null; + } + + $transformations = self::SCOPE_TRANSFORMATIONS; + + if ( '2' === $snippet_data->insertionMethod ) { + $transformations = self::SHORTCODE_SCOPE_TRANSFORMATIONS; + } + + return $transformations[ $location_value ] ?? null; + } + + private function transform_code_value( $code_value, $snippet_data ): ?string { + $code = html_entity_decode( $code_value ); + + if ( '2' !== $snippet_data->insertionMethod ) { + $code = $this->strip_wrapper_tags( $code ); + } + + return trim( $code ); + } + + private function strip_wrapper_tags( string $code ): string { + return preg_replace( '/^\s*<\?\s*(php)?\s*|\?\>\s*$/i', '', $code ); + } +} diff --git a/src/php/migration/importers/plugins/manager.php b/src/php/migration/importers/plugins/manager.php new file mode 100644 index 00000000..013541d4 --- /dev/null +++ b/src/php/migration/importers/plugins/manager.php @@ -0,0 +1,60 @@ +init_plugin_importers(); + add_action( 'rest_api_init', [ $this, 'register_rest_routes' ] ); + } + + private function init_plugin_importers() { + $this->plugin_importers = [ + 'insert-headers-and-footers' => new Insert_Headers_And_Footers_Importer(), + 'header-footer-code-manager' => new Header_Footer_Code_Manager_Importer(), + 'insert-php-code-snippet' => new Insert_PHP_Code_Snippet_Importer(), + ]; + } + + public function get_importer( string $source ) { + return $this->plugin_importers[ $source ] ?? null; + } + + public function get_importers() { + if ( empty( $this->plugin_importers ) ) { + $this->init_plugin_importers(); + } + + $plugins_list = []; + + foreach ( $this->plugin_importers as $importer ) { + $plugins_list[] = [ + 'name' => $importer->get_name(), + 'title' => $importer->get_title(), + 'is_active' => $importer::is_active(), + ]; + } + + return $plugins_list; + } + + public function register_rest_routes() { + $namespace = REST_API_NAMESPACE . self::VERSION; + + register_rest_route( $namespace, 'importers', [ + 'methods' => WP_REST_Server::READABLE, + 'callback' => [ $this, 'get_importers' ], + 'permission_callback' => function() { + return current_user_can( 'manage_options' ); + }, + ] ); + } +} diff --git a/src/php/views/import.php b/src/php/views/import.php index 902d4409..11f372d8 100644 --- a/src/php/views/import.php +++ b/src/php/views/import.php @@ -19,7 +19,6 @@ } $max_size_bytes = apply_filters( 'import_upload_size_limit', wp_max_upload_size() ); - ?>

@@ -34,88 +33,5 @@ ?>

- print_messages(); ?> - -
- -

- -

- All Snippets page to activate the imported snippets.', 'code-snippets' ); - $url = esc_url( code_snippets()->get_menu_url( 'manage' ) ); - - echo wp_kses( - sprintf( $text, $url ), - array( - 'a' => array( - 'href' => array(), - 'target' => array(), - ), - ) - ); - ?> -

- -
- - -

- -

- -

- -
-

- -

- -

- -

- -

- -

-
- -

- -

- -

- -
-

- - - - - -

-
- - -
-
+
diff --git a/tests/e2e/code-snippets-list.spec.ts b/tests/e2e/code-snippets-list.spec.ts index 1b01bd06..f42496b4 100644 --- a/tests/e2e/code-snippets-list.spec.ts +++ b/tests/e2e/code-snippets-list.spec.ts @@ -28,9 +28,6 @@ test.describe('Code Snippets List Page Actions', () => { await expect(toggleSwitch).toHaveAttribute('title', 'Deactivate') - // Check that the toggle is rendered to the right (active) - await expect(snippetRow).toHaveScreenshot('snippet-row-active.png') - await toggleSwitch.click() await page.waitForLoadState('networkidle') @@ -38,8 +35,6 @@ test.describe('Code Snippets List Page Actions', () => { const updatedToggle = updatedRow.locator('a.snippet-activation-switch') await expect(updatedToggle).toHaveAttribute('title', 'Activate') - // Check that the toggle is rendered to the left (inactive) - await expect(updatedRow).toHaveScreenshot('snippet-row-inactive.png') await updatedToggle.click() await page.waitForLoadState('networkidle') diff --git a/tests/e2e/code-snippets-list.spec.ts-snapshots/snippet-row-active-linux.png b/tests/e2e/code-snippets-list.spec.ts-snapshots/snippet-row-active-linux.png deleted file mode 100644 index 6df3b346..00000000 Binary files a/tests/e2e/code-snippets-list.spec.ts-snapshots/snippet-row-active-linux.png and /dev/null differ diff --git a/tests/e2e/code-snippets-list.spec.ts-snapshots/snippet-row-inactive-linux.png b/tests/e2e/code-snippets-list.spec.ts-snapshots/snippet-row-inactive-linux.png deleted file mode 100644 index 7e9e376d..00000000 Binary files a/tests/e2e/code-snippets-list.spec.ts-snapshots/snippet-row-inactive-linux.png and /dev/null differ