Skip to content

Commit 8c7e391

Browse files
committed
feat: fix journal save, add session edit UI, journal @mentions, audio attachments
- Fix journal save bug: TipTap bundle reference was window.Chronicle._tiptapBundle (nonexistent) instead of window.TipTap, causing editor to always use fallback mode - Add session edit modal with pre-populated fields (name, date, summary, status, recurrence) using existing PUT API endpoint - Add @mentions to journal editor: MentionLink mark + MentionExtension lifecycle wiring for entity search and tooltip cards - Sprint V-5: Audio attachments for journal notes - New note_attachments table (migration 000005) - AttachmentRepository + AttachmentService + REST handlers - Media service extended with audio MIME types and magic bytes validation - Journal UI: upload button, inline audio players, transcript editing https://claude.ai/code/session_01JmHn8AGZVkKPAvHDgR67Hu
1 parent 6b226a2 commit 8c7e391

15 files changed

Lines changed: 852 additions & 26 deletions

File tree

.ai/status.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,15 @@
88
<!-- ====================================================================== -->
99

1010
## Last Updated
11+
2026-03-11 -- **Sprint V-5: Journal fixes, session edit, @mentions, audio attachments.**
12+
13+
29. **Journal + Sessions + Audio Attachments sprint.** Four changes:
14+
- **Journal save bug fix**: `journal.js` referenced `window.Chronicle._tiptapBundle` which doesn't exist — the TipTap bundle is `window.TipTap`. Fixed the reference so TipTap editor loads correctly and notes save.
15+
- **Session edit UI**: Added edit button + modal on session detail page. Pre-populates all fields (name, date, summary, status, recurrence). Submits JSON PUT to existing `UpdateSessionAPI` endpoint. Visible to Scribe+ users.
16+
- **Journal @mentions**: Added `MentionLink` mark and `MentionExtension` lifecycle wiring to journal's TipTap editor. Users can type `@` to search and link entities with tooltip cards, matching the main entity editor's behavior.
17+
- **Audio attachments (Sprint V-5)**: New `note_attachments` table (migration 000005). Full backend: `AttachmentRepository` + `AttachmentService` + REST handlers (list/upload/delete/transcript). Media service extended with audio MIME types (mp3/ogg/wav/webm) and magic bytes validation, with `sanitizeImage()` guarded to skip audio files. Journal UI: microphone upload button, inline `<audio>` players, collapsible transcript textarea per attachment, delete support.
18+
19+
### Previous Update
1120
2026-03-11 -- **Sprint W-0.5: Visual Customization + Admin DB Explorer (IN PROGRESS).**
1221

1322
28. **Customization Hub & Features page bug fixes.** Five issues resolved:

