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:
AJ
2025-10-12 01:27:19 -04:00
parent 977a8de9f9
commit 52615eeb75
2 changed files with 162 additions and 1 deletions

View File

@@ -6,7 +6,13 @@
<div class="d-flex justify-content-between align-items-center mb-3">
<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>
</div>
</div>
@@ -227,6 +233,60 @@ else
</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 {
<script>
function openEditModal(id, category, pattern, priority) {

View File

@@ -1,7 +1,10 @@
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
@@ -108,6 +111,97 @@ namespace MoneyMap.Pages
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()
{
var mappings = await _categorizer.GetAllMappingsAsync();
@@ -162,5 +256,12 @@ namespace MoneyMap.Pages
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;
}
}
}