Add JSON export/import functionality for category mappings
Add the ability to export and import category mappings as JSON files: Export: - Downloads all category mappings as a formatted JSON file - Ordered by category and priority Import: - Upload JSON file with category mappings - Option to replace all existing mappings or merge with current ones - Validates JSON format and data integrity - Shows clear error messages for invalid files UI additions: - Export to JSON button in header - Import from JSON button with modal dialog - Shows example JSON format in import modal - Replace/merge option with clear explanation This allows users to: - Backup their custom category mappings - Share mappings between instances - Version control their categorization rules - Easily restore default or custom configurations 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -6,7 +6,13 @@
|
|||||||
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
<h2>Category Mappings</h2>
|
<h2>Category Mappings</h2>
|
||||||
<div>
|
<div class="d-flex gap-2">
|
||||||
|
<a asp-page-handler="Export" class="btn btn-outline-primary">
|
||||||
|
<span>⬇</span> Export to JSON
|
||||||
|
</a>
|
||||||
|
<button type="button" class="btn btn-outline-success" data-bs-toggle="modal" data-bs-target="#importModal">
|
||||||
|
<span>⬆</span> Import from JSON
|
||||||
|
</button>
|
||||||
<a asp-page="/Index" class="btn btn-outline-secondary">Back to Dashboard</a>
|
<a asp-page="/Index" class="btn btn-outline-secondary">Back to Dashboard</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -227,6 +233,60 @@ else
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Import Modal -->
|
||||||
|
<div class="modal fade" id="importModal" tabindex="-1" aria-labelledby="importModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<form method="post" asp-page-handler="Import" enctype="multipart/form-data">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="importModalLabel">Import Categories from JSON</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="jsonFile" class="form-label">Select JSON File</label>
|
||||||
|
<input type="file" name="jsonFile" id="jsonFile" class="form-control" accept=".json" required />
|
||||||
|
<div class="form-text">Upload a JSON file with category mappings</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" name="replaceExisting" id="replaceExisting" value="true" />
|
||||||
|
<label class="form-check-label" for="replaceExisting">
|
||||||
|
<strong>Replace all existing mappings</strong>
|
||||||
|
</label>
|
||||||
|
<div class="form-text">
|
||||||
|
If checked, all current mappings will be deleted and replaced with the imported ones.
|
||||||
|
If unchecked, imported mappings will be added to existing ones.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-info mb-0">
|
||||||
|
<strong>JSON Format:</strong>
|
||||||
|
<pre class="mb-0 mt-2 small">[
|
||||||
|
{
|
||||||
|
"category": "Groceries",
|
||||||
|
"pattern": "WALMART",
|
||||||
|
"priority": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "Gas & Auto",
|
||||||
|
"pattern": "SHELL",
|
||||||
|
"priority": 100
|
||||||
|
}
|
||||||
|
]</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-success">Import</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
@section Scripts {
|
@section Scripts {
|
||||||
<script>
|
<script>
|
||||||
function openEditModal(id, category, pattern, priority) {
|
function openEditModal(id, category, pattern, priority) {
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -108,6 +111,97 @@ namespace MoneyMap.Pages
|
|||||||
return RedirectToPage();
|
return RedirectToPage();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnGetExportAsync()
|
||||||
|
{
|
||||||
|
var mappings = await _db.CategoryMappings
|
||||||
|
.OrderBy(m => m.Category)
|
||||||
|
.ThenByDescending(m => m.Priority)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var exportData = mappings.Select(m => new CategoryMappingExport
|
||||||
|
{
|
||||||
|
Category = m.Category,
|
||||||
|
Pattern = m.Pattern,
|
||||||
|
Priority = m.Priority
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
var json = JsonSerializer.Serialize(exportData, new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
WriteIndented = true
|
||||||
|
});
|
||||||
|
|
||||||
|
var bytes = Encoding.UTF8.GetBytes(json);
|
||||||
|
return File(bytes, "application/json", "category-mappings.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnPostImportAsync(IFormFile jsonFile, bool replaceExisting)
|
||||||
|
{
|
||||||
|
if (jsonFile == null || jsonFile.Length == 0)
|
||||||
|
{
|
||||||
|
ErrorMessage = "Please select a JSON file to import.";
|
||||||
|
return RedirectToPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var stream = jsonFile.OpenReadStream();
|
||||||
|
var importData = await JsonSerializer.DeserializeAsync<List<CategoryMappingExport>>(stream, new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true
|
||||||
|
});
|
||||||
|
|
||||||
|
if (importData == null || !importData.Any())
|
||||||
|
{
|
||||||
|
ErrorMessage = "No category mappings found in the file.";
|
||||||
|
return RedirectToPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the data
|
||||||
|
foreach (var item in importData)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(item.Category) || string.IsNullOrWhiteSpace(item.Pattern))
|
||||||
|
{
|
||||||
|
ErrorMessage = "Invalid data in JSON file. Each mapping must have a category and pattern.";
|
||||||
|
return RedirectToPage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace existing or merge
|
||||||
|
if (replaceExisting)
|
||||||
|
{
|
||||||
|
var existingMappings = await _db.CategoryMappings.ToListAsync();
|
||||||
|
_db.CategoryMappings.RemoveRange(existingMappings);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new mappings
|
||||||
|
var newMappings = importData.Select(m => new CategoryMapping
|
||||||
|
{
|
||||||
|
Category = m.Category.Trim(),
|
||||||
|
Pattern = m.Pattern.Trim(),
|
||||||
|
Priority = m.Priority
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
_db.CategoryMappings.AddRange(newMappings);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
SuccessMessage = replaceExisting
|
||||||
|
? $"Successfully replaced all mappings with {importData.Count} mappings from file."
|
||||||
|
: $"Successfully imported {importData.Count} mappings from file.";
|
||||||
|
|
||||||
|
return RedirectToPage();
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
ErrorMessage = "Invalid JSON file format. Please check the file and try again.";
|
||||||
|
return RedirectToPage();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
ErrorMessage = $"Error importing file: {ex.Message}";
|
||||||
|
return RedirectToPage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task LoadDataAsync()
|
private async Task LoadDataAsync()
|
||||||
{
|
{
|
||||||
var mappings = await _categorizer.GetAllMappingsAsync();
|
var mappings = await _categorizer.GetAllMappingsAsync();
|
||||||
@@ -162,5 +256,12 @@ namespace MoneyMap.Pages
|
|||||||
|
|
||||||
public int Priority { get; set; } = 0;
|
public int Priority { get; set; } = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class CategoryMappingExport
|
||||||
|
{
|
||||||
|
public string Category { get; set; } = "";
|
||||||
|
public string Pattern { get; set; } = "";
|
||||||
|
public int Priority { get; set; } = 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user