feat(tickets): Kurz-Verweise (Shortlinks) im Ticket-Text auflösen
Die KI kann im Text `tier:<8hex>` / `kontakt:` / `wurf:` / `gehege:` schreiben; Frontend löst per POST /refs/resolve (Präfix-Match, DB-agnostisch) zu Name + internem Link auf. Reine KI-Schreibhilfe — kein Nutzer-Button. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
123
GerbilManager.Tests/RefsEndpointTests.cs
Normal file
123
GerbilManager.Tests/RefsEndpointTests.cs
Normal file
@@ -0,0 +1,123 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// REFS: POST /refs/resolve löst getippte Kurz-Verweise (tier|kontakt|wurf|gehege) auf.
|
||||
/// - exakter (volle GUID) und kurzer (Präfix) Treffer je Typ
|
||||
/// - mehrdeutiges Präfix (mehrere Treffer) => id=null (nicht geraten)
|
||||
/// - unbekannter Code / unbekannter Typ => id=null
|
||||
/// Entitäten werden mit deterministischen GUIDs direkt in die SQLite-Test-DB geseedet,
|
||||
/// damit Präfixe und Mehrdeutigkeit kontrolliert getestet werden können.
|
||||
/// </summary>
|
||||
public class RefsEndpointTests : IClassFixture<ApiFactory>
|
||||
{
|
||||
private readonly ApiFactory _factory;
|
||||
public RefsEndpointTests(ApiFactory factory) => _factory = factory;
|
||||
|
||||
// Deterministische GUIDs: zwei Tiere mit GEMEINSAMEM 6-Hex-Präfix "abcdef" (mehrdeutig),
|
||||
// sowie je eine eindeutige Entität pro Typ.
|
||||
private static readonly Guid TierA = new("abcdef00-0000-0000-0000-000000000001");
|
||||
private static readonly Guid TierB = new("abcdef00-0000-0000-0000-000000000002");
|
||||
private static readonly Guid Kontakt = new("11112222-3333-4444-5555-666677778888");
|
||||
private static readonly Guid Wurf = new("99990000-aaaa-bbbb-cccc-ddddeeeeffff");
|
||||
private static readonly Guid Gehege = new("12345678-9abc-def0-1234-567890abcdef");
|
||||
|
||||
private async Task SeedAsync()
|
||||
{
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationContext>();
|
||||
if (db.Gerbils.Any(g => g.Id == TierA)) return; // einmalig (geteilte Fixture)
|
||||
|
||||
db.Gerbils.Add(new Gerbil { Id = TierA, Name = "Krümel", Gender = Gender.female });
|
||||
db.Gerbils.Add(new Gerbil { Id = TierB, Name = "Möhre", Gender = Gender.male });
|
||||
db.Contacts.Add(new Contact { Id = Kontakt, Name = "Erika Musterfrau" });
|
||||
db.Litters.Add(new Litter { Id = Wurf, Name = "Wurf X", Date = new DateOnly(2026, 1, 1) });
|
||||
db.Enclosures.Add(new Enclosure { Id = Gehege, Name = "Terrarium 1" });
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private async Task<List<JsonElement>> Resolve(params object[] refs)
|
||||
{
|
||||
var resp = await _factory.CreateClient()
|
||||
.PostAsJsonAsync("/refs/resolve", new { refs });
|
||||
resp.EnsureSuccessStatusCode();
|
||||
var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync());
|
||||
return doc.RootElement.EnumerateArray().ToList();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Resolves_full_guid_per_type()
|
||||
{
|
||||
await SeedAsync();
|
||||
var rows = await Resolve(
|
||||
new { type = "tier", code = TierA.ToString() },
|
||||
new { type = "kontakt", code = Kontakt.ToString() },
|
||||
new { type = "wurf", code = Wurf.ToString() },
|
||||
new { type = "gehege", code = Gehege.ToString() });
|
||||
|
||||
Assert.Equal(TierA.ToString(), rows[0].GetProperty("id").GetString());
|
||||
Assert.Equal("Krümel", rows[0].GetProperty("name").GetString());
|
||||
Assert.Equal(Kontakt.ToString(), rows[1].GetProperty("id").GetString());
|
||||
Assert.Equal("Erika Musterfrau", rows[1].GetProperty("name").GetString());
|
||||
Assert.Equal(Wurf.ToString(), rows[2].GetProperty("id").GetString());
|
||||
Assert.Equal("Wurf X", rows[2].GetProperty("name").GetString());
|
||||
Assert.Equal(Gehege.ToString(), rows[3].GetProperty("id").GetString());
|
||||
Assert.Equal("Terrarium 1", rows[3].GetProperty("name").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Resolves_short_prefix_when_unique()
|
||||
{
|
||||
await SeedAsync();
|
||||
// 8-Hex-Kurz-ID des Kontakts ("11112222") ist eindeutig.
|
||||
var rows = await Resolve(new { type = "kontakt", code = "11112222" });
|
||||
Assert.Equal(Kontakt.ToString(), rows[0].GetProperty("id").GetString());
|
||||
Assert.Equal("Erika Musterfrau", rows[0].GetProperty("name").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tolerates_dashes_and_case_in_code()
|
||||
{
|
||||
await SeedAsync();
|
||||
// Gemischte Schreibweise + Bindestriche im Kurz-Code des Geheges.
|
||||
var rows = await Resolve(new { type = "gehege", code = "1234-5678" });
|
||||
Assert.Equal(Gehege.ToString(), rows[0].GetProperty("id").GetString());
|
||||
Assert.Equal("Terrarium 1", rows[0].GetProperty("name").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ambiguous_prefix_resolves_to_null()
|
||||
{
|
||||
await SeedAsync();
|
||||
// "abcdef" trifft TierA UND TierB => mehrdeutig => id/name null.
|
||||
var rows = await Resolve(new { type = "tier", code = "abcdef" });
|
||||
Assert.Equal(JsonValueKind.Null, rows[0].GetProperty("id").ValueKind);
|
||||
Assert.Equal(JsonValueKind.Null, rows[0].GetProperty("name").ValueKind);
|
||||
Assert.Equal("tier", rows[0].GetProperty("type").GetString());
|
||||
Assert.Equal("abcdef", rows[0].GetProperty("code").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unknown_code_and_unknown_type_resolve_to_null()
|
||||
{
|
||||
await SeedAsync();
|
||||
var rows = await Resolve(
|
||||
new { type = "tier", code = "fffffffe" }, // unbekannter Code
|
||||
new { type = "schubladen", code = "11112222" }); // unbekannter Typ
|
||||
Assert.Equal(JsonValueKind.Null, rows[0].GetProperty("id").ValueKind);
|
||||
Assert.Equal(JsonValueKind.Null, rows[1].GetProperty("id").ValueKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Too_short_code_resolves_to_null()
|
||||
{
|
||||
await SeedAsync();
|
||||
// < 6 Hex-Zeichen wird nicht aufgelöst (auch wenn es eindeutig wäre).
|
||||
var rows = await Resolve(new { type = "kontakt", code = "1111" });
|
||||
Assert.Equal(JsonValueKind.Null, rows[0].GetProperty("id").ValueKind);
|
||||
}
|
||||
}
|
||||
19
GerbilManagerWebAPI/Dtos/RefsDtos.cs
Normal file
19
GerbilManagerWebAPI/Dtos/RefsDtos.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
namespace GerbilManagerWebAPI.Dtos
|
||||
{
|
||||
/// <summary>
|
||||
/// REFS: ein einzelner aufzulösender Kurz-Verweis (Shortlink, git-Stil) aus einem
|
||||
/// Freitext. <c>Type</c> ist "tier" | "kontakt" | "wurf" | "gehege"; <c>Code</c> ist
|
||||
/// ein GUID-Präfix (mind. 6 Hex-Zeichen) ODER die volle GUID — Bindestrich-tolerant.
|
||||
/// </summary>
|
||||
public record RefRequest(string Type, string Code);
|
||||
|
||||
/// <summary>REFS: Body von POST /refs/resolve.</summary>
|
||||
public record ResolveRefsInput(List<RefRequest> Refs);
|
||||
|
||||
/// <summary>
|
||||
/// REFS: Auflösungsergebnis. <c>Id</c>/<c>Name</c> sind null, wenn der Code unbekannt
|
||||
/// ODER mehrdeutig ist (mehrere Präfix-Treffer) — es wird bewusst nicht geraten.
|
||||
/// <c>Type</c>/<c>Code</c> werden gespiegelt, damit der Client zuordnen kann.
|
||||
/// </summary>
|
||||
public record RefResolution(string Type, string Code, Guid? Id, string? Name);
|
||||
}
|
||||
107
GerbilManagerWebAPI/Endpoints/RefsEndpoints.cs
Normal file
107
GerbilManagerWebAPI/Endpoints/RefsEndpoints.cs
Normal file
@@ -0,0 +1,107 @@
|
||||
using GerbilManagerWebAPI.Dtos;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GerbilManagerWebAPI.Endpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// REFS: Auflöser für getippte Kurz-Verweise (Shortlinks, git-Stil) in Freitexten.
|
||||
/// Tokens im Text: <c>tier:<code></c>, <c>kontakt:<code></c>,
|
||||
/// <c>wurf:<code></c>, <c>gehege:<code></c> — <c><code></c> ist ein
|
||||
/// GUID-Präfix (mind. 6 Hex) oder die volle GUID. Das Frontend rendert daraus
|
||||
/// Name + interner Link.
|
||||
///
|
||||
/// POST /refs/resolve -> [{ type, code, id|null, name|null }]
|
||||
///
|
||||
/// DB-AGNOSTISCH (Tests: SQLite in-memory, Prod: Postgres): pro Typ eine schlanke
|
||||
/// {Id,Name}-Projektion laden und im Speicher per Präfix matchen — KEIN ::text-Cast
|
||||
/// o.ä. Mehrdeutiges Präfix (mehrere Treffer) => id=null (nicht raten).
|
||||
/// </summary>
|
||||
public static class RefsEndpoints
|
||||
{
|
||||
/// <summary>Schlanke {Id,Name}-Projektion für den In-Memory-Präfix-Match.</summary>
|
||||
private readonly record struct NamedRow(Guid Id, string Name);
|
||||
|
||||
public static IEndpointRouteBuilder MapRefsEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/refs").WithTags("Refs");
|
||||
|
||||
group.MapPost("/resolve", async Task<Ok<List<RefResolution>>> (
|
||||
ResolveRefsInput input, ApplicationContext db) =>
|
||||
{
|
||||
var refs = input.Refs ?? new List<RefRequest>();
|
||||
|
||||
// Welche Entitätstypen werden überhaupt angefragt? Nur diese Tabellen laden.
|
||||
var wantedTypes = refs
|
||||
.Select(r => NormalizeType(r.Type))
|
||||
.Where(t => t is not null)
|
||||
.Distinct()
|
||||
.ToHashSet();
|
||||
|
||||
// Pro Typ einmalig eine leichte {Id,Name}-Projektion laden (DB-agnostisch:
|
||||
// reine Spaltenauswahl, kein ::text-Cast — läuft auf SQLite wie auf Postgres).
|
||||
var byType = new Dictionary<string, List<NamedRow>>();
|
||||
if (wantedTypes.Contains("tier"))
|
||||
byType["tier"] = await db.Gerbils.AsNoTracking()
|
||||
.Select(x => new NamedRow(x.Id, x.Name)).ToListAsync();
|
||||
if (wantedTypes.Contains("kontakt"))
|
||||
byType["kontakt"] = await db.Contacts.AsNoTracking()
|
||||
.Select(x => new NamedRow(x.Id, x.Name)).ToListAsync();
|
||||
if (wantedTypes.Contains("wurf"))
|
||||
byType["wurf"] = await db.Litters.AsNoTracking()
|
||||
.Select(x => new NamedRow(x.Id, x.Name)).ToListAsync();
|
||||
if (wantedTypes.Contains("gehege"))
|
||||
byType["gehege"] = await db.Enclosures.AsNoTracking()
|
||||
.Select(x => new NamedRow(x.Id, x.Name)).ToListAsync();
|
||||
|
||||
var results = new List<RefResolution>(refs.Count);
|
||||
foreach (var r in refs)
|
||||
{
|
||||
var type = NormalizeType(r.Type);
|
||||
var prefix = NormalizeCode(r.Code);
|
||||
|
||||
if (type is null || prefix.Length < 6 || !byType.TryGetValue(type, out var rows))
|
||||
{
|
||||
results.Add(new RefResolution(r.Type, r.Code, null, null));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Präfix-Match auf der bindestrich-losen, klein geschriebenen GUID ("N"-Format).
|
||||
var matches = rows
|
||||
.Where(x => x.Id.ToString("N").StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||
.Take(2)
|
||||
.ToList();
|
||||
|
||||
// Genau ein Treffer => auflösen; 0 (unbekannt) oder >1 (mehrdeutig) => null.
|
||||
if (matches.Count == 1)
|
||||
results.Add(new RefResolution(r.Type, r.Code, matches[0].Id, matches[0].Name));
|
||||
else
|
||||
results.Add(new RefResolution(r.Type, r.Code, null, null));
|
||||
}
|
||||
|
||||
return TypedResults.Ok(results);
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
/// <summary>Akzeptiert die vier Typ-Schlüssel (case-insensitive); sonst null.</summary>
|
||||
private static string? NormalizeType(string? type) => type?.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"tier" => "tier",
|
||||
"kontakt" => "kontakt",
|
||||
"wurf" => "wurf",
|
||||
"gehege" => "gehege",
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// <summary>GUID-Code bindestrich-tolerant + Hex-only normalisieren (Kleinschreibung).</summary>
|
||||
private static string NormalizeCode(string? code)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(code)) return string.Empty;
|
||||
var chars = code.Trim().ToLowerInvariant()
|
||||
.Where(c => (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'));
|
||||
return new string(chars.ToArray());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -132,6 +132,7 @@ app.MapSaleReservationEndpoints();
|
||||
app.MapWaitingListEndpoints();
|
||||
app.MapReturnRecordEndpoints();
|
||||
app.MapExhibitionEndpoints();
|
||||
app.MapRefsEndpoints();
|
||||
|
||||
app.Run();
|
||||
|
||||
|
||||
@@ -795,6 +795,34 @@ export async function installMockApi(page: Page): Promise<MockDb> {
|
||||
return json(route, 405)
|
||||
}
|
||||
|
||||
// REFS: getippte Kurz-Verweise (Shortlinks) auflösen. Spiegelt das Backend:
|
||||
// Präfix-Match (>=6 Hex, bindestrich-tolerant) auf der bindestrich-losen Id;
|
||||
// mehrdeutig/unbekannt => id/name null.
|
||||
if (path === '/refs/resolve' && method === 'POST') {
|
||||
const body = request.postDataJSON() as { refs?: { type?: string; code?: string }[] }
|
||||
const tableFor: Record<string, Row[]> = {
|
||||
tier: db.gerbils as unknown as Row[],
|
||||
kontakt: db.contacts as unknown as Row[],
|
||||
wurf: db.litters as unknown as Row[],
|
||||
gehege: db.enclosures as unknown as Row[],
|
||||
}
|
||||
const normCode = (code: string) => (code ?? '').toLowerCase().replace(/[^0-9a-f]/g, '')
|
||||
const results = (body.refs ?? []).map((r) => {
|
||||
const type = (r.type ?? '').toLowerCase()
|
||||
const prefix = normCode(r.code ?? '')
|
||||
const rows = tableFor[type]
|
||||
if (!rows || prefix.length < 6) return { type: r.type, code: r.code, id: null, name: null }
|
||||
const matches = rows.filter((row) =>
|
||||
String(row.id ?? '').toLowerCase().replace(/-/g, '').startsWith(prefix),
|
||||
)
|
||||
if (matches.length === 1) {
|
||||
return { type: r.type, code: r.code, id: String(matches[0].id), name: String(matches[0].name ?? '') }
|
||||
}
|
||||
return { type: r.type, code: r.code, id: null, name: null }
|
||||
})
|
||||
return json(route, 200, results)
|
||||
}
|
||||
|
||||
// Generische Kollektionen: /<resource> und /<resource>/<id>
|
||||
m = path.match(/^\/([a-z-]+)(?:\/([^/]+))?$/)
|
||||
const col = m ? collections[m[1]] : undefined
|
||||
|
||||
51
gerbil-manager-web/src/api/refs.ts
Normal file
51
gerbil-manager-web/src/api/refs.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* REFS: API-Client für getippte Kurz-Verweise (Shortlinks, git-Stil) in Freitexten.
|
||||
*
|
||||
* Tokens im Text: `tier:<code>`, `kontakt:<code>`, `wurf:<code>`, `gehege:<code>` —
|
||||
* `<code>` ist ein GUID-Präfix (mind. 6 Hex) oder die volle GUID. Das Backend löst
|
||||
* sie zu { id, name } auf (mehrdeutig/unbekannt => id=null); das Frontend rendert
|
||||
* daraus Name + internen Link zur jeweiligen Detailseite.
|
||||
*/
|
||||
import { api } from './client'
|
||||
|
||||
/** Die vier auflösbaren Entitätstypen (Token-Präfix vor dem ":"). */
|
||||
export type RefType = 'tier' | 'kontakt' | 'wurf' | 'gehege'
|
||||
|
||||
export interface RefRequest {
|
||||
type: RefType
|
||||
code: string
|
||||
}
|
||||
|
||||
export interface RefResolution {
|
||||
type: string
|
||||
code: string
|
||||
/** Volle GUID oder null (unbekannt ODER mehrdeutig — nicht geraten). */
|
||||
id: string | null
|
||||
name: string | null
|
||||
}
|
||||
|
||||
/** Routen-Präfix je Typ für den internen Link (siehe App.tsx). */
|
||||
export const REF_ROUTE: Record<RefType, string> = {
|
||||
tier: '/rennmaeuse',
|
||||
kontakt: '/kontakte',
|
||||
wurf: '/wuerfe',
|
||||
gehege: '/gehege',
|
||||
}
|
||||
// Hinweis: Frontend-Routen weichen vom Token-Namen ab (wurf→/wuerfe), daher die Map.
|
||||
|
||||
/** Token-Regex: `tier|kontakt|wurf|gehege` ":" gefolgt von >=6 Hex (oder voller GUID mit "-"). */
|
||||
export const REF_TOKEN_RE = /\b(tier|kontakt|wurf|gehege):([0-9a-fA-F]{6,}(?:-[0-9a-fA-F-]+)?)/g
|
||||
|
||||
/** Erste 8 Hex-Stellen (erster GUID-Block) als Kurz-ID — wie eine kurze Git-Commit-ID. */
|
||||
export function shortCode(id: string): string {
|
||||
return id.replace(/-/g, '').slice(0, 8)
|
||||
}
|
||||
|
||||
/** Vollständiges Token für die Zwischenablage, z. B. "tier:3c5b747b". */
|
||||
export function shortToken(type: RefType, id: string): string {
|
||||
return `${type}:${shortCode(id)}`
|
||||
}
|
||||
|
||||
export function resolveRefs(refs: RefRequest[]): Promise<RefResolution[]> {
|
||||
return api.post<RefResolution[]>('/refs/resolve', { refs })
|
||||
}
|
||||
@@ -22,21 +22,32 @@ import {
|
||||
type FeedbackTicket,
|
||||
} from '../api/feedback'
|
||||
import { getGerbil } from '../api/gerbils'
|
||||
import { REF_ROUTE, REF_TOKEN_RE, resolveRefs, type RefType } from '../api/refs'
|
||||
import { useApi } from '../hooks/useApi'
|
||||
import { useToast } from '../components/toast'
|
||||
import './tickets.css'
|
||||
|
||||
/** Aufgelöster Kurz-Verweis (Shortlink): null = unbekannt/mehrdeutig. */
|
||||
type ResolvedRef = { id: string; name: string } | null
|
||||
|
||||
/**
|
||||
* Nachschlage-Kontext für Ticket-Text-Links, damit RichText nicht durch jede Ebene
|
||||
* durchgereicht werden muss:
|
||||
* - ticketIds: bekannte Ticket-IDs (nackte GUID → Link auf das Ticket).
|
||||
* - gerbilNames: aufgelöste Tier-Namen je GerbilId (nackte GUID → Name + Link zur Akte).
|
||||
* Wert `undefined` = noch nicht geladen, `null` = nicht gefunden.
|
||||
* - refNames: aufgelöste Kurz-Verweise je `typ:code` (die KI kann `tier:3c5b747b` schreiben
|
||||
* → Name + Link). Wert `undefined` = noch nicht geladen, `null` = unbekannt/mehrdeutig.
|
||||
*/
|
||||
const TicketRefsContext = createContext<{
|
||||
ticketIds: Set<string>
|
||||
gerbilNames: Record<string, string | null>
|
||||
}>({ ticketIds: new Set(), gerbilNames: {} })
|
||||
refNames: Record<string, ResolvedRef>
|
||||
}>({ ticketIds: new Set(), gerbilNames: {}, refNames: {} })
|
||||
|
||||
/** Cache-Schlüssel für einen Kurz-Verweis. */
|
||||
function refKey(type: string, code: string): string {
|
||||
return `${type.toLowerCase()}:${code.replace(/-/g, '').toLowerCase()}`
|
||||
}
|
||||
|
||||
const t = de.feedback.tickets
|
||||
const ctx = de.feedback.contexts
|
||||
@@ -112,11 +123,13 @@ function TextWithBreaks({ value }: { value: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
/** Inline-Markdown: **fett**, `code`, [Label](url)-Links, Tier-/Ticket-IDs, Zeilenumbrüche. */
|
||||
/** Inline-Markdown: **fett**, `code`, [Label](url)-Links, Kurz-Verweise (tier:…), IDs, Umbrüche. */
|
||||
function InlineMarkdown({ text }: { text: string }) {
|
||||
const { ticketIds, gerbilNames } = useContext(TicketRefsContext)
|
||||
const { ticketIds, gerbilNames, refNames } = useContext(TicketRefsContext)
|
||||
// Gruppen: 1/2 = Markdown-Link, 3 = fett, 4 = code, 5/6 = Kurz-Verweis (typ/code), 7 = nackte GUID.
|
||||
const re = new RegExp(
|
||||
`\\[([^\\]]+)\\]\\(([^)]+)\\)|\\*\\*([^*]+)\\*\\*|\`([^\`]+)\`|(${GUID_RE})`,
|
||||
`\\[([^\\]]+)\\]\\(([^)]+)\\)|\\*\\*([^*]+)\\*\\*|\`([^\`]+)\`|` +
|
||||
`\\b(tier|kontakt|wurf|gehege):([0-9a-fA-F]{6,}(?:-[0-9a-fA-F-]+)?)|(${GUID_RE})`,
|
||||
'g',
|
||||
)
|
||||
const nodes: ReactNode[] = []
|
||||
@@ -146,8 +159,21 @@ function InlineMarkdown({ text }: { text: string }) {
|
||||
{m[4]}
|
||||
</code>,
|
||||
)
|
||||
} else if (m[5] !== undefined && m[6] !== undefined) {
|
||||
// Kurz-Verweis tier:/kontakt:/wurf:/gehege: → aufgelöster Name + Link, sonst Roh-Text.
|
||||
const type = m[5] as RefType
|
||||
const resolved = refNames[refKey(type, m[6])]
|
||||
if (resolved) {
|
||||
nodes.push(
|
||||
<Link key={k++} to={`${REF_ROUTE[type]}/${resolved.id}`} className="ticket-link">
|
||||
{resolved.name}
|
||||
</Link>,
|
||||
)
|
||||
} else {
|
||||
nodes.push(<TextWithBreaks key={k++} value={m[0]} />)
|
||||
}
|
||||
} else {
|
||||
const guid = m[5]
|
||||
const guid = m[7]
|
||||
const gn = gerbilNames[guid]
|
||||
if (ticketIds.has(guid)) {
|
||||
nodes.push(
|
||||
@@ -269,6 +295,8 @@ export default function TicketsPage() {
|
||||
const ticketIds = useMemo(() => new Set((rows ?? []).map((r) => r.id)), [rows])
|
||||
// Aufgelöste Tier-Namen je GerbilId (für nackte Tier-IDs im Text → Name + Link).
|
||||
const [gerbilNames, setGerbilNames] = useState<Record<string, string | null>>({})
|
||||
// Aufgelöste Kurz-Verweise (tier:/kontakt:/wurf:/gehege:) je `typ:code`.
|
||||
const [refNames, setRefNames] = useState<Record<string, ResolvedRef>>({})
|
||||
|
||||
// Gruppierung + Zählung je gefilterter Ansicht (für die Badges an den Filter-Chips).
|
||||
const counts = useMemo(() => {
|
||||
@@ -343,6 +371,49 @@ export default function TicketsPage() {
|
||||
}
|
||||
}, [candidateGerbilIds, gerbilNames])
|
||||
|
||||
// Kurz-Verweise (tier:/kontakt:/wurf:/gehege:) in den sichtbaren Tickets sammeln und
|
||||
// einmalig über POST /refs/resolve auflösen — so kann die KI im Text die Kurz-ID nutzen.
|
||||
const candidateRefs = useMemo(() => {
|
||||
const map = new Map<string, { type: RefType; code: string }>()
|
||||
const add = (txt?: string | null) => {
|
||||
if (!txt) return
|
||||
const re = new RegExp(REF_TOKEN_RE.source, REF_TOKEN_RE.flags)
|
||||
let m: RegExpExecArray | null
|
||||
while ((m = re.exec(txt)) !== null) {
|
||||
const type = m[1] as RefType
|
||||
map.set(refKey(type, m[2]), { type, code: m[2] })
|
||||
}
|
||||
}
|
||||
for (const tk of visible) {
|
||||
add(tk.message); add(tk.question); add(tk.answer); add(tk.fixNote)
|
||||
for (const e of tk.thread) add(e.text)
|
||||
}
|
||||
return map
|
||||
}, [visible])
|
||||
|
||||
useEffect(() => {
|
||||
const missing = [...candidateRefs].filter(([key]) => !(key in refNames))
|
||||
if (missing.length === 0) return
|
||||
let cancelled = false
|
||||
resolveRefs(missing.map(([, r]) => r))
|
||||
.then((results) => {
|
||||
if (cancelled) return
|
||||
setRefNames((prev) => {
|
||||
const next = { ...prev }
|
||||
for (const r of results) {
|
||||
next[refKey(r.type, r.code)] = r.id && r.name ? { id: r.id, name: r.name } : null
|
||||
}
|
||||
return next
|
||||
})
|
||||
})
|
||||
.catch(() => {
|
||||
/* Auflösung fehlgeschlagen — Tokens bleiben Roh-Text. */
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [candidateRefs, refNames])
|
||||
|
||||
const emptyText: Record<TicketView, string> = {
|
||||
dialog: t.emptyDialog,
|
||||
open: t.emptyOpen,
|
||||
@@ -386,7 +457,7 @@ export default function TicketsPage() {
|
||||
{visible.length === 0 ? (
|
||||
<p className="tickets-empty">{emptyText[activeView]}</p>
|
||||
) : (
|
||||
<TicketRefsContext.Provider value={{ ticketIds, gerbilNames }}>
|
||||
<TicketRefsContext.Provider value={{ ticketIds, gerbilNames, refNames }}>
|
||||
<ul className="tickets-list">
|
||||
{visible.map((ticket) => (
|
||||
<TicketCard
|
||||
|
||||
Reference in New Issue
Block a user