Implementerer kanban som noder+edges uten separate tabeller: - Board = collection-node med metadata.board og metadata.columns - Kort = content-noder med belongs_to-edge til board - Status via status-edge (kort→board) med metadata.value - Posisjon via belongs_to-edge metadata.position Backend: - POST /intentions/update_edge — oppdater edge-type/metadata - GET /query/board?board_id= — hent kort med status og posisjon Frontend: - /board/[id] route med kolonner, drag-and-drop, kortoppretting - Sanntid via SpacetimeDB edge-subscriptions - Board-oppretting og navigasjon fra mottak-siden Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
336 lines
8.7 KiB
TypeScript
336 lines
8.7 KiB
TypeScript
/**
|
|
* Client for maskinrommet intentions API.
|
|
* Uses the Vite dev proxy (/api → api.sidelinja.org) in development.
|
|
* In production, set VITE_API_URL to the maskinrommet URL.
|
|
*/
|
|
|
|
const BASE_URL = import.meta.env.VITE_API_URL ?? '/api';
|
|
|
|
export interface CreateNodeRequest {
|
|
node_kind?: string;
|
|
title?: string;
|
|
content?: string;
|
|
visibility?: string;
|
|
metadata?: Record<string, unknown>;
|
|
/** Kontekst-node (kommunikasjonsnode). Gir automatisk belongs_to-edge. */
|
|
context_id?: string;
|
|
}
|
|
|
|
export interface CreateNodeResponse {
|
|
node_id: string;
|
|
/** Edge-ID for automatisk belongs_to-edge (kun ved context_id). */
|
|
belongs_to_edge_id?: string;
|
|
}
|
|
|
|
export interface CreateEdgeRequest {
|
|
source_id: string;
|
|
target_id: string;
|
|
edge_type: string;
|
|
metadata?: Record<string, unknown>;
|
|
system?: boolean;
|
|
}
|
|
|
|
export interface CreateEdgeResponse {
|
|
edge_id: string;
|
|
}
|
|
|
|
async function post<T>(accessToken: string, path: string, data: unknown): Promise<T> {
|
|
const res = await fetch(`${BASE_URL}${path}`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Authorization: `Bearer ${accessToken}`
|
|
},
|
|
body: JSON.stringify(data)
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const body = await res.text();
|
|
throw new Error(`${path} failed (${res.status}): ${body}`);
|
|
}
|
|
|
|
return res.json();
|
|
}
|
|
|
|
export function createNode(
|
|
accessToken: string,
|
|
data: CreateNodeRequest
|
|
): Promise<CreateNodeResponse> {
|
|
return post(accessToken, '/intentions/create_node', data);
|
|
}
|
|
|
|
export function createEdge(
|
|
accessToken: string,
|
|
data: CreateEdgeRequest
|
|
): Promise<CreateEdgeResponse> {
|
|
return post(accessToken, '/intentions/create_edge', data);
|
|
}
|
|
|
|
// =============================================================================
|
|
// Edge-oppdatering
|
|
// =============================================================================
|
|
|
|
export interface UpdateEdgeRequest {
|
|
edge_id: string;
|
|
edge_type?: string;
|
|
metadata?: Record<string, unknown>;
|
|
}
|
|
|
|
export interface UpdateEdgeResponse {
|
|
edge_id: string;
|
|
}
|
|
|
|
export function updateEdge(
|
|
accessToken: string,
|
|
data: UpdateEdgeRequest
|
|
): Promise<UpdateEdgeResponse> {
|
|
return post(accessToken, '/intentions/update_edge', data);
|
|
}
|
|
|
|
// =============================================================================
|
|
// Board / Kanban
|
|
// =============================================================================
|
|
|
|
export interface BoardCard {
|
|
node_id: string;
|
|
title: string | null;
|
|
content: string | null;
|
|
node_kind: string;
|
|
metadata: Record<string, unknown>;
|
|
created_at: string;
|
|
created_by: string | null;
|
|
status: string | null;
|
|
position: number;
|
|
belongs_to_edge_id: string;
|
|
status_edge_id: string | null;
|
|
}
|
|
|
|
export interface BoardResponse {
|
|
board_id: string;
|
|
board_title: string | null;
|
|
columns: string[];
|
|
cards: BoardCard[];
|
|
}
|
|
|
|
export async function fetchBoard(accessToken: string, boardId: string): Promise<BoardResponse> {
|
|
const res = await fetch(`${BASE_URL}/query/board?board_id=${encodeURIComponent(boardId)}`, {
|
|
headers: { Authorization: `Bearer ${accessToken}` }
|
|
});
|
|
if (!res.ok) {
|
|
const body = await res.text();
|
|
throw new Error(`board failed (${res.status}): ${body}`);
|
|
}
|
|
return res.json();
|
|
}
|
|
|
|
// =============================================================================
|
|
// Kommunikasjon
|
|
// =============================================================================
|
|
|
|
export interface CreateCommunicationRequest {
|
|
title?: string;
|
|
participants?: string[];
|
|
visibility?: string;
|
|
metadata?: Record<string, unknown>;
|
|
}
|
|
|
|
export interface CreateCommunicationResponse {
|
|
node_id: string;
|
|
edge_ids: string[];
|
|
}
|
|
|
|
export function createCommunication(
|
|
accessToken: string,
|
|
data: CreateCommunicationRequest
|
|
): Promise<CreateCommunicationResponse> {
|
|
return post(accessToken, '/intentions/create_communication', data);
|
|
}
|
|
|
|
export interface UploadMediaRequest {
|
|
file: File;
|
|
source_id?: string;
|
|
visibility?: string;
|
|
title?: string;
|
|
}
|
|
|
|
export interface UploadMediaResponse {
|
|
media_node_id: string;
|
|
cas_hash: string;
|
|
size_bytes: number;
|
|
already_existed: boolean;
|
|
has_media_edge_id?: string;
|
|
}
|
|
|
|
export async function uploadMedia(
|
|
accessToken: string,
|
|
data: UploadMediaRequest
|
|
): Promise<UploadMediaResponse> {
|
|
const form = new FormData();
|
|
form.append('file', data.file);
|
|
if (data.source_id) form.append('source_id', data.source_id);
|
|
if (data.visibility) form.append('visibility', data.visibility);
|
|
if (data.title) form.append('title', data.title);
|
|
|
|
const res = await fetch(`${BASE_URL}/intentions/upload_media`, {
|
|
method: 'POST',
|
|
headers: { Authorization: `Bearer ${accessToken}` },
|
|
body: form
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const body = await res.text();
|
|
throw new Error(`upload_media failed (${res.status}): ${body}`);
|
|
}
|
|
|
|
return res.json();
|
|
}
|
|
|
|
/** Build the CAS URL for a given hash. */
|
|
export function casUrl(hash: string): string {
|
|
return `${BASE_URL}/cas/${hash}`;
|
|
}
|
|
|
|
// =============================================================================
|
|
// Transkripsjons-segmenter
|
|
// =============================================================================
|
|
|
|
export interface Segment {
|
|
id: number;
|
|
seq: number;
|
|
start_ms: number;
|
|
end_ms: number;
|
|
content: string;
|
|
edited: boolean;
|
|
}
|
|
|
|
export interface SegmentsResponse {
|
|
segments: Segment[];
|
|
transcribed_at: string | null;
|
|
}
|
|
|
|
/** Hent transkripsjons-segmenter for en media-node. */
|
|
export async function fetchSegments(
|
|
accessToken: string,
|
|
nodeId: string
|
|
): Promise<SegmentsResponse> {
|
|
const res = await fetch(`${BASE_URL}/query/segments?node_id=${encodeURIComponent(nodeId)}`, {
|
|
headers: { Authorization: `Bearer ${accessToken}` }
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const body = await res.text();
|
|
throw new Error(`segments failed (${res.status}): ${body}`);
|
|
}
|
|
|
|
return res.json();
|
|
}
|
|
|
|
/** Last ned SRT-fil for en media-node. Trigger filnedlasting i nettleseren. */
|
|
export async function downloadSrt(accessToken: string, nodeId: string): Promise<void> {
|
|
const res = await fetch(
|
|
`${BASE_URL}/query/segments/srt?node_id=${encodeURIComponent(nodeId)}`,
|
|
{ headers: { Authorization: `Bearer ${accessToken}` } }
|
|
);
|
|
if (!res.ok) {
|
|
const body = await res.text();
|
|
throw new Error(`SRT-eksport feilet (${res.status}): ${body}`);
|
|
}
|
|
const blob = await res.blob();
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = 'transcription.srt';
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
/** Oppdater teksten i et transkripsjons-segment. */
|
|
export function updateSegment(
|
|
accessToken: string,
|
|
segmentId: number,
|
|
content: string
|
|
): Promise<{ segment_id: number; edited: boolean }> {
|
|
return post(accessToken, '/intentions/update_segment', {
|
|
segment_id: segmentId,
|
|
content
|
|
});
|
|
}
|
|
|
|
// =============================================================================
|
|
// Re-transkripsjon
|
|
// =============================================================================
|
|
|
|
export interface TranscriptionVersion {
|
|
transcribed_at: string;
|
|
segment_count: number;
|
|
edited_count: number;
|
|
}
|
|
|
|
export interface TranscriptionVersionsResponse {
|
|
versions: TranscriptionVersion[];
|
|
}
|
|
|
|
/** Hent alle transkripsjonsversjoner for en node. */
|
|
export async function fetchTranscriptionVersions(
|
|
accessToken: string,
|
|
nodeId: string
|
|
): Promise<TranscriptionVersionsResponse> {
|
|
const res = await fetch(
|
|
`${BASE_URL}/query/transcription_versions?node_id=${encodeURIComponent(nodeId)}`,
|
|
{ headers: { Authorization: `Bearer ${accessToken}` } }
|
|
);
|
|
if (!res.ok) {
|
|
const body = await res.text();
|
|
throw new Error(`transcription_versions failed (${res.status}): ${body}`);
|
|
}
|
|
return res.json();
|
|
}
|
|
|
|
/** Hent segmenter for en spesifikk transkripsjonsversjon. */
|
|
export async function fetchSegmentsVersion(
|
|
accessToken: string,
|
|
nodeId: string,
|
|
transcribedAt: string
|
|
): Promise<SegmentsResponse> {
|
|
const params = new URLSearchParams({
|
|
node_id: nodeId,
|
|
transcribed_at: transcribedAt
|
|
});
|
|
const res = await fetch(`${BASE_URL}/query/segments_version?${params}`, {
|
|
headers: { Authorization: `Bearer ${accessToken}` }
|
|
});
|
|
if (!res.ok) {
|
|
const body = await res.text();
|
|
throw new Error(`segments_version failed (${res.status}): ${body}`);
|
|
}
|
|
return res.json();
|
|
}
|
|
|
|
/** Trigger re-transkripsjon for en media-node. */
|
|
export function retranscribe(
|
|
accessToken: string,
|
|
nodeId: string
|
|
): Promise<{ job_id: string }> {
|
|
return post(accessToken, '/intentions/retranscribe', { node_id: nodeId });
|
|
}
|
|
|
|
export interface SegmentChoice {
|
|
seq: number;
|
|
choice: 'new' | 'old';
|
|
}
|
|
|
|
/** Anvend brukerens per-segment-valg etter re-transkripsjon. */
|
|
export function resolveRetranscription(
|
|
accessToken: string,
|
|
nodeId: string,
|
|
newVersion: string,
|
|
oldVersion: string,
|
|
choices: SegmentChoice[]
|
|
): Promise<{ resolved: boolean; kept_old: number; kept_new: number }> {
|
|
return post(accessToken, '/intentions/resolve_retranscription', {
|
|
node_id: nodeId,
|
|
new_version: newVersion,
|
|
old_version: oldVersion,
|
|
choices
|
|
});
|
|
}
|