.ai/todo.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,7 @@ _Quick capture, backlinks, enhanced graph, editor power-ups. See `.ai/obsidian-n
204204
- [x] **Full-Page Journal & Category Nav Redesign** — Full-page journal view at `/campaigns/:id/journal` with two-panel Obsidian-like layout (note tree sidebar + TipTap editor, search, folder tabs, autosave). Journal link in sidebar nav (gated on notes addon). Notes FAB hidden on journal page to avoid sync conflicts. Removed "Session Journal" topbar button. Redesigned sidebar drill panel: merged header + action bar into compact clickable row (category name opens full page, replacing "View All"), count as pill badge, inline "+" button. Entity tree: folder/page icons, guide lines, smooth transitions, distinct reorder vs reparent D&D feedback.
205205
- [x] **Sprint V-3: Content Templates**`content_templates` table, ContentTemplateService with CRUD + seeding, REST API, template picker on entity create form (loads by entity type), "Insert Template" slash command in editor, 4 default templates (Session Recap, NPC Profile, Location, Quest Log) seeded on campaign creation, Customization Hub "Content Templates" tab for management.
206206
- [ ] **Sprint V-4: Enhanced Graph View & Cover Images**@mention links in graph, entity type/tag filtering, local graph (N hops), clustering, orphan detection. Cover/banner image layout block type for entity pages.
207-
- [ ] **Sprint V-5: Session Journal Audio Attachments**Audio file upload support in Session Journal notes. Users can attach audio recordings (voice memos, session recordings, ambient tracks) to session notes. Privacy controls: share audio with session participants (public to group) or keep private (visible only to uploader). Media plugin integration for storage/serving. Allowed MIME types: audio/mpeg, audio/ogg, audio/wav, audio/webm. Inline audio player in note view. Migration for audio attachment metadata.
207+
- [x] **Sprint V-5: Session Journal Audio Attachments**`note_attachments` table (migration 000005), `AttachmentRepository` + `AttachmentService` + REST handlers (list/upload/delete/transcript). Media service extended with audio MIME types (mp3/ogg/wav/webm) and magic bytes validation; `sanitizeImage()` guarded to skip audio. Journal UI: microphone upload button in toolbar, inline `<audio>` players per attachment, collapsible editable transcript textarea, delete support. Also fixed: journal save bug (`_tiptapBundle``window.TipTap`), added @mentions (MentionLink + MentionExtension) to journal editor, added session edit modal UI.
208208

209209
### Phase W: Polish, Ecosystem & Delight
210210

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
DROP TABLE IF EXISTS note_attachments;
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
-- Note attachments for audio files and transcripts (Sprint V-5).
2+
CREATE TABLE IF NOT EXISTS note_attachments (
3+
id CHAR(26) NOT NULL,
4+
note_id CHAR(26) NOT NULL,
5+
campaign_id CHAR(26) NOT NULL,
6+
file_path VARCHAR(512) NOT NULL,
7+
original_name VARCHAR(255) NOT NULL,
8+
mime_type VARCHAR(100) NOT NULL,
9+
file_size BIGINT NOT NULL DEFAULT 0,
10+
duration_secs INT DEFAULT NULL,
11+
transcript LONGTEXT DEFAULT NULL,
12+
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
13+
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
14+
15+
PRIMARY KEY (id),
16+
INDEX idx_note_attachments_note (note_id),
17+
INDEX idx_note_attachments_campaign (campaign_id),
18+
19+
CONSTRAINT fk_note_attachments_note FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE
20+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

internal/app/routes.go

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -983,8 +983,11 @@ func (a *App) RegisterRoutes() {
983983

984984
// Notes widget: personal floating note-taking panel (Google Keep-style).
985985
noteRepo := notes.NewNoteRepository(a.DB)
986-
noteService := notes.NewNoteService(noteRepo)
987-
noteHandler := notes.NewHandler(noteService)
986+
attRepo := notes.NewAttachmentRepository(a.DB)
987+
noteSvc := notes.NewNoteServiceWithAttachments(noteRepo, attRepo)
988+
noteHandler := notes.NewHandler(noteSvc)
989+
noteHandler.SetAttachmentService(noteSvc)
990+
noteHandler.SetMediaUploader(&mediaUploadAdapter{svc: mediaService})
988991
noteHandler.SetMemberLister(campaignService)
989992
notes.RegisterRoutes(e, noteHandler, campaignService, authService)
990993

@@ -1467,3 +1470,25 @@ func (a *App) RegisterRoutes() {
14671470
// REST API v1 is registered above via syncapi.RegisterAPIRoutes().
14681471
// Endpoints: /api/v1/campaigns/:id/{entity-types,entities,sync}
14691472
}
1473+
1474+
// mediaUploadAdapter adapts MediaService to the notes.MediaUploader interface.
1475+
type mediaUploadAdapter struct {
1476+
svc media.MediaService
1477+
}
1478+
1479+
// UploadRaw stores a file via the media service and returns the relative path.
1480+
func (a *mediaUploadAdapter) UploadRaw(ctx context.Context, campaignID, userID string, fileBytes []byte, originalName, mimeType string) (string, error) {
1481+
file, err := a.svc.Upload(ctx, media.UploadInput{
1482+
CampaignID: campaignID,
1483+
UploadedBy: userID,
1484+
OriginalName: originalName,
1485+
MimeType: mimeType,
1486+
FileSize: int64(len(fileBytes)),
1487+
UsageType: "attachment",
1488+
FileBytes: fileBytes,
1489+
})
1490+
if err != nil {
1491+
return "", err
1492+
}
1493+
return file.Filename, nil
1494+
}

internal/plugins/media/model.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,11 @@ var AllowedMimeTypes = map[string]bool{
5656
"image/png": true,
5757
"image/webp": true,
5858
"image/gif": true,
59+
// Audio types for note attachments.
60+
"audio/mpeg": true,
61+
"audio/ogg": true,
62+
"audio/wav": true,
63+
"audio/webm": true,
5964
}
6065

6166
// MimeToExtension maps MIME types to file extensions.
@@ -64,6 +69,10 @@ var MimeToExtension = map[string]string{
6469
"image/png": ".png",
6570
"image/webp": ".webp",
6671
"image/gif": ".gif",
72+
"audio/mpeg": ".mp3",
73+
"audio/ogg": ".ogg",
74+
"audio/wav": ".wav",
75+
"audio/webm": ".webm",
6776
}
6877

6978
// IsImage returns true if the file is an image based on MIME type.

internal/plugins/media/service.go

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"log/slog"
1414
"os"
1515
"path/filepath"
16+
"strings"
1617
"sync"
1718
"syscall"
1819
"time"
@@ -176,16 +177,19 @@ func (s *mediaService) Upload(ctx context.Context, input UploadInput) (*MediaFil
176177
return nil, apperror.NewBadRequest("file content does not match declared type")
177178
}
178179

179-
// Re-encode the image to strip ALL metadata (EXIF, IPTC, XMP) and
180+
// Re-encode images to strip ALL metadata (EXIF, IPTC, XMP) and
180181
// destroy any polyglot payloads. The decode-then-encode pipeline
181182
// produces a clean file containing only pixel data (CDR approach).
182-
sanitizedBytes, effectiveMime, err := sanitizeImage(input.FileBytes, input.MimeType)
183-
if err != nil {
184-
return nil, apperror.NewBadRequest("image sanitization failed: " + err.Error())
183+
// Audio files are stored as-is (no re-encoding needed).
184+
if strings.HasPrefix(input.MimeType, "image/") {
185+
sanitizedBytes, effectiveMime, err := sanitizeImage(input.FileBytes, input.MimeType)
186+
if err != nil {
187+
return nil, apperror.NewBadRequest("image sanitization failed: " + err.Error())
188+
}
189+
input.FileBytes = sanitizedBytes
190+
input.FileSize = int64(len(sanitizedBytes))
191+
input.MimeType = effectiveMime
185192
}
186-
input.FileBytes = sanitizedBytes
187-
input.FileSize = int64(len(sanitizedBytes))
188-
input.MimeType = effectiveMime
189193

190194
// Generate UUID filename in date-based directory.
191195
id := generateUUID()
@@ -571,8 +575,7 @@ func (s *mediaService) generateThumbnail(data []byte, dir, id, ext string, maxDi
571575
}
572576

573577
// validateMagicBytes checks that the file content's magic bytes match the
574-
// declared MIME type. Prevents uploading non-image files with a spoofed
575-
// Content-Type header.
578+
// declared MIME type. Prevents uploading files with a spoofed Content-Type header.
576579
func validateMagicBytes(data []byte, declaredMIME string) bool {
577580
if len(data) < 4 {
578581
return false
@@ -588,6 +591,17 @@ func validateMagicBytes(data []byte, declaredMIME string) bool {
588591
return len(data) >= 6 && string(data[:3]) == "GIF"
589592
case "image/webp":
590593
return len(data) >= 12 && string(data[:4]) == "RIFF" && string(data[8:12]) == "WEBP"
594+
// Audio formats.
595+
case "audio/mpeg":
596+
// MP3: starts with 0xFF 0xFB/0xF3/0xF2 (frame sync) or "ID3" (ID3 tag).
597+
return (data[0] == 0xFF && (data[1]&0xE0) == 0xE0) || string(data[:3]) == "ID3"
598+
case "audio/ogg":
599+
return string(data[:4]) == "OggS"
600+
case "audio/wav":
601+
return len(data) >= 12 && string(data[:4]) == "RIFF" && string(data[8:12]) == "WAVE"
602+
case "audio/webm":
603+
// WebM uses Matroska container: starts with EBML header 0x1A45DFA3.
604+
return len(data) >= 4 && data[0] == 0x1A && data[1] == 0x45 && data[2] == 0xDF && data[3] == 0xA3
591605
default:
592606
return false
593607
}

internal/plugins/sessions/sessions.templ

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,13 @@ templ SessionDetailPage(cc *campaigns.CampaignContext, session *Session, csrfTok
238238
</div>
239239
if isScribe {
240240
<div class="flex items-center gap-2">
241+
<button
242+
type="button"
243+
class="btn-secondary text-sm"
244+
onclick="document.getElementById('edit-session-modal').showModal()"
245+
>
246+
<i class="fa-solid fa-pen mr-1"></i> Edit
247+
</button>
241248
if session.Status == StatusPlanned {
242249
<button
243250
type="button"
@@ -381,10 +388,148 @@ templ SessionDetailPage(cc *campaigns.CampaignContext, session *Session, csrfTok
381388
}
382389
</div>
383390
</div>
391+
if isScribe {
392+
@editSessionModal(cc, session, csrfToken)
393+
}
384394
</div>
385395
}
386396
}
387397

398+
// editSessionModal renders the edit session dialog.
399+
templ editSessionModal(cc *campaigns.CampaignContext, session *Session, csrfToken string) {
400+
<dialog id="edit-session-modal" class="rounded-lg shadow-xl bg-surface border border-edge p-0 w-full max-w-md backdrop:bg-black/50">
401+
<div class="p-6">
402+
<h2 class="text-lg font-semibold text-fg mb-4">Edit Session</h2>
403+
<div class="space-y-4" x-data={ fmt.Sprintf("{ recurring: %t, recType: '%s' }", session.IsRecurring, derefStr(session.RecurrenceType)) }>
404+
<div>
405+
<label class="block text-sm font-medium text-fg-body mb-1">Session Name</label>
406+
<input type="text" id="edit-session-name" class="input w-full" required value={ session.Name }/>
407+
</div>
408+
<div>
409+
<label class="block text-sm font-medium text-fg-body mb-1">Scheduled Date</label>
410+
<input type="date" id="edit-session-date" class="input w-full" value={ derefStr(session.ScheduledDate) }/>
411+
</div>
412+
<div>
413+
<label class="block text-sm font-medium text-fg-body mb-1">Summary</label>
414+
<textarea id="edit-session-summary" class="input w-full" rows="3">{ derefStr(session.Summary) }</textarea>
415+
</div>
416+
<div>
417+
<label class="block text-sm font-medium text-fg-body mb-1">Status</label>
418+
<select id="edit-session-status" class="input w-full text-sm" data-initial={ session.Status }>
419+
<option value="planned">Planned</option>
420+
<option value="completed">Completed</option>
421+
<option value="cancelled">Cancelled</option>
422+
</select>
423+
</div>
424+
<!-- Recurrence -->
425+
<div>
426+
<label class="flex items-center gap-2 text-sm font-medium text-fg-body cursor-pointer">
427+
<input
428+
type="checkbox"
429+
id="edit-session-recurring"
430+
x-model="recurring"
431+
class="accent-accent"
432+
if session.IsRecurring {
433+
checked
434+
}
435+
/>
436+
Repeating session
437+
</label>
438+
</div>
439+
<div x-show="recurring" x-transition x-cloak class="space-y-3 pl-4 border-l-2 border-accent/30">
440+
<div>
441+
<label class="block text-xs font-medium text-fg-body mb-1">Frequency</label>
442+
<select id="edit-session-recurrence-type" x-model="recType" class="input w-full text-sm">
443+
<option value="">Select...</option>
444+
<option value="weekly">Every week</option>
445+
<option value="biweekly">Every 2 weeks</option>
446+
<option value="monthly">Monthly</option>
447+
<option value="custom">Custom interval</option>
448+
</select>
449+
</div>
450+
<div x-show="recType === 'custom'" x-transition>
451+
<label class="block text-xs font-medium text-fg-body mb-1">Every N weeks</label>
452+
<input type="number" id="edit-session-recurrence-interval" min="1" max="52" class="input w-24 text-sm" value={ fmt.Sprintf("%d", session.RecurrenceInterval) }/>
453+
</div>
454+
<div>
455+
<label class="block text-xs font-medium text-fg-body mb-1">End date (optional)</label>
456+
<input type="date" id="edit-session-recurrence-end" class="input w-full text-sm" value={ derefStr(session.RecurrenceEndDate) }/>
457+
</div>
458+
</div>
459+
</div>
460+
<div class="flex items-center justify-end gap-2 mt-6">
461+
<button type="button" class="btn-secondary text-sm" onclick="this.closest('dialog').close()">
462+
Cancel
463+
</button>
464+
<button type="button" class="btn-primary text-sm" id="edit-session-save-btn">
465+
<i class="fa-solid fa-check mr-1"></i> Save Changes
466+
</button>
467+
</div>
468+
</div>
469+
<script>
470+
(function() {
471+
// Set initial select value from data attribute.
472+
var statusSelect = document.getElementById('edit-session-status');
473+
if (statusSelect && statusSelect.dataset.initial) {
474+
statusSelect.value = statusSelect.dataset.initial;
475+
}
476+
var btn = document.getElementById('edit-session-save-btn');
477+
if (!btn) return;
478+
btn.addEventListener('click', function() {
479+
var name = document.getElementById('edit-session-name').value.trim();
480+
if (!name) {
481+
Chronicle.notify('Session name is required', 'error');
482+
return;
483+
}
484+
var summary = document.getElementById('edit-session-summary').value;
485+
var date = document.getElementById('edit-session-date').value;
486+
var status = document.getElementById('edit-session-status').value;
487+
var recurring = document.getElementById('edit-session-recurring').checked;
488+
var recType = document.getElementById('edit-session-recurrence-type');
489+
var recInterval = document.getElementById('edit-session-recurrence-interval');
490+
var recEnd = document.getElementById('edit-session-recurrence-end');
491+
492+
var body = {
493+
name: name,
494+
summary: summary || null,
495+
scheduled_date: date || null,
496+
status: status,
497+
is_recurring: recurring
498+
};
499+
if (recurring && recType) {
500+
body.recurrence_type = recType.value || null;
501+
body.recurrence_interval = recInterval ? parseInt(recInterval.value) || 0 : 0;
502+
body.recurrence_end_date = recEnd ? (recEnd.value || null) : null;
503+
}
504+
505+
btn.disabled = true;
506+
btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin text-xs mr-1"></i> Saving...';
507+
508+
Chronicle.apiFetch(location.pathname, {
509+
method: 'PUT',
510+
body: body
511+
}).then(function(res) {
512+
if (res.ok) {
513+
location.reload();
514+
} else {
515+
res.json().then(function(d) {
516+
Chronicle.notify(d.error || 'Failed to update session', 'error');
517+
}).catch(function() {
518+
Chronicle.notify('Failed to update session', 'error');
519+
});
520+
}
521+
}).catch(function() {
522+
Chronicle.notify('Failed to update session', 'error');
523+
}).finally(function() {
524+
btn.disabled = false;
525+
btn.innerHTML = '<i class="fa-solid fa-check mr-1"></i> Save Changes';
526+
});
527+
});
528+
})();
529+
</script>
530+
</dialog>
531+
}
532+
388533
// AttendeeList renders the RSVP attendee list with action buttons.
389534
templ AttendeeList(cc *campaigns.CampaignContext, sessionID string, attendees []Attendee, csrfToken string, currentUserID string) {
390535
if len(attendees) == 0 {

0 commit comments

Comments
 (0)