fix(journals): resolve browser hang and integrate Metadata component

- Implemented manual 'deep_copy' to handle reactive proxies safely.
- Added concurrency locks ('is_processing') to prevent PATCH loops.
- Standardized metadata display by switching to 'JournalEntry_Metadata'.
This commit is contained in:
Scott Idem
2026-01-14 16:31:27 -05:00
parent 58a41e093b
commit b9fc4addfc

View File

@@ -22,7 +22,7 @@
import JournalEntry_Editor from './JournalEntry_Editor.svelte'; import JournalEntry_Editor from './JournalEntry_Editor.svelte';
import JournalEntry_Header from './JournalEntry_Header.svelte'; import JournalEntry_Header from './JournalEntry_Header.svelte';
import AE_MetadataFooter from '$lib/ae_elements/AE_MetadataFooter.svelte'; import JournalEntry_Metadata from './JournalEntry_Metadata.svelte';
import AE_AITools from '$lib/ae_elements/AE_AITools.svelte'; import AE_AITools from '$lib/ae_elements/AE_AITools.svelte';
import AeCompModalJournalEntryAppend from './ae_comp__modal_journal_entry_append.svelte'; import AeCompModalJournalEntryAppend from './ae_comp__modal_journal_entry_append.svelte';
@@ -55,6 +55,32 @@
let auto_save_timer: ReturnType<typeof setTimeout>; let auto_save_timer: ReturnType<typeof setTimeout>;
let is_processing = $state(false); let is_processing = $state(false);
// *** Helpers
function deep_copy(obj: any) {
if (!obj) return null;
try {
// Manual selective copy to avoid proxy issues and handle Dates
const copy: key_val = {};
for (const key in obj) {
const val = obj[key];
if (val instanceof Date) {
copy[key] = new Date(val.getTime());
} else if (Array.isArray(val)) {
copy[key] = [...val];
} else if (typeof val === 'object' && val !== null) {
// Simple object copy (shallow for nested to avoid recursion)
copy[key] = { ...val };
} else {
copy[key] = val;
}
}
return copy;
} catch (e) {
console.error('deep_copy failed:', e);
return { ...obj };
}
}
// *** Derived // *** Derived
let has_unsaved_changes = $derived.by(() => { let has_unsaved_changes = $derived.by(() => {
if (!tmp_entry_obj || !orig_entry_obj || is_processing) return false; if (!tmp_entry_obj || !orig_entry_obj || is_processing) return false;
@@ -66,12 +92,14 @@
const name_changed = normalize(tmp_entry_obj.name) !== normalize(orig_entry_obj.name); const name_changed = normalize(tmp_entry_obj.name) !== normalize(orig_entry_obj.name);
const private_changed = (tmp_entry_obj.private ?? false) !== (orig_entry_obj.private ?? false); const private_changed = (tmp_entry_obj.private ?? false) !== (orig_entry_obj.private ?? false);
return content_changed || name_changed || private_changed || if (content_changed || name_changed || private_changed) return true;
(tmp_entry_obj.tags ?? null) !== (orig_entry_obj.tags ?? null) ||
(tmp_entry_obj.category_code ?? null) !== (orig_entry_obj.category_code ?? null) || const other_fields = ['tags', 'category_code', 'public', 'personal', 'professional'];
(tmp_entry_obj.public ?? false) !== (orig_entry_obj.public ?? false) || for (const field of other_fields) {
(tmp_entry_obj.personal ?? false) !== (orig_entry_obj.personal ?? false) || if (normalize(tmp_entry_obj[field]) !== normalize(orig_entry_obj[field])) return true;
(tmp_entry_obj.professional ?? false) !== (orig_entry_obj.professional ?? false); }
return false;
}); });
// *** Effects // *** Effects
@@ -99,22 +127,22 @@
} }
console.log('ae_view: Effect[Sync] update local state.'); console.log('ae_view: Effect[Sync] update local state.');
const base = { const base = deep_copy(entry);
...entry, if (base) {
content: entry.content ?? null, base.content = base.content ?? null;
history: entry.history ?? null base.history = base.history ?? null;
};
orig_entry_obj = { ...base }; orig_entry_obj = { ...base };
tmp_entry_obj = { ...base }; tmp_entry_obj = { ...base };
} }
} }
}
}); });
// 2. Auto-Save Debounce // 2. Auto-Save Debounce
$effect(() => { $effect(() => {
// Explicitly depend on content but DO NOT react to other tmp_entry_obj properties here // Dependency tracking
const _trigger = tmp_entry_obj.content; const _content = tmp_entry_obj.content;
const _trigger_priv = tmp_entry_obj.private; const _private = tmp_entry_obj.private;
if (has_unsaved_changes && !is_processing) { if (has_unsaved_changes && !is_processing) {
console.log('ae_view: Effect[AutoSave] trigger.'); console.log('ae_view: Effect[AutoSave] trigger.');
@@ -145,7 +173,7 @@
const has_encrypted_content = !!$lq__journal_entry_obj?.content_encrypted; const has_encrypted_content = !!$lq__journal_entry_obj?.content_encrypted;
if (has_encrypted_content && is_verified && decrypted_status !== true && decrypted_status !== 'processing') { if (has_encrypted_content && is_verified && decrypted_status !== true && decrypted_status !== 'processing') {
console.log('ae_view: Effect[Decryption] auto-running.'); console.log('ae_view: Auto-decrypt triggering.');
run_decryption_workflow(); run_decryption_workflow();
} }
}); });
@@ -210,9 +238,12 @@
async function update_journal_entry() { async function update_journal_entry() {
if (!$ae_loc.trusted_access || save_status === 'saving') return; if (!$ae_loc.trusted_access || save_status === 'saving') return;
// Prevent concurrent saves
if (is_processing) return;
is_processing = true;
save_status = 'saving'; save_status = 'saving';
// Reference Standard: Explicit whitelist of editable fields
const data_kv: key_val = { const data_kv: key_val = {
name: tmp_entry_obj.name, name: tmp_entry_obj.name,
content: tmp_entry_obj.content, content: tmp_entry_obj.content,
@@ -236,48 +267,45 @@
const decrypt_key = $lq__journal_obj.combined_passcode; const decrypt_key = $lq__journal_obj.combined_passcode;
// Encryption Logic Transition
if (tmp_entry_obj.private) { if (tmp_entry_obj.private) {
if (tmp_entry_obj.content) { if (tmp_entry_obj.content) {
data_kv.content_encrypted = await ae_util.encrypt_wrapper(tmp_entry_obj.content, decrypt_key); data_kv.content_encrypted = await ae_util.encrypt_wrapper(tmp_entry_obj.content, decrypt_key);
data_kv.content = null; data_kv.content = null;
} }
if (tmp_entry_obj.history) {
data_kv.history_encrypted = await ae_util.encrypt_wrapper(tmp_entry_obj.history, decrypt_key);
data_kv.history = null;
}
} else { } else {
// Ensure we clear encrypted fields if privacy is disabled
data_kv.content_encrypted = null; data_kv.content_encrypted = null;
data_kv.history_encrypted = null; data_kv.history_encrypted = null;
} }
try { try {
console.log('ae_view: Starting PATCH update...');
await journals_func.update_ae_obj__journal_entry({ await journals_func.update_ae_obj__journal_entry({
api_cfg: $ae_api, api_cfg: $ae_api,
journal_entry_id: $lq__journal_entry_obj?.journal_entry_id, journal_entry_id: $lq__journal_entry_obj?.journal_entry_id,
data_kv: data_kv, data_kv: data_kv,
log_lvl: 1 log_lvl: 1
}); });
// CRITICAL: Sync ORIG after save to clear unsaved changes
orig_entry_obj = JSON.parse(JSON.stringify(tmp_entry_obj)); // Re-sync after successful save
orig_entry_obj = deep_copy(tmp_entry_obj);
save_status = 'saved'; save_status = 'saved';
console.log('ae_view: PATCH complete and state synced.');
} catch (error) { } catch (error) {
console.error('Update failed:', error); console.error('Update failed:', error);
save_status = 'unsaved'; save_status = 'unsaved';
} finally {
is_processing = false;
} }
} }
async function handle_content_decryption() { async function handle_content_decryption() {
const journal = $lq__journal_obj; const journal = $lq__journal_obj;
const entry = $lq__journal_entry_obj; const entry = $lq__journal_entry_obj;
if (!journal?.id || !entry) return; if (!journal?.id || !entry || is_processing) return;
// If encrypted and not currently decrypted, run the decryption workflow
if (entry.content_encrypted && !tmp_entry_obj.content) { if (entry.content_encrypted && !tmp_entry_obj.content) {
await run_decryption_workflow(); await run_decryption_workflow();
} else { } else {
// Toggle logic: if already decrypted, "lock" it by clearing local state
is_processing = true; is_processing = true;
tmp_entry_obj.content = null; tmp_entry_obj.content = null;
tmp_entry_obj.history = null; tmp_entry_obj.history = null;
@@ -299,7 +327,7 @@
} }
async function change_journal_id() { async function change_journal_id() {
if (!$ae_loc.trusted_access) return; if (!$ae_loc.trusted_access || is_processing) return;
await journals_func.update_ae_obj__journal_entry({ await journals_func.update_ae_obj__journal_entry({
api_cfg: $ae_api, api_cfg: $ae_api,
journal_entry_id: $lq__journal_entry_obj?.journal_entry_id, journal_entry_id: $lq__journal_entry_obj?.journal_entry_id,
@@ -366,7 +394,7 @@
/> />
</section> </section>
<AE_MetadataFooter obj={tmp_entry_obj} /> <JournalEntry_Metadata entry={tmp_entry_obj} />
<AeCompModalJournalEntryAppend <AeCompModalJournalEntryAppend
bind:open={show_append_modal} bind:open={show_append_modal}