|
6 | 6 | "encoding/json" |
7 | 7 | "errors" |
8 | 8 | "fmt" |
| 9 | + "regexp" |
9 | 10 | "strings" |
10 | 11 |
|
11 | 12 | "github.com/keyxmakerx/chronicle/internal/apperror" |
@@ -430,6 +431,7 @@ type EntityRepository interface { |
430 | 431 | UpdateFields(ctx context.Context, id string, fieldsData map[string]any) error |
431 | 432 | UpdateFieldOverrides(ctx context.Context, id string, overrides *FieldOverrides) error |
432 | 433 | UpdateImage(ctx context.Context, id, imagePath string) error |
| 434 | + UpdateCoverImage(ctx context.Context, id, coverImagePath string) error |
433 | 435 | Delete(ctx context.Context, id string) error |
434 | 436 | SlugExists(ctx context.Context, campaignID, slug string) (bool, error) |
435 | 437 |
|
@@ -481,6 +483,11 @@ type EntityRepository interface { |
481 | 483 | // SetAliases replaces all aliases for an entity with the given list. |
482 | 484 | // Deletes existing aliases and inserts new ones in a single transaction. |
483 | 485 | SetAliases(ctx context.Context, entityID string, aliases []string) error |
| 486 | + |
| 487 | + // FindAllMentionLinks returns all @mention references across a campaign by |
| 488 | + // scanning entry_html for data-mention-id attributes. Each result is a |
| 489 | + // source→target pair. Used by the graph visualization to show mention edges. |
| 490 | + FindAllMentionLinks(ctx context.Context, campaignID string, role int, userID string) ([]MentionLink, error) |
484 | 491 | } |
485 | 492 |
|
486 | 493 | // entityRepository implements EntityRepository with MariaDB queries. |
@@ -519,7 +526,7 @@ func (r *entityRepository) Create(ctx context.Context, entity *Entity) error { |
519 | 526 |
|
520 | 527 | // entitySelectColumns is the standard column list for entity queries with joined type info. |
521 | 528 | const entitySelectColumns = `e.id, e.campaign_id, e.entity_type_id, e.name, e.slug, |
522 | | - e.entry, e.entry_html, e.image_path, e.parent_id, e.sort_order, e.type_label, |
| 529 | + e.entry, e.entry_html, e.image_path, e.cover_image_path, e.parent_id, e.sort_order, e.type_label, |
523 | 530 | e.is_private, e.visibility, e.is_template, e.fields_data, e.field_overrides, e.popup_config, |
524 | 531 | e.created_by, e.created_at, e.updated_at, |
525 | 532 | et.name, et.icon, et.color, et.slug` |
@@ -551,7 +558,7 @@ func (r *entityRepository) scanEntity(row *sql.Row) (*Entity, error) { |
551 | 558 | var fieldsRaw, overridesRaw, popupRaw []byte |
552 | 559 | err := row.Scan( |
553 | 560 | &e.ID, &e.CampaignID, &e.EntityTypeID, &e.Name, &e.Slug, |
554 | | - &e.Entry, &e.EntryHTML, &e.ImagePath, &e.ParentID, &e.SortOrder, &e.TypeLabel, |
| 561 | + &e.Entry, &e.EntryHTML, &e.ImagePath, &e.CoverImagePath, &e.ParentID, &e.SortOrder, &e.TypeLabel, |
555 | 562 | &e.IsPrivate, &e.Visibility, &e.IsTemplate, &fieldsRaw, &overridesRaw, &popupRaw, |
556 | 563 | &e.CreatedBy, &e.CreatedAt, &e.UpdatedAt, |
557 | 564 | &e.TypeName, &e.TypeIcon, &e.TypeColor, &e.TypeSlug, |
@@ -711,6 +718,30 @@ func (r *entityRepository) UpdateImage(ctx context.Context, id, imagePath string |
711 | 718 | return nil |
712 | 719 | } |
713 | 720 |
|
| 721 | +// UpdateCoverImage updates only the cover_image_path for an entity. |
| 722 | +// Used by the cover image upload API. |
| 723 | +func (r *entityRepository) UpdateCoverImage(ctx context.Context, id, coverImagePath string) error { |
| 724 | + var val any |
| 725 | + if coverImagePath != "" { |
| 726 | + val = coverImagePath |
| 727 | + } |
| 728 | + |
| 729 | + query := `UPDATE entities SET cover_image_path = ?, updated_at = NOW() WHERE id = ?` |
| 730 | + result, err := r.db.ExecContext(ctx, query, val, id) |
| 731 | + if err != nil { |
| 732 | + return fmt.Errorf("updating entity cover image: %w", err) |
| 733 | + } |
| 734 | + |
| 735 | + rows, err := result.RowsAffected() |
| 736 | + if err != nil { |
| 737 | + return fmt.Errorf("checking rows affected: %w", err) |
| 738 | + } |
| 739 | + if rows == 0 { |
| 740 | + return apperror.NewNotFound("entity not found") |
| 741 | + } |
| 742 | + return nil |
| 743 | +} |
| 744 | + |
714 | 745 | // Delete removes an entity. |
715 | 746 | func (r *entityRepository) Delete(ctx context.Context, id string) error { |
716 | 747 | result, err := r.db.ExecContext(ctx, `DELETE FROM entities WHERE id = ?`, id) |
@@ -1152,7 +1183,7 @@ func (r *entityRepository) scanEntityRow(rows *sql.Rows) (*Entity, error) { |
1152 | 1183 | var fieldsRaw, overridesRaw, popupRaw []byte |
1153 | 1184 | err := rows.Scan( |
1154 | 1185 | &e.ID, &e.CampaignID, &e.EntityTypeID, &e.Name, &e.Slug, |
1155 | | - &e.Entry, &e.EntryHTML, &e.ImagePath, &e.ParentID, &e.SortOrder, &e.TypeLabel, |
| 1186 | + &e.Entry, &e.EntryHTML, &e.ImagePath, &e.CoverImagePath, &e.ParentID, &e.SortOrder, &e.TypeLabel, |
1156 | 1187 | &e.IsPrivate, &e.Visibility, &e.IsTemplate, &fieldsRaw, &overridesRaw, &popupRaw, |
1157 | 1188 | &e.CreatedBy, &e.CreatedAt, &e.UpdatedAt, |
1158 | 1189 | &e.TypeName, &e.TypeIcon, &e.TypeColor, &e.TypeSlug, |
@@ -1445,3 +1476,55 @@ func (r *entityRepository) SetAliases(ctx context.Context, entityID string, alia |
1445 | 1476 |
|
1446 | 1477 | return tx.Commit() |
1447 | 1478 | } |
| 1479 | + |
| 1480 | +// mentionIDPattern matches data-mention-id="<uuid>" attributes in entry_html. |
| 1481 | +var mentionIDPattern = regexp.MustCompile(`data-mention-id="([a-f0-9-]+)"`) |
| 1482 | + |
| 1483 | +// FindAllMentionLinks scans all entities in a campaign for @mention references |
| 1484 | +// in their entry_html and returns source→target pairs. Respects visibility |
| 1485 | +// filtering so players only see links from/to entities they can access. |
| 1486 | +func (r *entityRepository) FindAllMentionLinks(ctx context.Context, campaignID string, role int, userID string) ([]MentionLink, error) { |
| 1487 | + where := `WHERE e.campaign_id = ? AND e.entry_html LIKE '%data-mention-id=%'` |
| 1488 | + args := []any{campaignID} |
| 1489 | + |
| 1490 | + visFilter, visArgs := visibilityFilter(role, userID) |
| 1491 | + where += visFilter |
| 1492 | + args = append(args, visArgs...) |
| 1493 | + |
| 1494 | + query := fmt.Sprintf(`SELECT e.id, e.entry_html FROM entities e %s`, where) |
| 1495 | + |
| 1496 | + rows, err := r.db.QueryContext(ctx, query, args...) |
| 1497 | + if err != nil { |
| 1498 | + return nil, fmt.Errorf("finding mention links: %w", err) |
| 1499 | + } |
| 1500 | + defer rows.Close() |
| 1501 | + |
| 1502 | + var links []MentionLink |
| 1503 | + for rows.Next() { |
| 1504 | + var sourceID string |
| 1505 | + var entryHTML *string |
| 1506 | + if err := rows.Scan(&sourceID, &entryHTML); err != nil { |
| 1507 | + return nil, fmt.Errorf("scanning mention row: %w", err) |
| 1508 | + } |
| 1509 | + if entryHTML == nil { |
| 1510 | + continue |
| 1511 | + } |
| 1512 | + |
| 1513 | + // Extract all mention target IDs from the HTML. |
| 1514 | + matches := mentionIDPattern.FindAllStringSubmatch(*entryHTML, -1) |
| 1515 | + seen := make(map[string]bool) |
| 1516 | + for _, match := range matches { |
| 1517 | + targetID := match[1] |
| 1518 | + // Skip self-mentions and dedup within same entity. |
| 1519 | + if targetID == sourceID || seen[targetID] { |
| 1520 | + continue |
| 1521 | + } |
| 1522 | + seen[targetID] = true |
| 1523 | + links = append(links, MentionLink{ |
| 1524 | + SourceEntityID: sourceID, |
| 1525 | + TargetEntityID: targetID, |
| 1526 | + }) |
| 1527 | + } |
| 1528 | + } |
| 1529 | + return links, rows.Err() |
| 1530 | +} |
0 commit comments