feat: add @manacore/shared-links for cross-app record linking

New shared package enabling bidirectional links between records across
apps (e.g. todo→calendar, task→file). Each link creates a forward+reverse
pair sharing a pairId for efficient queries from both sides. Stored in
dedicated IndexedDB (manacore-links), synced via mana-sync.

Includes: types, store, mutations, reactive queries, cached display data
resolvers, ManaLinkBadge and ManaLinkList UI components.

Integrates into Todo app as first consumer — link store initialized in
layout, ManaLinkList rendered in TaskEditModal.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-30 01:02:42 +02:00
parent 928d88aabd
commit 2222ce25e5
14 changed files with 614 additions and 1 deletions

View file

@ -40,6 +40,7 @@
},
"dependencies": {
"@manacore/local-store": "workspace:*",
"@manacore/shared-links": "workspace:*",
"@manacore/shared-api-client": "workspace:*",
"@manacore/shared-app-onboarding": "workspace:*",
"@manacore/shared-auth": "workspace:*",

View file

@ -25,6 +25,7 @@
TagSelector,
} from './form';
import { ContactSelector, focusTrap } from '@manacore/shared-ui';
import { ManaLinkList } from '@manacore/shared-links/ui';
interface Props {
task: Task;
@ -315,6 +316,12 @@
<SubtaskList {subtasks} onChange={handleSubtasksChange} />
</div>
<!-- Verknüpfungen -->
<div class="form-section">
<label class="form-label">Verknüpfungen</label>
<ManaLinkList recordRef={{ app: 'todo', collection: 'tasks', id: task.id }} editable />
</div>
<!-- Wiederholung -->
<div class="form-section">
<label class="form-label" for="task-recurrence">Wiederholung</label>

View file

@ -31,6 +31,7 @@
tagMutations,
useAllTags as useAllSharedTags,
} from '@manacore/shared-stores';
import { linkLocalStore, linkMutations } from '@manacore/shared-links';
import { theme } from '$lib/stores/theme';
import TaskFilters from '$lib/components/TaskFilters.svelte';
import { viewStore, type SortBy } from '$lib/stores/view.svelte';
@ -317,18 +318,24 @@
async function handleLogout() {
await authStore.signOut();
tagMutations.stopSync();
linkMutations.stopSync();
goto('/login');
}
async function handleAuthReady() {
// Initialize local-first databases (opens IndexedDB, seeds guest data)
await Promise.all([todoStore.initialize(), tagLocalStore.initialize()]);
await Promise.all([
todoStore.initialize(),
tagLocalStore.initialize(),
linkLocalStore.initialize(),
]);
// If authenticated, start syncing to server
if (authStore.isAuthenticated) {
const getToken = () => authStore.getValidToken();
todoStore.startSync(getToken);
tagMutations.startSync(getToken);
linkMutations.startSync(getToken);
}
// Initialize split-panel from URL/localStorage

View file

@ -0,0 +1,23 @@
{
"name": "@manacore/shared-links",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./ui": "./src/ui/index.ts"
},
"dependencies": {
"@manacore/local-store": "workspace:*",
"@manacore/shared-branding": "workspace:*"
},
"peerDependencies": {
"svelte": "^5.0.0"
},
"devDependencies": {
"svelte": "^5.0.0",
"typescript": "^5.9.3"
}
}

View file

@ -0,0 +1,26 @@
// Store
export { linkLocalStore, linkCollection } from './store.js';
// Types
export type {
LocalManaLink,
CreateManaLinkInput,
LinkCachedData,
ManaRecordRef,
ManaLinkType,
} from './types.js';
export { LINK_TYPE_INVERSIONS } from './types.js';
// Mutations
export { linkMutations } from './mutations.svelte.js';
// Queries
export {
useLinksForRecord,
useLinksForApp,
useLinksOfType,
useLinkCount,
} from './queries.svelte.js';
// Resolvers
export { buildCachedData, isCacheStale } from './resolvers.js';

View file

