mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-17 14:29:40 +02:00
feat(mana/web): encryption phase 6.1 — cards, presi, inventar, planta
Four more modules join the encrypted-at-rest path. Tables flipped:
- cards.cards front + back (no `notes` column on LocalCard)
- cards.cardDecks name + description (schema uses `name` not `title`)
- presi.presiDecks title + description
- presi.slides content (LocalSlide has only the SlideContent
object — no separate `notes`. The
JSON-stringify in wrapValue handles
nested-object content cleanly)
- inventar.invItems description (only — `name` is in the schema
index used by where()/sortBy
queries, and `notes` is an array
of {id, content, createdAt} that
addNote/deleteNote splice in
place; encrypting either would
force per-mutation decrypt+
re-encrypt of the whole array.
Phase 7 concern.)
- planta.plants name + careNotes + temperature + soilType
(`name` is NOT indexed for plants — the schema
only indexes id/isActive/healthStatus, so it's
safe to encrypt unlike inventar/dreamSymbols)
Per-module mutations
Each store now follows the established Phase 4/5 pattern:
- createX: build LocalRecord, snapshot via toX() for the optimistic
return, encryptRecord, then table.add
- updateX: build diff, encryptRecord on the diff, then table.update
- The Sprint 1 atomic-cascade deleteDeck (cards + presi) is unchanged
because deletes only touch plaintext deletedAt/updatedAt fields.
planta.update() reads the row back after the write to return a Plant
to its caller; that read goes through decryptRecord because the
raw row is now encrypted on disk.
Per-module queries
useAllDecks / useDeck / useCardsByDeck (cards)
useAllDecks / useDeck / useDeckSlides (presi)
useAllItems (inventar)
useAllPlants (planta)
All filter on plaintext metadata first, then decryptRecords on the
visible set.
cross-app-queries dashboard widgets
- useRecentDecks (presi) decrypts the title/description before the
dashboard widget renders the deck name
- useCardsProgress decrypts the deck name list — counts continue to
work on plaintext fields
Skipped intentionally
- tasks / calendar.events / habits — title is duplicated to the
cross-module timeBlocks table. Encrypting only the task copy
would still leak the title via the timeBlock. Needs a coordinated
timeBlocks encryption pass (Phase 6.1.5).
- picture.images / storage.files / music.songs — records are
server-pushed (image generation, file uploads, library imports).
Client-side encryptRecord can't help; needs the API service to
encrypt before pushing, or a sync-time wrap step. Documented as
a Phase 7 concern.
- nutriphi.meals / uload.links / context.documents / questions /
answers — write directly from views, no store. Need a store
extraction first.
Verified: 20 test files, 262/262 tests passing. Pre-existing TS
errors in context/index.ts, picture/images.svelte.ts, planta/
quick-input-adapter.ts and questions/index.ts are unrelated parallel
refactor drift.
Phase 6.2 next: settings/security UI showing vault status, encrypted-
table list, manual rotate button.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b2bddfefab
commit
73f294b298
11 changed files with 106 additions and 43 deletions
|
|
@ -248,13 +248,17 @@ export function useMusicStats() {
|
|||
/** Recent presentation decks. */
|
||||
export function useRecentDecks(limit = 5) {
|
||||
return useLiveQueryWithDefault(async () => {
|
||||
return db
|
||||
const visible = await db
|
||||
.table<LocalPresiDeck>('presiDecks')
|
||||
.orderBy('updatedAt')
|
||||
.reverse()
|
||||
.filter((d) => !d.deletedAt)
|
||||
.limit(limit)
|
||||
.toArray();
|
||||
// Phase 6: presiDecks title/description encrypted — decrypt for the
|
||||
// dashboard widget so the user sees the deck name, not a blob.
|
||||
const { decryptRecords } = await import('./crypto');
|
||||
return decryptRecords('presiDecks', visible);
|
||||
}, [] as LocalPresiDeck[]);
|
||||
}
|
||||
|
||||
|
|
@ -306,12 +310,16 @@ export function useCardsProgress() {
|
|||
const activeCards = cards.filter((c) => !c.deletedAt);
|
||||
const now = new Date().toISOString();
|
||||
const dueCards = activeCards.filter((c) => c.nextReview && c.nextReview <= now);
|
||||
// Phase 6: cardDecks.name is encrypted — the widget renders the
|
||||
// deck names so they need decryption. Counts work plaintext.
|
||||
const { decryptRecords } = await import('./crypto');
|
||||
const decryptedDecks = await decryptRecords('cardDecks', activeDecks);
|
||||
return {
|
||||
totalDecks: activeDecks.length,
|
||||
totalCards: activeCards.length,
|
||||
cardsLearned: activeCards.filter((c) => (c.reviewCount ?? 0) > 0).length,
|
||||
dueForReview: dueCards.length,
|
||||
decks: activeDecks,
|
||||
decks: decryptedDecks,
|
||||
};
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -114,15 +114,24 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
|
|||
meals: { enabled: false, fields: ['description', 'notes', 'aiAnalysis'] },
|
||||
|
||||
// ─── Planta ──────────────────────────────────────────────
|
||||
plants: { enabled: false, fields: ['name', 'notes', 'careNotes'] },
|
||||
// `name` is NOT in the schema index for plants (only isActive +
|
||||
// healthStatus), so encrypting it is safe. LocalPlant uses
|
||||
// `careNotes` (no separate `notes`) plus the user-typed metadata.
|
||||
plants: { enabled: true, fields: ['name', 'careNotes', 'temperature', 'soilType'] },
|
||||
|
||||
// ─── Cards ───────────────────────────────────────────────
|
||||
cards: { enabled: false, fields: ['front', 'back', 'notes'] },
|
||||
cardDecks: { enabled: false, fields: ['title', 'description'] },
|
||||
// `cards` has no `notes` column on LocalCard — only front + back are
|
||||
// user content. cardDecks uses `name` (not `title`) on the schema
|
||||
// even though the public DTO translates it to `title`.
|
||||
cards: { enabled: true, fields: ['front', 'back'] },
|
||||
cardDecks: { enabled: true, fields: ['name', 'description'] },
|
||||
|
||||
// ─── Presi ───────────────────────────────────────────────
|
||||
presiDecks: { enabled: false, fields: ['title', 'description'] },
|
||||
slides: { enabled: false, fields: ['content', 'notes'] },
|
||||
// LocalSlide only has `content` (SlideContent object) — no separate
|
||||
// notes column on the schema. JSON-stringify in wrapValue handles
|
||||
// the nested object cleanly.
|
||||
presiDecks: { enabled: true, fields: ['title', 'description'] },
|
||||
slides: { enabled: true, fields: ['content'] },
|
||||
|
||||
// ─── Context ─────────────────────────────────────────────
|
||||
documents: { enabled: false, fields: ['title', 'content', 'body'] },
|
||||
|
|
@ -161,7 +170,13 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
|
|||
manaLinks: { enabled: false, fields: ['label', 'url', 'notes'] },
|
||||
|
||||
// ─── Inventar ────────────────────────────────────────────
|
||||
invItems: { enabled: false, fields: ['name', 'description', 'notes'] },
|
||||
// `name` is indexed (used in where()/sortBy queries). `notes` is an
|
||||
// array of {id, content, createdAt} that addNote/deleteNote splice
|
||||
// in place — encrypting it would force every mutation to decrypt+
|
||||
// re-encrypt the whole array. Encrypt only the description field
|
||||
// for now; broader coverage is a Phase 7 concern that needs a
|
||||
// different storage layout.
|
||||
invItems: { enabled: true, fields: ['description'] },
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
|
||||
import { liveQuery } from 'dexie';
|
||||
import { db } from '$lib/data/database';
|
||||
import { decryptRecord, decryptRecords } from '$lib/data/crypto';
|
||||
import type { LocalDeck, LocalCard, Deck, Card } from './types';
|
||||
|
||||
// ─── Type Converters ───────────────────────────────────────
|
||||
|
|
@ -44,8 +45,9 @@ export function toCard(local: LocalCard): Card {
|
|||
/** All decks, auto-updates on any change. */
|
||||
export function useAllDecks() {
|
||||
return liveQuery(async () => {
|
||||
const locals = await db.table<LocalDeck>('cardDecks').toArray();
|
||||
return locals.filter((d) => !d.deletedAt).map(toDeck);
|
||||
const visible = (await db.table<LocalDeck>('cardDecks').toArray()).filter((d) => !d.deletedAt);
|
||||
const decrypted = await decryptRecords('cardDecks', visible);
|
||||
return decrypted.map(toDeck);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -53,19 +55,20 @@ export function useAllDecks() {
|
|||
export function useDeck(deckId: string) {
|
||||
return liveQuery(async () => {
|
||||
const local = await db.table<LocalDeck>('cardDecks').get(deckId);
|
||||
return local && !local.deletedAt ? toDeck(local) : null;
|
||||
if (!local || local.deletedAt) return null;
|
||||
const decrypted = await decryptRecord('cardDecks', { ...local });
|
||||
return toDeck(decrypted);
|
||||
});
|
||||
}
|
||||
|
||||
/** All cards for a specific deck, sorted by order. Auto-updates on any change. */
|
||||
export function useCardsByDeck(deckId: string) {
|
||||
return liveQuery(async () => {
|
||||
const locals = await db
|
||||
.table<LocalCard>('cards')
|
||||
.where('deckId')
|
||||
.equals(deckId)
|
||||
.sortBy('order');
|
||||
return locals.filter((c) => !c.deletedAt).map(toCard);
|
||||
const visible = (
|
||||
await db.table<LocalCard>('cards').where('deckId').equals(deckId).sortBy('order')
|
||||
).filter((c) => !c.deletedAt);
|
||||
const decrypted = await decryptRecords('cards', visible);
|
||||
return decrypted.map(toCard);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
import { CardsEvents } from '@mana/shared-utils/analytics';
|
||||
import { cardTable, cardDeckTable } from '../collections';
|
||||
import { toCard } from '../queries';
|
||||
import { encryptRecord } from '$lib/data/crypto';
|
||||
import type { LocalCard, Card, CreateCardInput, UpdateCardInput } from '../types';
|
||||
|
||||
let error = $state<string | null>(null);
|
||||
|
|
@ -30,6 +31,8 @@ export const cardStore = {
|
|||
order: currentCardCount,
|
||||
};
|
||||
|
||||
const plaintextSnapshot = toCard(newLocal);
|
||||
await encryptRecord('cards', newLocal);
|
||||
await cardTable.add(newLocal);
|
||||
|
||||
// Update deck card count
|
||||
|
|
@ -42,7 +45,7 @@ export const cardStore = {
|
|||
}
|
||||
|
||||
CardsEvents.cardCreated();
|
||||
return toCard(newLocal);
|
||||
return plaintextSnapshot;
|
||||
} catch (err: any) {
|
||||
error = err.message || 'Failed to create card';
|
||||
console.error('Create card error:', err);
|
||||
|
|
@ -59,10 +62,12 @@ export const cardStore = {
|
|||
if (updates.difficulty !== undefined) localUpdates.difficulty = updates.difficulty;
|
||||
if (updates.order !== undefined) localUpdates.order = updates.order;
|
||||
|
||||
await cardTable.update(id, {
|
||||
const diff: Partial<LocalCard> = {
|
||||
...localUpdates,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
};
|
||||
await encryptRecord('cards', diff);
|
||||
await cardTable.update(id, diff);
|
||||
} catch (err: any) {
|
||||
error = err.message || 'Failed to update card';
|
||||
console.error('Update card error:', err);
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { CardsEvents } from '@mana/shared-utils/analytics';
|
|||
import { db } from '$lib/data/database';
|
||||
import { cardDeckTable, cardTable } from '../collections';
|
||||
import { toDeck } from '../queries';
|
||||
import { encryptRecord } from '$lib/data/crypto';
|
||||
import type { LocalDeck } from '../types';
|
||||
import type { Deck, CreateDeckInput, UpdateDeckInput } from '../types';
|
||||
|
||||
|
|
@ -31,9 +32,11 @@ export const deckStore = {
|
|||
isPublic: input.isPublic ?? false,
|
||||
};
|
||||
|
||||
const plaintextSnapshot = toDeck(newLocal);
|
||||
await encryptRecord('cardDecks', newLocal);
|
||||
await cardDeckTable.add(newLocal);
|
||||
CardsEvents.deckCreated();
|
||||
return toDeck(newLocal);
|
||||
return plaintextSnapshot;
|
||||
} catch (err: any) {
|
||||
error = err.message || 'Failed to create deck';
|
||||
console.error('Create deck error:', err);
|
||||
|
|
@ -49,10 +52,12 @@ export const deckStore = {
|
|||
if (updates.description !== undefined) localUpdates.description = updates.description;
|
||||
if (updates.isPublic !== undefined) localUpdates.isPublic = updates.isPublic;
|
||||
|
||||
await cardDeckTable.update(id, {
|
||||
const diff: Partial<LocalDeck> = {
|
||||
...localUpdates,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
};
|
||||
await encryptRecord('cardDecks', diff);
|
||||
await cardDeckTable.update(id, diff);
|
||||
} catch (err: any) {
|
||||
error = err.message || 'Failed to update deck';
|
||||
console.error('Update deck error:', err);
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
|
||||
import { liveQuery } from 'dexie';
|
||||
import { db } from '$lib/data/database';
|
||||
import { decryptRecords } from '$lib/data/crypto';
|
||||
import type { LocalCollection, LocalItem, LocalLocation, LocalCategory } from './types';
|
||||
|
||||
// ─── Shared Types (inline to avoid @inventar/shared dependency) ───
|
||||
|
|
@ -167,8 +168,9 @@ export function useAllCollections() {
|
|||
|
||||
export function useAllItems() {
|
||||
return liveQuery(async () => {
|
||||
const locals = await db.table<LocalItem>('invItems').toArray();
|
||||
return locals.filter((i) => !i.deletedAt).map(toItem);
|
||||
const visible = (await db.table<LocalItem>('invItems').toArray()).filter((i) => !i.deletedAt);
|
||||
const decrypted = await decryptRecords('invItems', visible);
|
||||
return decrypted.map(toItem);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { toItem } from '../queries';
|
|||
import type { LocalItem } from '../types';
|
||||
import type { ItemStatus } from '../queries';
|
||||
import { InventarEvents } from '@mana/shared-utils/analytics';
|
||||
import { encryptRecord } from '$lib/data/crypto';
|
||||
|
||||
export const itemsStore = {
|
||||
async create(data: {
|
||||
|
|
@ -45,9 +46,11 @@ export const itemsStore = {
|
|||
tags: data.tags || [],
|
||||
order: collectionItems.length,
|
||||
};
|
||||
const plaintextSnapshot = toItem(newLocal);
|
||||
await encryptRecord('invItems', newLocal);
|
||||
await invItemTable.add(newLocal);
|
||||
InventarEvents.itemCreated();
|
||||
return toItem(newLocal);
|
||||
return plaintextSnapshot;
|
||||
},
|
||||
|
||||
async update(
|
||||
|
|
@ -67,10 +70,12 @@ export const itemsStore = {
|
|||
>
|
||||
>
|
||||
) {
|
||||
await invItemTable.update(id, {
|
||||
const diff: Partial<LocalItem> = {
|
||||
...data,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
};
|
||||
await encryptRecord('invItems', diff);
|
||||
await invItemTable.update(id, diff);
|
||||
InventarEvents.itemUpdated();
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
import { db } from '$lib/data/database';
|
||||
import { toPlant, toWateringSchedule } from './queries';
|
||||
import { PlantaEvents } from '@mana/shared-utils/analytics';
|
||||
import { encryptRecord } from '$lib/data/crypto';
|
||||
import type {
|
||||
LocalPlant,
|
||||
LocalWateringSchedule,
|
||||
|
|
@ -38,9 +39,11 @@ export const plantMutations = {
|
|||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
const plaintextSnapshot = toPlant(newLocal);
|
||||
await encryptRecord('plants', newLocal);
|
||||
await db.table('plants').add(newLocal);
|
||||
PlantaEvents.plantCreated();
|
||||
return toPlant(newLocal);
|
||||
return plaintextSnapshot;
|
||||
} catch (e) {
|
||||
console.error('Failed to create plant:', e);
|
||||
return null;
|
||||
|
|
@ -63,9 +66,15 @@ export const plantMutations = {
|
|||
updateData.wateringFrequencyDays = dto.wateringFrequencyDays ?? null;
|
||||
if (dto.humidity !== undefined) updateData.humidity = dto.humidity ?? null;
|
||||
|
||||
await encryptRecord('plants', updateData);
|
||||
await db.table('plants').update(id, updateData);
|
||||
// Re-read decrypts via the queries layer if a query is consumed.
|
||||
// Direct returns from this function need explicit decryption.
|
||||
const { decryptRecord } = await import('$lib/data/crypto');
|
||||
const updated = await db.table<LocalPlant>('plants').get(id);
|
||||
return updated ? toPlant(updated) : null;
|
||||
if (!updated) return null;
|
||||
const decrypted = await decryptRecord('plants', { ...updated });
|
||||
return toPlant(decrypted);
|
||||
} catch (e) {
|
||||
console.error('Failed to update plant:', e);
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
|
||||
import { liveQuery } from 'dexie';
|
||||
import { db } from '$lib/data/database';
|
||||
import { decryptRecords } from '$lib/data/crypto';
|
||||
import type {
|
||||
LocalPlant,
|
||||
LocalPlantPhoto,
|
||||
|
|
@ -93,8 +94,9 @@ export function toWateringLog(local: LocalWateringLog): WateringLog {
|
|||
/** All plants. Auto-updates on any change. */
|
||||
export function useAllPlants() {
|
||||
return liveQuery(async () => {
|
||||
const locals = await db.table<LocalPlant>('plants').toArray();
|
||||
return locals.filter((p) => !p.deletedAt).map(toPlant);
|
||||
const visible = (await db.table<LocalPlant>('plants').toArray()).filter((p) => !p.deletedAt);
|
||||
const decrypted = await decryptRecords('plants', visible);
|
||||
return decrypted.map(toPlant);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
|
||||
import { liveQuery } from 'dexie';
|
||||
import { db } from '$lib/data/database';
|
||||
import { decryptRecord, decryptRecords } from '$lib/data/crypto';
|
||||
import type { LocalDeck, LocalSlide, Deck, Slide } from './types';
|
||||
|
||||
// ─── Type Converters ──────────────────────────────────────
|
||||
|
|
@ -39,9 +40,9 @@ export function toSlide(local: LocalSlide): Slide {
|
|||
/** All decks, sorted by updatedAt descending. Auto-updates on any change. */
|
||||
export function useAllDecks() {
|
||||
return liveQuery(async () => {
|
||||
const locals = await db.table<LocalDeck>('presiDecks').toArray();
|
||||
return locals
|
||||
.filter((d) => !d.deletedAt)
|
||||
const visible = (await db.table<LocalDeck>('presiDecks').toArray()).filter((d) => !d.deletedAt);
|
||||
const decrypted = await decryptRecords('presiDecks', visible);
|
||||
return decrypted
|
||||
.map(toDeck)
|
||||
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
||||
});
|
||||
|
|
@ -50,11 +51,11 @@ export function useAllDecks() {
|
|||
/** Slides for a specific deck, sorted by order. Auto-updates on any change. */
|
||||
export function useDeckSlides(deckId: string) {
|
||||
return liveQuery(async () => {
|
||||
const locals = await db.table<LocalSlide>('slides').where('deckId').equals(deckId).toArray();
|
||||
return locals
|
||||
.filter((s) => !s.deletedAt)
|
||||
.map(toSlide)
|
||||
.sort((a, b) => a.order - b.order);
|
||||
const visible = (
|
||||
await db.table<LocalSlide>('slides').where('deckId').equals(deckId).toArray()
|
||||
).filter((s) => !s.deletedAt);
|
||||
const decrypted = await decryptRecords('slides', visible);
|
||||
return decrypted.map(toSlide).sort((a, b) => a.order - b.order);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -63,7 +64,8 @@ export function useDeck(id: string) {
|
|||
return liveQuery(async () => {
|
||||
const local = await db.table<LocalDeck>('presiDecks').get(id);
|
||||
if (!local || local.deletedAt) return null;
|
||||
return toDeck(local);
|
||||
const decrypted = await decryptRecord('presiDecks', { ...local });
|
||||
return toDeck(decrypted);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { db } from '$lib/data/database';
|
|||
import { presiDeckTable, slideTable } from '../collections';
|
||||
import { toDeck, toSlide } from '../queries';
|
||||
import { PresiEvents } from '@mana/shared-utils/analytics';
|
||||
import { encryptRecord } from '$lib/data/crypto';
|
||||
import type {
|
||||
LocalDeck,
|
||||
LocalSlide,
|
||||
|
|
@ -35,9 +36,11 @@ function createDecksStore() {
|
|||
themeId: dto.themeId || null,
|
||||
isPublic: false,
|
||||
};
|
||||
const plaintextSnapshot = toDeck(newLocal);
|
||||
await encryptRecord('presiDecks', newLocal);
|
||||
await presiDeckTable.add(newLocal);
|
||||
PresiEvents.deckCreated();
|
||||
return toDeck(newLocal);
|
||||
return plaintextSnapshot;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to create deck';
|
||||
console.error('Failed to create deck:', e);
|
||||
|
|
@ -58,6 +61,7 @@ function createDecksStore() {
|
|||
if (dto.themeId !== undefined) localUpdates.themeId = dto.themeId;
|
||||
if (dto.isPublic !== undefined) localUpdates.isPublic = dto.isPublic;
|
||||
|
||||
await encryptRecord('presiDecks', localUpdates);
|
||||
await presiDeckTable.update(id, localUpdates);
|
||||
return true;
|
||||
} catch (e) {
|
||||
|
|
@ -100,9 +104,11 @@ function createDecksStore() {
|
|||
order,
|
||||
content: dto.content,
|
||||
};
|
||||
const plaintextSnapshot = toSlide(newLocal);
|
||||
await encryptRecord('slides', newLocal);
|
||||
await slideTable.add(newLocal);
|
||||
PresiEvents.slideCreated();
|
||||
return toSlide(newLocal);
|
||||
return plaintextSnapshot;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to create slide';
|
||||
console.error('Failed to create slide:', e);
|
||||
|
|
@ -119,6 +125,7 @@ function createDecksStore() {
|
|||
if (dto.content !== undefined) localUpdates.content = dto.content;
|
||||
if (dto.order !== undefined) localUpdates.order = dto.order;
|
||||
|
||||
await encryptRecord('slides', localUpdates);
|
||||
await slideTable.update(id, localUpdates);
|
||||
return true;
|
||||
} catch (e) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue