From da999454a6ab2b42671a134b010df4d5917b7509 Mon Sep 17 00:00:00 2001 From: Louis Wolmarans Date: Thu, 27 Nov 2025 21:46:57 +0200 Subject: [PATCH 1/4] feat: import snippets --- config/webpack-js.ts | 1 + .../Import/FromFileUpload/FileUploadForm.tsx | 201 +++++++++ .../components/DragDropUploadArea.tsx | 66 +++ .../components/DuplicateActionSelector.tsx | 70 +++ .../components/ImportResultDisplay.tsx | 74 ++++ .../components/SelectedFilesList.tsx | 65 +++ .../components/SnippetSelectionTable.tsx | 90 ++++ .../Import/FromFileUpload/components/index.ts | 5 + .../Import/FromFileUpload/hooks/index.ts | 4 + .../FromFileUpload/hooks/useDragAndDrop.ts | 36 ++ .../FromFileUpload/hooks/useFileSelection.ts | 42 ++ .../FromFileUpload/hooks/useImportWorkflow.ts | 117 +++++ .../hooks/useSnippetSelection.ts | 45 ++ .../Import/FromFileUpload/utils/fileUtils.ts | 17 + .../Import/FromOtherPlugins/ImportForm.tsx | 142 ++++++ .../components/ImportOptions.tsx | 52 +++ .../components/ImporterSelector.tsx | 50 +++ .../components/SimpleSnippetTable.tsx | 102 +++++ .../components/StatusDisplay.tsx | 55 +++ .../FromOtherPlugins/components/index.ts | 4 + .../Import/FromOtherPlugins/hooks/index.ts | 3 + .../hooks/useImportSnippetSelection.ts | 45 ++ .../hooks/useImporterSelection.ts | 42 ++ .../hooks/useSnippetImport.ts | 107 +++++ .../Import/FromOtherPlugins/index.ts | 1 + src/js/components/Import/ImportApp.tsx | 62 +++ .../Import/shared/components/ImportCard.tsx | 45 ++ .../shared/components/ImportSection.tsx | 33 ++ .../Import/shared/components/index.ts | 4 + src/js/components/Import/shared/index.ts | 1 + src/js/hooks/useAxios.ts | 39 ++ src/js/hooks/useFileUploadAPI.ts | 87 ++++ src/js/hooks/useImportersAPI.ts | 52 +++ src/js/import.tsx | 12 + src/php/admin-menus/class-import-menu.php | 119 +---- src/php/class-plugin.php | 6 +- .../importers/files/file-upload-importer.php | 406 ++++++++++++++++++ .../plugins/header-footer-code-manager.php | 149 +++++++ .../importers/plugins/importer-base.php | 126 ++++++ .../plugins/insert-headers-and-footers.php | 138 ++++++ .../plugins/insert-php-code-snippet.php | 125 ++++++ .../migration/importers/plugins/manager.php | 60 +++ src/php/views/import.php | 86 +--- 43 files changed, 2798 insertions(+), 188 deletions(-) create mode 100644 src/js/components/Import/FromFileUpload/FileUploadForm.tsx create mode 100644 src/js/components/Import/FromFileUpload/components/DragDropUploadArea.tsx create mode 100644 src/js/components/Import/FromFileUpload/components/DuplicateActionSelector.tsx create mode 100644 src/js/components/Import/FromFileUpload/components/ImportResultDisplay.tsx create mode 100644 src/js/components/Import/FromFileUpload/components/SelectedFilesList.tsx create mode 100644 src/js/components/Import/FromFileUpload/components/SnippetSelectionTable.tsx create mode 100644 src/js/components/Import/FromFileUpload/components/index.ts create mode 100644 src/js/components/Import/FromFileUpload/hooks/index.ts create mode 100644 src/js/components/Import/FromFileUpload/hooks/useDragAndDrop.ts create mode 100644 src/js/components/Import/FromFileUpload/hooks/useFileSelection.ts create mode 100644 src/js/components/Import/FromFileUpload/hooks/useImportWorkflow.ts create mode 100644 src/js/components/Import/FromFileUpload/hooks/useSnippetSelection.ts create mode 100644 src/js/components/Import/FromFileUpload/utils/fileUtils.ts create mode 100644 src/js/components/Import/FromOtherPlugins/ImportForm.tsx create mode 100644 src/js/components/Import/FromOtherPlugins/components/ImportOptions.tsx create mode 100644 src/js/components/Import/FromOtherPlugins/components/ImporterSelector.tsx create mode 100644 src/js/components/Import/FromOtherPlugins/components/SimpleSnippetTable.tsx create mode 100644 src/js/components/Import/FromOtherPlugins/components/StatusDisplay.tsx create mode 100644 src/js/components/Import/FromOtherPlugins/components/index.ts create mode 100644 src/js/components/Import/FromOtherPlugins/hooks/index.ts create mode 100644 src/js/components/Import/FromOtherPlugins/hooks/useImportSnippetSelection.ts create mode 100644 src/js/components/Import/FromOtherPlugins/hooks/useImporterSelection.ts create mode 100644 src/js/components/Import/FromOtherPlugins/hooks/useSnippetImport.ts create mode 100644 src/js/components/Import/FromOtherPlugins/index.ts create mode 100644 src/js/components/Import/ImportApp.tsx create mode 100644 src/js/components/Import/shared/components/ImportCard.tsx create mode 100644 src/js/components/Import/shared/components/ImportSection.tsx create mode 100644 src/js/components/Import/shared/components/index.ts create mode 100644 src/js/components/Import/shared/index.ts create mode 100644 src/js/hooks/useAxios.ts create mode 100644 src/js/hooks/useFileUploadAPI.ts create mode 100644 src/js/hooks/useImportersAPI.ts create mode 100644 src/js/import.tsx create mode 100644 src/php/migration/importers/files/file-upload-importer.php create mode 100644 src/php/migration/importers/plugins/header-footer-code-manager.php create mode 100644 src/php/migration/importers/plugins/importer-base.php create mode 100644 src/php/migration/importers/plugins/insert-headers-and-footers.php create mode 100644 src/php/migration/importers/plugins/insert-php-code-snippet.php create mode 100644 src/php/migration/importers/plugins/manager.php 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/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/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/class-plugin.php b/src/php/class-plugin.php index a38e216e..56804959 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(), - ), - ) - ); - ?> -

- -
- - -

- -

- -

- -
-

- -

- -

- -

- -

- -

-
- -

- -

- -

- -
-

- - - - - -

-
- - -
-
+
From f23a30a352ba939d71a934d6256c616604618ed5 Mon Sep 17 00:00:00 2001 From: mgiannopoulos24 <79588074+mgiannopoulos24@users.noreply.github.com> Date: Thu, 11 Dec 2025 19:50:11 +0200 Subject: [PATCH 2/4] Add drag-and-drop functionality to snippets table --- src/css/manage.scss | 12 ++++++++ src/js/manage.ts | 3 +- src/js/services/manage/index.ts | 1 + src/js/services/manage/ordering.ts | 36 +++++++++++++++++++++++ src/php/admin-menus/class-manage-menu.php | 4 +-- 5 files changed, 53 insertions(+), 3 deletions(-) create mode 100644 src/js/services/manage/ordering.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/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-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 From d966fa3f70a52c672d1a1de160ad0e7ada491446 Mon Sep 17 00:00:00 2001 From: Louis Wolmarans Date: Thu, 18 Dec 2025 15:29:26 +0200 Subject: [PATCH 3/4] update test screenshot --- .../snippet-row-active-linux.png | Bin 7438 -> 7565 bytes 1 file changed, 0 insertions(+), 0 deletions(-) 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 index 6df3b3462851f29c41b8241002dc2f8b97b1864e..74b9f278b66b468c777f03d08b168d587f3021ae 100644 GIT binary patch literal 7565 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 literal 7438 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 LwUo*fUVr>A29&UU From f2697218cf757d04929e6e74e73a5ce411684dbe Mon Sep 17 00:00:00 2001 From: Louis Wolmarans Date: Thu, 18 Dec 2025 15:37:56 +0200 Subject: [PATCH 4/4] remove screenshot tests --- tests/e2e/code-snippets-list.spec.ts | 5 ----- .../snippet-row-active-linux.png | Bin 7565 -> 0 bytes .../snippet-row-inactive-linux.png | Bin 7594 -> 0 bytes 3 files changed, 5 deletions(-) delete mode 100644 tests/e2e/code-snippets-list.spec.ts-snapshots/snippet-row-active-linux.png delete mode 100644 tests/e2e/code-snippets-list.spec.ts-snapshots/snippet-row-inactive-linux.png 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 74b9f278b66b468c777f03d08b168d587f3021ae..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7565 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 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 7e9e376dbc09730e50f7a413b6766699f0c4941e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7594 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 B@F)NP