Browse Source

feat(curating): add user event viewer and delete functionality (v0.50.0)

- Add geteventsforpubkey API method for viewing user events with pagination
- Add deleteeventsforpubkey API method to purge blacklisted user events
- Add clickable user detail view in curation UI showing all events
- Add event content expansion/truncation for long content
- Add kind name display for common Nostr event types
- Implement safety check requiring blacklist before event deletion

Files modified:
- app/handle-nip86-curating.go: Add event fetch/delete handlers
- pkg/database/curating-acl.go: Add GetEventsForPubkey, DeleteEventsForPubkey
- app/web/src/CurationView.svelte: Add user detail view with event listing
- pkg/version/version: Bump to v0.50.0

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
main v0.50.0
woikos 4 months ago
parent
commit
cb50a9c5c4
No known key found for this signature in database
  1. 110
      app/handle-nip86-curating.go
  2. 2
      app/web/dist/bundle.css
  3. 28
      app/web/dist/bundle.js
  4. 2
      app/web/dist/bundle.js.map
  5. 465
      app/web/src/CurationView.svelte
  6. 97
      pkg/database/curating-acl.go
  7. 2
      pkg/version/version

110
app/handle-nip86-curating.go

@ -145,6 +145,10 @@ func (s *Server) handleCuratingNIP86Method(request NIP86Request, curatingACL *ac
return s.handleIsConfigured(dbACL) return s.handleIsConfigured(dbACL)
case "scanpubkeys": case "scanpubkeys":
return s.handleScanPubkeys(dbACL) return s.handleScanPubkeys(dbACL)
case "geteventsforpubkey":
return s.handleGetEventsForPubkey(request.Params, dbACL)
case "deleteeventsforpubkey":
return s.handleDeleteEventsForPubkey(request.Params, dbACL)
default: default:
return NIP86Response{Error: "Unknown method: " + request.Method} return NIP86Response{Error: "Unknown method: " + request.Method}
} }
@ -170,6 +174,8 @@ func (s *Server) handleCuratingSupportedMethods() NIP86Response {
"unblockip", "unblockip",
"isconfigured", "isconfigured",
"scanpubkeys", "scanpubkeys",
"geteventsforpubkey",
"deleteeventsforpubkey",
} }
return NIP86Response{Result: methods} return NIP86Response{Result: methods}
} }
@ -624,3 +630,107 @@ func (s *Server) handleScanPubkeys(dbACL *database.CuratingACL) NIP86Response {
"skipped": result.Skipped, "skipped": result.Skipped,
}} }}
} }
// handleGetEventsForPubkey returns events for a specific pubkey
// Params: [pubkey, limit (optional, default 100), offset (optional, default 0)]
func (s *Server) handleGetEventsForPubkey(params []interface{}, dbACL *database.CuratingACL) NIP86Response {
if len(params) < 1 {
return NIP86Response{Error: "Missing required parameter: pubkey"}
}
pubkey, ok := params[0].(string)
if !ok {
return NIP86Response{Error: "Invalid pubkey parameter"}
}
if len(pubkey) != 64 {
return NIP86Response{Error: "Invalid pubkey format (must be 64 hex characters)"}
}
// Parse optional limit (default 100)
limit := 100
if len(params) > 1 {
if l, ok := params[1].(float64); ok {
limit = int(l)
if limit > 500 {
limit = 500 // Cap at 500
}
if limit < 1 {
limit = 1
}
}
}
// Parse optional offset (default 0)
offset := 0
if len(params) > 2 {
if o, ok := params[2].(float64); ok {
offset = int(o)
if offset < 0 {
offset = 0
}
}
}
events, total, err := dbACL.GetEventsForPubkey(pubkey, limit, offset)
if chk.E(err) {
return NIP86Response{Error: "Failed to get events: " + err.Error()}
}
// Convert to response format
eventList := make([]map[string]interface{}, len(events))
for i, ev := range events {
eventList[i] = map[string]interface{}{
"id": ev.ID,
"kind": ev.Kind,
"content": ev.Content,
"created_at": ev.CreatedAt,
}
}
return NIP86Response{Result: map[string]interface{}{
"events": eventList,
"total": total,
"limit": limit,
"offset": offset,
}}
}
// handleDeleteEventsForPubkey deletes all events for a specific pubkey
// This is only allowed for blacklisted pubkeys as a safety measure
// Params: [pubkey]
func (s *Server) handleDeleteEventsForPubkey(params []interface{}, dbACL *database.CuratingACL) NIP86Response {
if len(params) < 1 {
return NIP86Response{Error: "Missing required parameter: pubkey"}
}
pubkey, ok := params[0].(string)
if !ok {
return NIP86Response{Error: "Invalid pubkey parameter"}
}
if len(pubkey) != 64 {
return NIP86Response{Error: "Invalid pubkey format (must be 64 hex characters)"}
}
// Safety check: only allow deletion of events from blacklisted users
isBlacklisted, err := dbACL.IsPubkeyBlacklisted(pubkey)
if chk.E(err) {
return NIP86Response{Error: "Failed to check blacklist status: " + err.Error()}
}
if !isBlacklisted {
return NIP86Response{Error: "Can only delete events from blacklisted users. Blacklist the user first."}
}
// Delete all events for this pubkey
deleted, err := dbACL.DeleteEventsForPubkey(pubkey)
if chk.E(err) {
return NIP86Response{Error: "Failed to delete events: " + err.Error()}
}
return NIP86Response{Result: map[string]interface{}{
"deleted": deleted,
"pubkey": pubkey,
}}
}

2
app/web/dist/bundle.css vendored

File diff suppressed because one or more lines are too long

28
app/web/dist/bundle.js vendored

File diff suppressed because one or more lines are too long

2
app/web/dist/bundle.js.map vendored

File diff suppressed because one or more lines are too long

465
app/web/src/CurationView.svelte

@ -13,6 +13,15 @@
let messageType = "info"; let messageType = "info";
let isConfigured = false; let isConfigured = false;
// User detail view state
let selectedUser = null;
let selectedUserType = null; // "trusted", "blacklisted", or "unclassified"
let userEvents = [];
let userEventsTotal = 0;
let userEventsOffset = 0;
let loadingEvents = false;
let expandedEvents = {}; // Track which events are expanded
// Configuration state // Configuration state
let config = { let config = {
daily_limit: 50, daily_limit: 50,
@ -443,6 +452,176 @@
if (!timestamp) return ""; if (!timestamp) return "";
return new Date(timestamp).toLocaleString(); return new Date(timestamp).toLocaleString();
} }
// Show message helper
function showMessage(msg, type = "info") {
message = msg;
messageType = type;
}
// Open user detail view
async function openUserDetail(pubkey, type) {
console.log("openUserDetail called:", pubkey, type);
selectedUser = pubkey;
selectedUserType = type;
userEvents = [];
userEventsTotal = 0;
userEventsOffset = 0;
expandedEvents = {};
console.log("selectedUser set to:", selectedUser);
await loadUserEvents();
}
// Close user detail view
function closeUserDetail() {
selectedUser = null;
selectedUserType = null;
userEvents = [];
userEventsTotal = 0;
userEventsOffset = 0;
expandedEvents = {};
}
// Load events for selected user
async function loadUserEvents() {
console.log("loadUserEvents called, selectedUser:", selectedUser, "loadingEvents:", loadingEvents);
if (!selectedUser || loadingEvents) return;
try {
loadingEvents = true;
console.log("Calling geteventsforpubkey API...");
const result = await callNIP86API("geteventsforpubkey", [selectedUser, 100, userEventsOffset]);
console.log("API result:", result);
if (result) {
if (userEventsOffset === 0) {
userEvents = result.events || [];
} else {
userEvents = [...userEvents, ...(result.events || [])];
}
userEventsTotal = result.total || 0;
}
} catch (error) {
console.error("Failed to load user events:", error);
showMessage("Failed to load events: " + error.message, "error");
} finally {
loadingEvents = false;
}
}
// Load more events
async function loadMoreEvents() {
userEventsOffset = userEvents.length;
await loadUserEvents();
}
// Toggle event expansion
function toggleEventExpansion(eventId) {
expandedEvents = {
...expandedEvents,
[eventId]: !expandedEvents[eventId]
};
}
// Truncate content to 6 lines (approximately 300 chars per line)
function truncateContent(content, maxLines = 6) {
if (!content) return "";
const lines = content.split('\n');
if (lines.length <= maxLines && content.length <= maxLines * 100) {
return content;
}
// Truncate by lines or characters, whichever is smaller
let truncated = lines.slice(0, maxLines).join('\n');
if (truncated.length > maxLines * 100) {
truncated = truncated.substring(0, maxLines * 100);
}
return truncated;
}
// Check if content is truncated
function isContentTruncated(content, maxLines = 6) {
if (!content) return false;
const lines = content.split('\n');
return lines.length > maxLines || content.length > maxLines * 100;
}
// Trust user from detail view and refresh
async function trustUserFromDetail() {
await trustPubkey(selectedUser, "");
// Refresh list and go back
await loadAllData();
closeUserDetail();
}
// Blacklist user from detail view and refresh
async function blacklistUserFromDetail() {
await blacklistPubkey(selectedUser, "");
// Refresh list and go back
await loadAllData();
closeUserDetail();
}
// Untrust user from detail view and refresh
async function untrustUserFromDetail() {
await untrustPubkey(selectedUser);
await loadAllData();
closeUserDetail();
}
// Unblacklist user from detail view and refresh
async function unblacklistUserFromDetail() {
await unblacklistPubkey(selectedUser);
await loadAllData();
closeUserDetail();
}
// Delete all events for a blacklisted user
async function deleteAllEventsForUser() {
if (!confirm(`Delete ALL ${userEventsTotal} events from this user? This cannot be undone.`)) {
return;
}
try {
isLoading = true;
const result = await callNIP86API("deleteeventsforpubkey", [selectedUser]);
showMessage(`Deleted ${result.deleted} events`, "success");
// Refresh the events list
userEvents = [];
userEventsTotal = 0;
userEventsOffset = 0;
await loadUserEvents();
} catch (error) {
console.error("Failed to delete events:", error);
showMessage("Failed to delete events: " + error.message, "error");
} finally {
isLoading = false;
}
}
// Get kind name
function getKindName(kind) {
const kindNames = {
0: "Metadata",
1: "Text Note",
3: "Follow List",
4: "Encrypted DM",
6: "Repost",
7: "Reaction",
14: "Chat Message",
16: "Order Message",
17: "Payment Receipt",
1063: "File Metadata",
10002: "Relay List",
30017: "Stall",
30018: "Product (NIP-15)",
30023: "Long-form",
30078: "App Data",
30402: "Product (NIP-99)",
30405: "Collection",
30406: "Shipping",
31555: "Review",
};
return kindNames[kind] || `Kind ${kind}`;
}
</script> </script>
<div class="curation-view"> <div class="curation-view">
@ -544,6 +723,74 @@
<p class="publish-note">This will publish a kind 30078 event to activate curating mode.</p> <p class="publish-note">This will publish a kind 30078 event to activate curating mode.</p>
</div> </div>
</div> </div>
{:else}
<!-- User Detail View -->
{#if selectedUser}
<div class="user-detail-view">
<div class="detail-header">
<div class="detail-header-left">
<button class="back-btn" on:click={closeUserDetail}>
&larr; Back
</button>
<h3>User Events</h3>
<span class="detail-pubkey" title={selectedUser}>{formatPubkey(selectedUser)}</span>
<span class="detail-count">{userEventsTotal} events</span>
</div>
<div class="detail-header-right">
{#if selectedUserType === "trusted"}
<button class="btn-danger" on:click={untrustUserFromDetail}>Remove Trust</button>
<button class="btn-danger" on:click={blacklistUserFromDetail}>Blacklist</button>
{:else if selectedUserType === "blacklisted"}
<button class="btn-delete-all" on:click={deleteAllEventsForUser} disabled={isLoading || userEventsTotal === 0}>
Delete All Events
</button>
<button class="btn-success" on:click={unblacklistUserFromDetail}>Remove from Blacklist</button>
<button class="btn-success" on:click={trustUserFromDetail}>Trust</button>
{:else}
<button class="btn-success" on:click={trustUserFromDetail}>Trust</button>
<button class="btn-danger" on:click={blacklistUserFromDetail}>Blacklist</button>
{/if}
</div>
</div>
<div class="events-list">
{#if loadingEvents && userEvents.length === 0}
<div class="loading">Loading events...</div>
{:else if userEvents.length === 0}
<div class="empty">No events found for this user.</div>
{:else}
{#each userEvents as event}
<div class="event-item">
<div class="event-header">
<span class="event-kind">{getKindName(event.kind)}</span>
<span class="event-id" title={event.id}>{formatPubkey(event.id)}</span>
<span class="event-time">{formatDate(event.created_at * 1000)}</span>
</div>
<div class="event-content" class:expanded={expandedEvents[event.id]}>
{#if expandedEvents[event.id] || !isContentTruncated(event.content)}
<pre>{event.content || "(empty)"}</pre>
{:else}
<pre>{truncateContent(event.content)}...</pre>
{/if}
</div>
{#if isContentTruncated(event.content)}
<button class="expand-btn" on:click={() => toggleEventExpansion(event.id)}>
{expandedEvents[event.id] ? "Show less" : "Show more"}
</button>
{/if}
</div>
{/each}
{#if userEvents.length < userEventsTotal}
<div class="load-more">
<button on:click={loadMoreEvents} disabled={loadingEvents}>
{loadingEvents ? "Loading..." : `Load more (${userEvents.length} of ${userEventsTotal})`}
</button>
</div>
{/if}
{/if}
</div>
</div>
{:else} {:else}
<!-- Active Mode --> <!-- Active Mode -->
<div class="tabs"> <div class="tabs">
@ -592,7 +839,7 @@
<div class="list"> <div class="list">
{#if trustedPubkeys.length > 0} {#if trustedPubkeys.length > 0}
{#each trustedPubkeys as item} {#each trustedPubkeys as item}
<div class="list-item"> <div class="list-item clickable" on:click={() => openUserDetail(item.pubkey, "trusted")}>
<div class="item-main"> <div class="item-main">
<span class="pubkey" title={item.pubkey}>{formatPubkey(item.pubkey)}</span> <span class="pubkey" title={item.pubkey}>{formatPubkey(item.pubkey)}</span>
{#if item.note} {#if item.note}
@ -600,7 +847,7 @@
{/if} {/if}
</div> </div>
<div class="item-actions"> <div class="item-actions">
<button class="btn-danger" on:click={() => untrustPubkey(item.pubkey)}> <button class="btn-danger" on:click|stopPropagation={() => untrustPubkey(item.pubkey)}>
Remove Remove
</button> </button>
</div> </div>
@ -637,7 +884,7 @@
<div class="list"> <div class="list">
{#if blacklistedPubkeys.length > 0} {#if blacklistedPubkeys.length > 0}
{#each blacklistedPubkeys as item} {#each blacklistedPubkeys as item}
<div class="list-item"> <div class="list-item clickable" on:click={() => openUserDetail(item.pubkey, "blacklisted")}>
<div class="item-main"> <div class="item-main">
<span class="pubkey" title={item.pubkey}>{formatPubkey(item.pubkey)}</span> <span class="pubkey" title={item.pubkey}>{formatPubkey(item.pubkey)}</span>
{#if item.reason} {#if item.reason}
@ -645,7 +892,7 @@
{/if} {/if}
</div> </div>
<div class="item-actions"> <div class="item-actions">
<button class="btn-success" on:click={() => unblacklistPubkey(item.pubkey)}> <button class="btn-success" on:click|stopPropagation={() => unblacklistPubkey(item.pubkey)}>
Remove Remove
</button> </button>
</div> </div>
@ -675,16 +922,16 @@
<div class="list"> <div class="list">
{#if unclassifiedUsers.length > 0} {#if unclassifiedUsers.length > 0}
{#each unclassifiedUsers as user} {#each unclassifiedUsers as user}
<div class="list-item"> <div class="list-item clickable" on:click={() => openUserDetail(user.pubkey, "unclassified")}>
<div class="item-main"> <div class="item-main">
<span class="pubkey" title={user.pubkey}>{formatPubkey(user.pubkey)}</span> <span class="pubkey" title={user.pubkey}>{formatPubkey(user.pubkey)}</span>
<span class="event-count">{user.event_count} events</span> <span class="event-count">{user.event_count} events</span>
</div> </div>
<div class="item-actions"> <div class="item-actions">
<button class="btn-success" on:click={() => trustPubkey(user.pubkey, "")}> <button class="btn-success" on:click|stopPropagation={() => trustPubkey(user.pubkey, "")}>
Trust Trust
</button> </button>
<button class="btn-danger" on:click={() => blacklistPubkey(user.pubkey, "")}> <button class="btn-danger" on:click|stopPropagation={() => blacklistPubkey(user.pubkey, "")}>
Blacklist Blacklist
</button> </button>
</div> </div>
@ -859,6 +1106,7 @@
{/if} {/if}
</div> </div>
{/if} {/if}
{/if}
</div> </div>
<style> <style>
@ -1260,6 +1508,26 @@
font-size: 0.85em; font-size: 0.85em;
} }
.btn-delete-all {
padding: 0.35rem 0.75rem;
background: #8B0000;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.85em;
font-weight: 600;
}
.btn-delete-all:hover:not(:disabled) {
background: #660000;
}
.btn-delete-all:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.empty { .empty {
padding: 2rem; padding: 2rem;
text-align: center; text-align: center;
@ -1267,4 +1535,187 @@
opacity: 0.6; opacity: 0.6;
font-style: italic; font-style: italic;
} }
/* Clickable list items */
.list-item.clickable {
cursor: pointer;
transition: background-color 0.2s;
}
.list-item.clickable:hover {
background-color: var(--button-hover-bg);
}
/* User Detail View */
.user-detail-view {
background: var(--card-bg);
border-radius: 8px;
padding: 1.5em;
border: 1px solid var(--border-color);
}
.detail-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border-color);
flex-wrap: wrap;
gap: 1rem;
}
.detail-header-left {
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.detail-header-left h3 {
margin: 0;
color: var(--text-color);
}
.detail-header-right {
display: flex;
gap: 0.5rem;
}
.back-btn {
padding: 0.5rem 1rem;
background: var(--bg-color);
color: var(--text-color);
border: 1px solid var(--border-color);
border-radius: 4px;
cursor: pointer;
font-size: 0.9em;
}
.back-btn:hover {
background: var(--button-hover-bg);
}
.detail-pubkey {
font-family: monospace;
font-size: 0.9em;
color: var(--text-color);
background: var(--bg-color);
padding: 0.25rem 0.5rem;
border-radius: 4px;
}
.detail-count {
font-size: 0.85em;
color: var(--success);
font-weight: 500;
}
/* Events List */
.events-list {
max-height: 600px;
overflow-y: auto;
}
.event-item {
background: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 1rem;
margin-bottom: 0.75rem;
}
.event-header {
display: flex;
gap: 1rem;
margin-bottom: 0.5rem;
flex-wrap: wrap;
align-items: center;
}
.event-kind {
background: var(--accent-color);
color: var(--text-color);
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-size: 0.8em;
font-weight: 500;
}
.event-id {
font-family: monospace;
font-size: 0.8em;
color: var(--text-color);
opacity: 0.7;
}
.event-time {
font-size: 0.8em;
color: var(--text-color);
opacity: 0.6;
}
.event-content {
background: var(--card-bg);
border-radius: 4px;
padding: 0.75rem;
overflow: hidden;
}
.event-content pre {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
font-family: inherit;
font-size: 0.9em;
color: var(--text-color);
max-height: 150px;
overflow: hidden;
}
.event-content.expanded pre {
max-height: none;
}
.expand-btn {
margin-top: 0.5rem;
padding: 0.25rem 0.5rem;
background: transparent;
color: var(--accent-color);
border: 1px solid var(--accent-color);
border-radius: 4px;
cursor: pointer;
font-size: 0.8em;
}
.expand-btn:hover {
background: var(--accent-color);
color: var(--text-color);
}
.load-more {
text-align: center;
padding: 1rem;
}
.load-more button {
padding: 0.5rem 1.5rem;
background: var(--info);
color: var(--text-color);
border: none;
border-radius: 4px;
cursor: pointer;
}
.load-more button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.loading {
padding: 2rem;
text-align: center;
color: var(--text-color);
opacity: 0.6;
}
</style> </style>

97
pkg/database/curating-acl.go

@ -4,6 +4,7 @@ package database
import ( import (
"bytes" "bytes"
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"sort" "sort"
@ -11,6 +12,9 @@ import (
"github.com/dgraph-io/badger/v4" "github.com/dgraph-io/badger/v4"
"github.com/minio/sha256-simd" "github.com/minio/sha256-simd"
"git.mleku.dev/mleku/nostr/encoders/filter"
"git.mleku.dev/mleku/nostr/encoders/hex"
"git.mleku.dev/mleku/nostr/encoders/tag"
) )
// CuratingConfig represents the configuration for curating ACL mode // CuratingConfig represents the configuration for curating ACL mode
@ -1099,6 +1103,99 @@ func (c *CuratingACL) ScanAllPubkeys() (*ScanResult, error) {
return result, nil return result, nil
} }
// EventSummary represents a simplified event for display in the UI
type EventSummary struct {
ID string `json:"id"`
Kind int `json:"kind"`
Content string `json:"content"`
CreatedAt int64 `json:"created_at"`
}
// GetEventsForPubkey fetches events for a pubkey, returning simplified event data
// limit specifies max events to return, offset is for pagination
func (c *CuratingACL) GetEventsForPubkey(pubkeyHex string, limit, offset int) ([]EventSummary, int, error) {
var events []EventSummary
// First, count total events for this pubkey
totalCount, err := c.countEventsForPubkey(pubkeyHex)
if err != nil {
return nil, 0, err
}
// Decode the pubkey hex to bytes
pubkeyBytes, err := hex.DecAppend(nil, []byte(pubkeyHex))
if err != nil {
return nil, 0, fmt.Errorf("invalid pubkey hex: %w", err)
}
// Create a filter to query events by author
// Use a larger limit to account for offset, then slice
queryLimit := uint(limit + offset)
f := &filter.F{
Authors: tag.NewFromBytesSlice(pubkeyBytes),
Limit: &queryLimit,
}
// Query events using the database's QueryEvents method
ctx := context.Background()
evs, err := c.D.QueryEvents(ctx, f)
if err != nil {
return nil, 0, err
}
// Apply offset and convert to EventSummary
for i, ev := range evs {
if i < offset {
continue
}
if len(events) >= limit {
break
}
events = append(events, EventSummary{
ID: hex.Enc(ev.ID),
Kind: int(ev.Kind),
Content: string(ev.Content),
CreatedAt: ev.CreatedAt,
})
}
return events, totalCount, nil
}
// DeleteEventsForPubkey deletes all events for a given pubkey
// Returns the number of events deleted
func (c *CuratingACL) DeleteEventsForPubkey(pubkeyHex string) (int, error) {
// Decode the pubkey hex to bytes
pubkeyBytes, err := hex.DecAppend(nil, []byte(pubkeyHex))
if err != nil {
return 0, fmt.Errorf("invalid pubkey hex: %w", err)
}
// Create a filter to find all events by this author
f := &filter.F{
Authors: tag.NewFromBytesSlice(pubkeyBytes),
}
// Query all events for this pubkey
ctx := context.Background()
evs, err := c.D.QueryEvents(ctx, f)
if err != nil {
return 0, err
}
// Delete each event
deleted := 0
for _, ev := range evs {
if err := c.D.DeleteEvent(ctx, ev.ID); err != nil {
// Log error but continue deleting
continue
}
deleted++
}
return deleted, nil
}
// countEventsForPubkey counts events in the database for a given pubkey hex string // countEventsForPubkey counts events in the database for a given pubkey hex string
func (c *CuratingACL) countEventsForPubkey(pubkeyHex string) (int, error) { func (c *CuratingACL) countEventsForPubkey(pubkeyHex string) (int, error) {
count := 0 count := 0

2
pkg/version/version

@ -1 +1 @@
v0.49.2 v0.50.0

Loading…
Cancel
Save