feat(electron): implement inline metadata editing with save options modal
- Remove Edit button from header, add pencil icons next to editable fields - Add inline editing for Version, Author, and Comment metadata fields - Implement save options modal with overwrite/save-as-new functionality - Add file dialog integration for save-as-new option - Fix backend MANIFEST_FILENAME error by using correct string literals - Add CSS styling for editable fields and save options modal - Clean up debug logging code BREAKING CHANGE: Edit button removed from UI in favor of inline editing
This commit is contained in:
@@ -31,11 +31,11 @@
|
||||
<header class="content-header">
|
||||
<h2 id="bundle-title"></h2>
|
||||
<div class="action-buttons">
|
||||
<button id="edit-bundle-btn">Edit</button>
|
||||
<button id="sign-bundle-btn">Sign</button>
|
||||
<button id="validate-bundle-btn">Validate</button>
|
||||
<button id="fill-bundle-btn">Fill</button>
|
||||
<button id="clear-bundle-btn">Clear</button>
|
||||
<button id="save-metadata-btn" class="hidden">Save Changes</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -73,6 +73,35 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Spinner Overlay -->
|
||||
<div id="spinner-overlay" class="spinner-overlay hidden">
|
||||
<div class="spinner"></div>
|
||||
<p>Processing...</p>
|
||||
</div>
|
||||
|
||||
<!-- Save Options Modal -->
|
||||
<div id="save-options-modal" class="modal-container hidden">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>Save Metadata Changes</h3>
|
||||
<button class="modal-close" onclick="hideSaveOptionsModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>How would you like to save your metadata changes?</p>
|
||||
<div class="save-options">
|
||||
<button id="overwrite-bundle-btn" class="save-option-btn primary">
|
||||
<strong>Overwrite Current Bundle</strong>
|
||||
<small>Update the existing bundle file</small>
|
||||
</button>
|
||||
<button id="save-as-new-btn" class="save-option-btn secondary">
|
||||
<strong>Save As New Bundle</strong>
|
||||
<small>Create a new bundle file with changes</small>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fill Modal -->
|
||||
<div id="fill-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
|
||||
@@ -297,3 +297,19 @@ ipcMain.handle('open-bundle', async (event, bundlePath) => {
|
||||
// Return error as-is since it's already in the correct format
|
||||
return result || { success: false, error: 'An unknown error occurred while opening the bundle.' };
|
||||
});
|
||||
|
||||
// Handle show save dialog
|
||||
ipcMain.handle('show-save-dialog', async (event, options) => {
|
||||
const result = await dialog.showSaveDialog(BrowserWindow.fromWebContents(event.sender), options);
|
||||
return result;
|
||||
});
|
||||
|
||||
// Handle file copying
|
||||
ipcMain.handle('copy-file', async (event, { source, destination }) => {
|
||||
try {
|
||||
await fs.copy(source, destination);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
@@ -15,11 +15,16 @@ const openBundleBtn = document.getElementById('open-bundle-btn');
|
||||
const createBundleBtn = document.getElementById('create-bundle-btn');
|
||||
|
||||
// Bundle action buttons
|
||||
const editBundleBtn = document.getElementById('edit-bundle-btn');
|
||||
const signBundleBtn = document.getElementById('sign-bundle-btn');
|
||||
const validateBundleBtn = document.getElementById('validate-bundle-btn');
|
||||
const fillBundleBtn = document.getElementById('fill-bundle-btn');
|
||||
const clearBundleBtn = document.getElementById('clear-bundle-btn');
|
||||
const saveMetadataBtn = document.getElementById('save-metadata-btn');
|
||||
|
||||
// Save options modal elements
|
||||
const saveOptionsModal = document.getElementById('save-options-modal');
|
||||
const overwriteBundleBtn = document.getElementById('overwrite-bundle-btn');
|
||||
const saveAsNewBtn = document.getElementById('save-as-new-btn');
|
||||
|
||||
// Bundle display
|
||||
const bundleTitle = document.getElementById('bundle-title');
|
||||
@@ -52,6 +57,8 @@ const fillProgressView = document.getElementById('fill-progress-view');
|
||||
const fillProgressList = document.getElementById('fill-progress-list');
|
||||
|
||||
let currentBundlePath = null;
|
||||
let hasUnsavedChanges = false;
|
||||
let originalMetadata = {};
|
||||
|
||||
// --- INITIALIZATION ---
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
@@ -93,6 +100,9 @@ function setupEventListeners() {
|
||||
signBundleBtn.addEventListener('click', handleSignBundle);
|
||||
validateBundleBtn.addEventListener('click', handleValidateBundle);
|
||||
clearBundleBtn.addEventListener('click', handleClearBundle);
|
||||
saveMetadataBtn.addEventListener('click', showSaveOptionsModal);
|
||||
overwriteBundleBtn.addEventListener('click', () => handleSaveMetadata(false));
|
||||
saveAsNewBtn.addEventListener('click', () => handleSaveMetadata(true));
|
||||
fillBundleBtn.addEventListener('click', async () => {
|
||||
if (!currentBundlePath) {
|
||||
showModal('Error', 'No bundle is currently open.');
|
||||
@@ -415,6 +425,15 @@ function displayBundleInfo(report) {
|
||||
|
||||
const { manifest, signature, validation, plugins } = report;
|
||||
|
||||
// Store original metadata for comparison
|
||||
originalMetadata = {
|
||||
bundleVersion: manifest.bundleVersion || '',
|
||||
bundleAuthor: manifest.bundleAuthor || '',
|
||||
bundleComment: manifest.bundleComment || ''
|
||||
};
|
||||
hasUnsavedChanges = false;
|
||||
updateSaveButtonVisibility();
|
||||
|
||||
// Set bundle title
|
||||
bundleTitle.textContent = manifest.bundleName || 'Untitled Bundle';
|
||||
|
||||
@@ -443,16 +462,19 @@ function displayBundleInfo(report) {
|
||||
<div class="card">
|
||||
<div class="card-header"><h3>Manifest Details</h3></div>
|
||||
<div class="card-content">
|
||||
<p><strong>Version:</strong> ${manifest.bundleVersion || 'N/A'}</p>
|
||||
<p><strong>Author:</strong> ${manifest.bundleAuthor || 'N/A'}</p>
|
||||
${createEditableField('Version', 'bundleVersion', manifest.bundleVersion || 'N/A')}
|
||||
${createEditableField('Author', 'bundleAuthor', manifest.bundleAuthor || 'N/A')}
|
||||
<p><strong>Bundled On:</strong> ${manifest.bundledOn || 'N/A'}</p>
|
||||
<p><strong>Comment:</strong> ${manifest.bundleComment || 'N/A'}</p>
|
||||
${createEditableField('Comment', 'bundleComment', manifest.bundleComment || 'N/A')}
|
||||
${manifest.bundleAuthorKeyFingerprint ? `<p><strong>Author Key:</strong> ${manifest.bundleAuthorKeyFingerprint}</p>` : ''}
|
||||
${manifest.bundleSignature ? `<p><strong>Signature:</strong> <span class="signature">${manifest.bundleSignature}</span></p>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Add event listeners for edit functionality
|
||||
setupEditableFieldListeners();
|
||||
|
||||
// --- Plugins Tab ---
|
||||
pluginsList.innerHTML = '';
|
||||
if (plugins && Object.keys(plugins).length > 0) {
|
||||
@@ -493,3 +515,230 @@ function displayBundleInfo(report) {
|
||||
// Reset to overview tab by default
|
||||
switchTab('overview-tab');
|
||||
}
|
||||
|
||||
// Helper function to create editable fields with pencil icons
|
||||
function createEditableField(label, fieldName, value) {
|
||||
const displayValue = value === 'N/A' ? '' : value;
|
||||
return `
|
||||
<p class="editable-field">
|
||||
<strong>${label}:</strong>
|
||||
<span class="field-display" data-field="${fieldName}">${value}</span>
|
||||
<span class="field-edit hidden" data-field="${fieldName}">
|
||||
<input type="text" class="field-input" data-field="${fieldName}" value="${displayValue}">
|
||||
</span>
|
||||
<button class="edit-icon" data-field="${fieldName}" title="Edit ${label}">✏️</button>
|
||||
</p>
|
||||
`;
|
||||
}
|
||||
|
||||
// Setup event listeners for editable fields
|
||||
function setupEditableFieldListeners() {
|
||||
const editIcons = document.querySelectorAll('.edit-icon');
|
||||
const fieldInputs = document.querySelectorAll('.field-input');
|
||||
|
||||
editIcons.forEach(icon => {
|
||||
icon.addEventListener('click', (e) => {
|
||||
const fieldName = e.target.dataset.field;
|
||||
toggleFieldEdit(fieldName, true);
|
||||
});
|
||||
});
|
||||
|
||||
fieldInputs.forEach(input => {
|
||||
input.addEventListener('blur', (e) => {
|
||||
const fieldName = e.target.dataset.field;
|
||||
saveFieldEdit(fieldName);
|
||||
});
|
||||
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
const fieldName = e.target.dataset.field;
|
||||
saveFieldEdit(fieldName);
|
||||
} else if (e.key === 'Escape') {
|
||||
const fieldName = e.target.dataset.field;
|
||||
cancelFieldEdit(fieldName);
|
||||
}
|
||||
});
|
||||
|
||||
input.addEventListener('input', () => {
|
||||
checkForChanges();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Toggle between display and edit mode for a field
|
||||
function toggleFieldEdit(fieldName, editMode) {
|
||||
const displaySpan = document.querySelector(`.field-display[data-field="${fieldName}"]`);
|
||||
const editSpan = document.querySelector(`.field-edit[data-field="${fieldName}"]`);
|
||||
const input = document.querySelector(`.field-input[data-field="${fieldName}"]`);
|
||||
const icon = document.querySelector(`.edit-icon[data-field="${fieldName}"]`);
|
||||
|
||||
if (editMode) {
|
||||
displaySpan.classList.add('hidden');
|
||||
editSpan.classList.remove('hidden');
|
||||
icon.textContent = '✅';
|
||||
icon.title = 'Save';
|
||||
input.focus();
|
||||
input.select();
|
||||
} else {
|
||||
displaySpan.classList.remove('hidden');
|
||||
editSpan.classList.add('hidden');
|
||||
icon.textContent = '✏️';
|
||||
icon.title = `Edit ${fieldName}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Save field edit and update display
|
||||
function saveFieldEdit(fieldName) {
|
||||
const input = document.querySelector(`.field-input[data-field="${fieldName}"]`);
|
||||
const displaySpan = document.querySelector(`.field-display[data-field="${fieldName}"]`);
|
||||
|
||||
const newValue = input.value.trim();
|
||||
const displayValue = newValue || 'N/A';
|
||||
|
||||
displaySpan.textContent = displayValue;
|
||||
toggleFieldEdit(fieldName, false);
|
||||
checkForChanges();
|
||||
}
|
||||
|
||||
// Cancel field edit and restore original value
|
||||
function cancelFieldEdit(fieldName) {
|
||||
const input = document.querySelector(`.field-input[data-field="${fieldName}"]`);
|
||||
const originalValue = originalMetadata[fieldName] || '';
|
||||
|
||||
input.value = originalValue;
|
||||
toggleFieldEdit(fieldName, false);
|
||||
}
|
||||
|
||||
// Check if any fields have been modified
|
||||
function checkForChanges() {
|
||||
const inputs = document.querySelectorAll('.field-input');
|
||||
let hasChanges = false;
|
||||
|
||||
inputs.forEach(input => {
|
||||
const fieldName = input.dataset.field;
|
||||
const currentValue = input.value.trim();
|
||||
const originalValue = originalMetadata[fieldName] || '';
|
||||
|
||||
if (currentValue !== originalValue) {
|
||||
hasChanges = true;
|
||||
}
|
||||
});
|
||||
|
||||
hasUnsavedChanges = hasChanges;
|
||||
updateSaveButtonVisibility();
|
||||
}
|
||||
|
||||
// Show/hide save button based on changes
|
||||
function updateSaveButtonVisibility() {
|
||||
if (hasUnsavedChanges) {
|
||||
saveMetadataBtn.classList.remove('hidden');
|
||||
} else {
|
||||
saveMetadataBtn.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// Show save options modal
|
||||
function showSaveOptionsModal() {
|
||||
if (!currentBundlePath || !hasUnsavedChanges) return;
|
||||
saveOptionsModal.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// Hide save options modal
|
||||
function hideSaveOptionsModal() {
|
||||
saveOptionsModal.classList.add('hidden');
|
||||
}
|
||||
|
||||
// Handle save metadata with option for save as new
|
||||
async function handleSaveMetadata(saveAsNew = false) {
|
||||
if (!currentBundlePath || !hasUnsavedChanges) return;
|
||||
|
||||
// Hide the modal first
|
||||
hideSaveOptionsModal();
|
||||
|
||||
const inputs = document.querySelectorAll('.field-input');
|
||||
const updatedMetadata = {};
|
||||
|
||||
inputs.forEach(input => {
|
||||
const fieldName = input.dataset.field;
|
||||
const value = input.value.trim();
|
||||
if (value !== originalMetadata[fieldName]) {
|
||||
// Convert camelCase to snake_case for backend
|
||||
const backendFieldName = fieldName.replace(/([A-Z])/g, '_$1').toLowerCase();
|
||||
updatedMetadata[backendFieldName] = value;
|
||||
}
|
||||
});
|
||||
|
||||
if (Object.keys(updatedMetadata).length === 0) {
|
||||
hasUnsavedChanges = false;
|
||||
updateSaveButtonVisibility();
|
||||
return;
|
||||
}
|
||||
|
||||
let targetPath = currentBundlePath;
|
||||
|
||||
if (saveAsNew) {
|
||||
// Show file save dialog for new bundle
|
||||
const result = await ipcRenderer.invoke('show-save-dialog', {
|
||||
defaultPath: currentBundlePath.replace('.fbundle', '_edited.fbundle'),
|
||||
filters: [{ name: 'Bundle Files', extensions: ['fbundle'] }]
|
||||
});
|
||||
|
||||
if (result.canceled) {
|
||||
return; // User canceled the save dialog
|
||||
}
|
||||
|
||||
targetPath = result.filePath;
|
||||
|
||||
// Copy the original bundle to the new location first
|
||||
try {
|
||||
await ipcRenderer.invoke('copy-file', {
|
||||
source: currentBundlePath,
|
||||
destination: targetPath
|
||||
});
|
||||
} catch (error) {
|
||||
showModal('Copy Error', `Failed to copy bundle: ${error.message}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
showSpinner();
|
||||
const result = await ipcRenderer.invoke('edit-bundle', {
|
||||
bundlePath: targetPath,
|
||||
updatedManifest: updatedMetadata
|
||||
});
|
||||
hideSpinner();
|
||||
|
||||
if (result.success) {
|
||||
// Update original metadata to reflect saved changes
|
||||
Object.keys(updatedMetadata).forEach(backendKey => {
|
||||
const frontendKey = backendKey.replace(/_([a-z])/g, (match, letter) => letter.toUpperCase());
|
||||
originalMetadata[frontendKey] = updatedMetadata[backendKey];
|
||||
});
|
||||
|
||||
hasUnsavedChanges = false;
|
||||
updateSaveButtonVisibility();
|
||||
|
||||
if (saveAsNew) {
|
||||
showModal('Success', `New bundle created successfully at: ${targetPath}`);
|
||||
// Open the new bundle
|
||||
currentBundlePath = targetPath;
|
||||
await reloadCurrentBundle();
|
||||
} else {
|
||||
showModal('Success', 'Metadata updated successfully!');
|
||||
// Reload current bundle to reflect changes
|
||||
await reloadCurrentBundle();
|
||||
}
|
||||
} else {
|
||||
showModal('Save Error', `Failed to save metadata: ${result.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to reload current bundle
|
||||
async function reloadCurrentBundle() {
|
||||
if (!currentBundlePath) return;
|
||||
|
||||
const result = await ipcRenderer.invoke('open-bundle', currentBundlePath);
|
||||
if (result.success) {
|
||||
displayBundleInfo(result.report);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -370,6 +370,99 @@ body {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Inline Editing Styles */
|
||||
.editable-field {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.edit-icon {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.edit-icon:hover {
|
||||
background-color: var(--border-color);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.field-input {
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
font-family: var(--font-family);
|
||||
background-color: white;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.field-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 2px rgba(52, 152, 219, 0.2);
|
||||
}
|
||||
|
||||
.field-display {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Save Options Modal */
|
||||
.save-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.save-option-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding: 16px;
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.save-option-btn:hover {
|
||||
border-color: var(--primary-color);
|
||||
background-color: rgba(52, 152, 219, 0.05);
|
||||
}
|
||||
|
||||
.save-option-btn.primary {
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.save-option-btn.secondary {
|
||||
border-color: var(--text-light);
|
||||
}
|
||||
|
||||
.save-option-btn strong {
|
||||
font-size: 16px;
|
||||
margin-bottom: 4px;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.save-option-btn small {
|
||||
font-size: 14px;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.progress-indicator.spinner-icon {
|
||||
border: 2px solid var(--text-color-light);
|
||||
border-top: 2px solid var(--primary-color);
|
||||
|
||||
@@ -283,10 +283,10 @@ def edit_bundle_metadata(bundle_path: Path, metadata: dict, progress_callback: O
|
||||
raise FileNotFoundError("Bundle is not a valid zip file.")
|
||||
|
||||
with zipfile.ZipFile(bundle_path, 'a') as zf:
|
||||
if MANIFEST_FILENAME not in zf.namelist():
|
||||
raise FileNotFoundError(f"{MANIFEST_FILENAME} not found in bundle.")
|
||||
if "manifest.yaml" not in zf.namelist():
|
||||
raise FileNotFoundError("manifest.yaml not found in bundle.")
|
||||
|
||||
with zf.open(MANIFEST_FILENAME, 'r') as f:
|
||||
with zf.open("manifest.yaml", 'r') as f:
|
||||
manifest = yaml.safe_load(f)
|
||||
|
||||
_progress("Updating manifest...")
|
||||
@@ -305,14 +305,14 @@ def edit_bundle_metadata(bundle_path: Path, metadata: dict, progress_callback: O
|
||||
temp_bundle_path = bundle_path.with_suffix('.zip.tmp')
|
||||
with zipfile.ZipFile(temp_bundle_path, 'w', zipfile.ZIP_DEFLATED) as temp_zf:
|
||||
for item in zf.infolist():
|
||||
if item.filename == MANIFEST_FILENAME:
|
||||
if item.filename == "manifest.yaml":
|
||||
continue # Skip old manifest
|
||||
buffer = zf.read(item.filename)
|
||||
temp_zf.writestr(item, buffer)
|
||||
|
||||
# Write the updated manifest
|
||||
new_manifest_content = yaml.dump(manifest, Dumper=yaml.SafeDumper)
|
||||
temp_zf.writestr(MANIFEST_FILENAME, new_manifest_content)
|
||||
temp_zf.writestr("manifest.yaml", new_manifest_content)
|
||||
|
||||
# Replace the original bundle with the updated one
|
||||
shutil.move(temp_bundle_path, bundle_path)
|
||||
|
||||
Reference in New Issue
Block a user