@ -0,0 +1,143 @@
/**
* ManaLink Mutation Service
*
* Handles creating, deleting, and updating cross-app links.
* Each link creates TWO records (forward + reverse) sharing a pairId
* for efficient bidirectional queries.
*/
import { linkLocalStore, linkCollection } from './store.js';
import type { LocalManaLink, CreateManaLinkInput, LinkCachedData } from './types.js';
import { LINK_TYPE_INVERSIONS } from './types.js';
/** Invert a directional link type. Symmetric types return unchanged. */
function invertLinkType(type: string): string {
return LINK_TYPE_INVERSIONS[type] ?? type;
}
let error = $state<string | null>(null);
export const linkMutations = {
get error() {
return error;
},
// === Store Lifecycle ===
async initialize() {
await linkLocalStore.initialize();
},
startSync(getToken: () => Promise<string | null>) {
linkLocalStore.startSync(getToken);
},
stopSync() {
linkLocalStore.stopSync();
},
// === Links ===
/**
* Create a bidirectional link. Inserts TWO records sharing a pairId:
* - Forward: source target
* - Reverse: target source (swapped)
*/
async createLink(
input: CreateManaLinkInput
): Promise<{ forward: LocalManaLink; reverse: LocalManaLink }> {
error = null;
try {
const pairId = crypto.randomUUID();
const linkType = input.linkType ?? 'related';
const forward: LocalManaLink = {
id: crypto.randomUUID(),
pairId,
direction: 'forward',
sourceApp: input.sourceApp,
sourceCollection: input.sourceCollection,
sourceId: input.sourceId,
targetApp: input.targetApp,
targetCollection: input.targetCollection,
targetId: input.targetId,
linkType,
cachedSource: input.cachedSource,
cachedTarget: input.cachedTarget,
};
const reverse: LocalManaLink = {
id: crypto.randomUUID(),
pairId,
direction: 'reverse',
sourceApp: input.targetApp,
sourceCollection: input.targetCollection,
sourceId: input.targetId,
targetApp: input.sourceApp,
targetCollection: input.sourceCollection,
targetId: input.sourceId,
linkType: invertLinkType(linkType),
cachedSource: input.cachedTarget,
cachedTarget: input.cachedSource,
};
const insertedForward = await linkCollection.insert(forward);
const insertedReverse = await linkCollection.insert(reverse);
return { forward: insertedForward, reverse: insertedReverse };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to create link';
throw e;
}
},
/**
* Delete a link pair by pairId. Soft-deletes both forward and reverse records.
*/
async deleteLinkPair(pairId: string): Promise<void> {
error = null;
try {
const links = await linkCollection.getAll({
pairId,
} as Partial<LocalManaLink>);
for (const link of links) {
await linkCollection.delete(link.id);
}
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to delete link pair';
throw e;
}
},
/**
* Delete a link by its record ID (and its pair partner).
*/
async deleteLink(linkId: string): Promise<void> {
error = null;
try {
const link = await linkCollection.get(linkId);
if (link) {
await this.deleteLinkPair(link.pairId);
}
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to delete link';
throw e;
}
},
/**
* Update cached display data on a link record.
*/
async updateCache(
linkId: string,
cached: Partial<{ cachedSource: LinkCachedData; cachedTarget: LinkCachedData }>
): Promise<void> {
error = null;
try {
await linkCollection.update(linkId, cached as Partial<LocalManaLink>);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to update link cache';
throw e;
}
},
};

View file

@ -0,0 +1,64 @@
/**
* ManaLink Reactive Live Queries
*
* Svelte 5 reactive queries for cross-app links.
* Auto-update when IndexedDB changes (local writes, sync, other tabs).
*/
import { useLiveQueryWithDefault } from '@manacore/local-store/svelte';
import { linkCollection } from './store.js';
import type { LocalManaLink, ManaRecordRef } from './types.js';
/**
* Get all links where this record is the source.
* Because we store reverse records, this returns links in BOTH directions.
*/
export function useLinksForRecord(ref: ManaRecordRef) {
return useLiveQueryWithDefault(
() =>
linkCollection.getAll({
sourceApp: ref.app,
sourceId: ref.id,
} as Partial<LocalManaLink>),
[] as LocalManaLink[]
);
}
/**
* Get all links for an entire app.
*/
export function useLinksForApp(appId: string) {
return useLiveQueryWithDefault(
() =>
linkCollection.getAll({
sourceApp: appId,
} as Partial<LocalManaLink>),
[] as LocalManaLink[]
);
}
/**
* Get links of a specific type for a record.
*/
export function useLinksOfType(ref: ManaRecordRef, linkType: string) {
return useLiveQueryWithDefault(async () => {
const all = await linkCollection.getAll({
sourceApp: ref.app,
sourceId: ref.id,
} as Partial<LocalManaLink>);
return all.filter((l) => l.linkType === linkType);
}, [] as LocalManaLink[]);
}
/**
* Count links for a record.
*/
export function useLinkCount(ref: ManaRecordRef) {
return useLiveQueryWithDefault(async () => {
const all = await linkCollection.getAll({
sourceApp: ref.app,
sourceId: ref.id,
} as Partial<LocalManaLink>);
return all.length;
}, 0);
}

View file

@ -0,0 +1,34 @@
/**
* ManaLink Display Data Resolvers
*
* Helpers to build cached display data from app metadata.
*/
import { getManaApp } from '@manacore/shared-branding';
import type { AppIconId } from '@manacore/shared-branding';
import type { LinkCachedData } from './types.js';
/**
* Build LinkCachedData from app branding + a title.
* Called by the app creating the link to populate offline-friendly display data.
*/
export function buildCachedData(appId: string, title: string, subtitle?: string): LinkCachedData {
const app = getManaApp(appId as AppIconId);
return {
title,
subtitle,
icon: app?.icon,
color: app?.color,
appName: app?.name,
fetchedAt: new Date().toISOString(),
};
}
/**
* Check if cached data is stale (older than threshold).
* Default: 24 hours.
*/
export function isCacheStale(cached: LinkCachedData, maxAgeMs = 24 * 60 * 60 * 1000): boolean {
const age = Date.now() - new Date(cached.fetchedAt).getTime();
return age > maxAgeMs;
}

View file

@ -0,0 +1,40 @@
/**
* ManaLink Local-First Store
*
* Creates a shared IndexedDB database ('manacore-links') that all apps
* can read from. Links are synced to the server via mana-sync.
*/
import { createLocalStore } from '@manacore/local-store';
import type { LocalManaLink } from './types.js';
const SYNC_SERVER_URL =
(typeof window !== 'undefined' &&
(window as unknown as { __PUBLIC_SYNC_SERVER_URL__?: string }).__PUBLIC_SYNC_SERVER_URL__) ||
(typeof import.meta !== 'undefined' && import.meta.env?.PUBLIC_SYNC_SERVER_URL) ||
'http://localhost:3050';
export const linkLocalStore = createLocalStore({
appId: 'links',
collections: [
{
name: 'links',
indexes: [
'pairId',
'direction',
'sourceApp',
'sourceId',
'targetApp',
'targetId',
'linkType',
'[sourceApp+sourceId]',
'[sourceApp+sourceCollection+sourceId]',
],
},
],
sync: {
serverUrl: SYNC_SERVER_URL,
},
});
export const linkCollection = linkLocalStore.collection<LocalManaLink>('links');

View file

@ -0,0 +1,92 @@
/**
* ManaLink Cross-App Linking Types
*
* Defines the data structures for bidirectional links between records
* across different apps in the Mana ecosystem.
*/
import type { BaseRecord } from '@manacore/local-store';
/** Cached display data for a linked record (offline-friendly). */
export interface LinkCachedData {
/** Display title of the linked record. */
title: string;
/** Optional subtitle (e.g. date, status). */
subtitle?: string;
/** App icon (data URL from shared-branding). */
icon?: string;
/** App color hex (from shared-branding). */
color?: string;
/** Human-readable app name. */
appName?: string;
/** ISO timestamp when this cache was built. */
fetchedAt: string;
}
/** A single link record stored in IndexedDB (manacore-links). */
export interface LocalManaLink extends BaseRecord {
/** UUID shared by the forward and reverse link records. */
pairId: string;
/** Direction marker for this half of the pair. */
direction: 'forward' | 'reverse';
/** App ID of the source record (e.g. 'todo'). */
sourceApp: string;
/** Collection name in the source app (e.g. 'tasks'). */
sourceCollection: string;
/** Record ID in the source app. */
sourceId: string;
/** App ID of the target record (e.g. 'calendar'). */
targetApp: string;
/** Collection name in the target app (e.g. 'events'). */
targetCollection: string;
/** Record ID in the target app. */
targetId: string;
/** Semantic link type. */
linkType: string;
/** Cached display data for the source record. */
cachedSource?: LinkCachedData;
/** Cached display data for the target record. */
cachedTarget?: LinkCachedData;
/** User ID (for sync scoping). */
userId?: string;
}
/** Input for creating a link (user-facing). */
export interface CreateManaLinkInput {
sourceApp: string;
sourceCollection: string;
sourceId: string;
targetApp: string;
targetCollection: string;
targetId: string;
/** Link type. Default: 'related'. */
linkType?: string;
cachedSource?: LinkCachedData;
cachedTarget?: LinkCachedData;
}
/** Identifies a record across apps. */
export interface ManaRecordRef {
app: string;
collection: string;
id: string;
}
/** Built-in link types. */
export type ManaLinkType =
| 'related'
| 'blocks'
| 'blocked-by'
| 'time-block'
| 'attachment'
| 'reference'
| 'parent'
| 'child';
/** Map of directional link type inversions. */
export const LINK_TYPE_INVERSIONS: Record<string, string> = {
blocks: 'blocked-by',
'blocked-by': 'blocks',
parent: 'child',
child: 'parent',
};

View file

@ -0,0 +1,119 @@
<script lang="ts">
import type { LocalManaLink } from '../types.js';
import { getManaApp } from '@manacore/shared-branding';
import type { AppIconId } from '@manacore/shared-branding';
interface Props {
link: LocalManaLink;
onRemove?: (pairId: string) => void;
onclick?: (link: LocalManaLink) => void;
}
let { link, onRemove, onclick }: Props = $props();
let targetApp = $derived(getManaApp(link.targetApp as AppIconId));
let displayTitle = $derived(
link.cachedTarget?.title ?? `${link.targetApp}/${link.targetId.slice(0, 8)}`
);
let displayColor = $derived(link.cachedTarget?.color ?? targetApp?.color ?? '#6b7280');
let displayAppName = $derived(link.cachedTarget?.appName ?? targetApp?.name ?? link.targetApp);
</script>
<span class="manalink-badge" role="group">
<button
class="manalink-main"
onclick={() => onclick?.(link)}
title="{displayAppName}: {displayTitle}"
>
<span class="manalink-dot" style:background-color={displayColor}></span>
<span class="manalink-title">{displayTitle}</span>
{#if link.cachedTarget?.subtitle}
<span class="manalink-subtitle">{link.cachedTarget.subtitle}</span>
{/if}
</button>
{#if onRemove}
<button class="manalink-remove" onclick={() => onRemove?.(link.pairId)} title="Link entfernen">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
<path
d="M3 3l6 6M9 3l-6 6"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
/>
</svg>
</button>
{/if}
</span>
<style>
.manalink-badge {
display: inline-flex;
align-items: center;
border-radius: 9999px;
background: var(--color-surface, #f3f4f6);
border: 1px solid var(--color-border, #e5e7eb);
font-size: 0.75rem;
line-height: 1;
transition: all 0.15s ease;
max-width: 220px;
}
.manalink-badge:hover {
background: var(--color-surface-hover, #e5e7eb);
border-color: var(--color-border-hover, #d1d5db);
}
.manalink-main {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.625rem;
border: none;
background: transparent;
cursor: pointer;
font-size: inherit;
line-height: inherit;
overflow: hidden;
}
.manalink-dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
flex-shrink: 0;
}
.manalink-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--color-text, #374151);
}
.manalink-subtitle {
color: var(--color-text-muted, #9ca3af);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.manalink-remove {
display: flex;
align-items: center;
justify-content: center;
width: 1rem;
height: 1rem;
border-radius: 50%;
border: none;
background: transparent;
color: var(--color-text-muted, #9ca3af);
cursor: pointer;
padding: 0;
flex-shrink: 0;
}
.manalink-remove:hover {
background: var(--color-danger, #ef4444);
color: white;
}
</style>

View file

@ -0,0 +1,36 @@
<script lang="ts">
import type { LocalManaLink, ManaRecordRef } from '../types.js';
import { useLinksForRecord } from '../queries.svelte.js';
import { linkMutations } from '../mutations.svelte.js';
import ManaLinkBadge from './ManaLinkBadge.svelte';
interface Props {
recordRef: ManaRecordRef;
onclick?: (link: LocalManaLink) => void;
editable?: boolean;
}
let { recordRef, onclick, editable = false }: Props = $props();
const links = useLinksForRecord(recordRef);
function handleRemove(pairId: string) {
linkMutations.deleteLinkPair(pairId);
}
</script>
{#if (links.value ?? []).length > 0}
<div class="manalink-list">
{#each links.value ?? [] as link (link.id)}
<ManaLinkBadge {link} {onclick} onRemove={editable ? handleRemove : undefined} />
{/each}
</div>
{/if}
<style>
.manalink-list {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
</style>

View file

@ -0,0 +1,2 @@
export { default as ManaLinkBadge } from './ManaLinkBadge.svelte';
export { default as ManaLinkList } from './ManaLinkList.svelte';

View file

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "dist",
"types": ["svelte"]
},
"include": ["src/**/*"]
}