mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
Merge acbf402cfd into 3f7b9667c6
This commit is contained in:
commit
551429b4cf
6 changed files with 239 additions and 81 deletions
|
|
@ -16,8 +16,9 @@ import {
|
|||
import {
|
||||
acceptMemorySuggestion,
|
||||
clearMemorySuggestion,
|
||||
clearPendingMemorySuggestion,
|
||||
createMemorySuggestionPayload,
|
||||
hasAcceptedSupermemoryContext,
|
||||
serializeMemoriesForDataset,
|
||||
setMemoryMarkerStatus,
|
||||
showLoadingSuggestion,
|
||||
showMarkerPopover,
|
||||
|
|
@ -164,21 +165,19 @@ async function getRelatedMemoriesForChatGPT(actionSource: string) {
|
|||
const userQuery =
|
||||
document.getElementById("prompt-textarea")?.textContent || ""
|
||||
|
||||
const icon = document.querySelectorAll(
|
||||
const iconElement = document.querySelectorAll(
|
||||
'[id*="sm-chatgpt-input-bar-element-before-composer"]',
|
||||
)[0]
|
||||
|
||||
const iconElement = icon as HTMLElement
|
||||
)[0] as HTMLElement | undefined
|
||||
const currentPromptElement = document.getElementById("prompt-textarea")
|
||||
clearPendingMemorySuggestion("chatgpt", currentPromptElement, iconElement)
|
||||
|
||||
if (!iconElement) {
|
||||
console.warn("ChatGPT icon element not found, cannot update feedback")
|
||||
return
|
||||
}
|
||||
|
||||
if (isAutoSearch) {
|
||||
const promptElement = document.getElementById("prompt-textarea")
|
||||
if (promptElement) {
|
||||
showLoadingSuggestion("chatgpt", promptElement)
|
||||
if (currentPromptElement) {
|
||||
showLoadingSuggestion("chatgpt", currentPromptElement)
|
||||
}
|
||||
setMemoryMarkerStatus(iconElement, "searching")
|
||||
} else {
|
||||
|
|
@ -201,21 +200,23 @@ async function getRelatedMemoriesForChatGPT(actionSource: string) {
|
|||
timeoutPromise,
|
||||
])
|
||||
|
||||
if (response?.success && response?.data) {
|
||||
const memorySuggestion = response?.success
|
||||
? createMemorySuggestionPayload(response.data)
|
||||
: null
|
||||
|
||||
if (memorySuggestion) {
|
||||
const promptElement = document.getElementById("prompt-textarea")
|
||||
if (promptElement) {
|
||||
const memoryText = showMemorySuggestion(
|
||||
"chatgpt",
|
||||
promptElement,
|
||||
response.data,
|
||||
memorySuggestion,
|
||||
)
|
||||
debugChatGPT("memory suggestion rendered", {
|
||||
memoryLength: memoryText.length,
|
||||
})
|
||||
|
||||
iconElement.dataset.memoriesData = serializeMemoriesForDataset(
|
||||
response.data,
|
||||
)
|
||||
iconElement.dataset.memoriesData = memorySuggestion.memoriesData
|
||||
|
||||
if (isAutoSearch) {
|
||||
setMemoryMarkerStatus(iconElement, "found")
|
||||
|
|
@ -234,6 +235,11 @@ async function getRelatedMemoriesForChatGPT(actionSource: string) {
|
|||
}
|
||||
} else {
|
||||
console.warn("No memories found or API response invalid")
|
||||
clearPendingMemorySuggestion(
|
||||
"chatgpt",
|
||||
document.getElementById("prompt-textarea"),
|
||||
iconElement,
|
||||
)
|
||||
if (isAutoSearch) {
|
||||
setMemoryMarkerStatus(iconElement, "none")
|
||||
} else {
|
||||
|
|
@ -245,7 +251,12 @@ async function getRelatedMemoriesForChatGPT(actionSource: string) {
|
|||
try {
|
||||
const icon = document.querySelectorAll(
|
||||
'[id*="sm-chatgpt-input-bar-element-before-composer"]',
|
||||
)[0] as HTMLElement
|
||||
)[0] as HTMLElement | undefined
|
||||
clearPendingMemorySuggestion(
|
||||
"chatgpt",
|
||||
document.getElementById("prompt-textarea"),
|
||||
icon,
|
||||
)
|
||||
if (icon) {
|
||||
if (
|
||||
actionSource === POSTHOG_EVENT_KEY.CHATGPT_CHAT_MEMORIES_AUTO_SEARCHED
|
||||
|
|
|
|||
|
|
@ -16,8 +16,9 @@ import {
|
|||
import {
|
||||
acceptMemorySuggestion,
|
||||
clearMemorySuggestion,
|
||||
clearPendingMemorySuggestion,
|
||||
createMemorySuggestionPayload,
|
||||
hasAcceptedSupermemoryContext,
|
||||
serializeMemoriesForDataset,
|
||||
setMemoryMarkerStatus,
|
||||
showLoadingSuggestion,
|
||||
showMarkerPopover,
|
||||
|
|
@ -401,24 +402,25 @@ async function getRelatedMemoriesForClaude(actionSource: string) {
|
|||
queryLength: userQuery.length,
|
||||
})
|
||||
|
||||
const iconElement = document.querySelector(
|
||||
'[id*="sm-claude-input-bar-element"]',
|
||||
) as HTMLElement | null
|
||||
const currentPromptInput = getClaudePromptInput()
|
||||
clearPendingMemorySuggestion("claude", currentPromptInput, iconElement)
|
||||
|
||||
if (!userQuery.trim()) {
|
||||
debugClaude("memory search skipped because query is empty")
|
||||
return
|
||||
}
|
||||
|
||||
const icon = document.querySelector('[id*="sm-claude-input-bar-element"]')
|
||||
|
||||
const iconElement = icon as HTMLElement
|
||||
|
||||
if (!iconElement) {
|
||||
console.warn("Claude icon element not found, cannot update feedback")
|
||||
return
|
||||
}
|
||||
|
||||
if (isAutoSearch) {
|
||||
const input = getClaudePromptInput()
|
||||
if (input) {
|
||||
showLoadingSuggestion("claude", input)
|
||||
if (currentPromptInput) {
|
||||
showLoadingSuggestion("claude", currentPromptInput)
|
||||
}
|
||||
setMemoryMarkerStatus(iconElement, "searching")
|
||||
} else {
|
||||
|
|
@ -445,24 +447,24 @@ async function getRelatedMemoriesForClaude(actionSource: string) {
|
|||
success: response?.success,
|
||||
})
|
||||
|
||||
if (response?.success && response?.data) {
|
||||
const textareaElement = document.querySelector(
|
||||
'div[contenteditable="true"]',
|
||||
) as HTMLElement
|
||||
const memorySuggestion = response?.success
|
||||
? createMemorySuggestionPayload(response.data)
|
||||
: null
|
||||
|
||||
if (memorySuggestion) {
|
||||
const textareaElement = getClaudePromptInput()
|
||||
|
||||
if (textareaElement) {
|
||||
const memoryText = showMemorySuggestion(
|
||||
"claude",
|
||||
textareaElement,
|
||||
response.data,
|
||||
memorySuggestion,
|
||||
)
|
||||
debugClaude("memory suggestion rendered", {
|
||||
memoryLength: memoryText.length,
|
||||
})
|
||||
|
||||
iconElement.dataset.memoriesData = serializeMemoriesForDataset(
|
||||
response.data,
|
||||
)
|
||||
iconElement.dataset.memoriesData = memorySuggestion.memoriesData
|
||||
|
||||
if (isAutoSearch) {
|
||||
setMemoryMarkerStatus(iconElement, "found")
|
||||
|
|
@ -481,6 +483,11 @@ async function getRelatedMemoriesForClaude(actionSource: string) {
|
|||
}
|
||||
} else {
|
||||
console.warn("No memories found or API response invalid for Claude")
|
||||
clearPendingMemorySuggestion(
|
||||
"claude",
|
||||
getClaudePromptInput(),
|
||||
iconElement,
|
||||
)
|
||||
if (isAutoSearch) {
|
||||
setMemoryMarkerStatus(iconElement, "none")
|
||||
} else {
|
||||
|
|
@ -492,7 +499,8 @@ async function getRelatedMemoriesForClaude(actionSource: string) {
|
|||
try {
|
||||
const icon = document.querySelector(
|
||||
'[id*="sm-claude-input-bar-element"]',
|
||||
) as HTMLElement
|
||||
) as HTMLElement | null
|
||||
clearPendingMemorySuggestion("claude", getClaudePromptInput(), icon)
|
||||
if (icon) {
|
||||
if (
|
||||
actionSource === POSTHOG_EVENT_KEY.CLAUDE_CHAT_MEMORIES_AUTO_SEARCHED
|
||||
|
|
|
|||
|
|
@ -16,8 +16,9 @@ import {
|
|||
import {
|
||||
acceptMemorySuggestion,
|
||||
clearMemorySuggestion,
|
||||
clearPendingMemorySuggestion,
|
||||
createMemorySuggestionPayload,
|
||||
hasAcceptedSupermemoryContext,
|
||||
serializeMemoriesForDataset,
|
||||
setMemoryMarkerStatus,
|
||||
showLoadingSuggestion,
|
||||
showMarkerPopover,
|
||||
|
|
@ -376,15 +377,16 @@ async function getRelatedMemoriesForGemini(actionSource: string) {
|
|||
queryLength: userQuery.length,
|
||||
})
|
||||
|
||||
const iconElement = document.querySelector(
|
||||
`[id*="${ELEMENT_IDS.GEMINI_INPUT_BAR_ELEMENT}"]`,
|
||||
) as HTMLElement | null
|
||||
clearPendingMemorySuggestion("gemini", input, iconElement)
|
||||
|
||||
if (!userQuery) {
|
||||
debugGemini("memory search skipped because query is empty")
|
||||
return
|
||||
}
|
||||
|
||||
const iconElement = document.querySelector(
|
||||
`[id*="${ELEMENT_IDS.GEMINI_INPUT_BAR_ELEMENT}"]`,
|
||||
) as HTMLElement | null
|
||||
|
||||
if (!iconElement) {
|
||||
console.warn("Gemini icon element not found, cannot update feedback")
|
||||
return
|
||||
|
|
@ -412,15 +414,17 @@ async function getRelatedMemoriesForGemini(actionSource: string) {
|
|||
actionSource,
|
||||
}),
|
||||
timeoutPromise,
|
||||
])) as { success?: boolean; data?: string }
|
||||
])) as { success?: boolean; data?: unknown }
|
||||
|
||||
debugGemini("memory search response", response)
|
||||
|
||||
if (response?.success && response?.data && input) {
|
||||
const memoryText = showMemorySuggestion("gemini", input, response.data)
|
||||
iconElement.dataset.memoriesData = serializeMemoriesForDataset(
|
||||
response.data,
|
||||
)
|
||||
const memorySuggestion = response?.success
|
||||
? createMemorySuggestionPayload(response.data)
|
||||
: null
|
||||
|
||||
if (memorySuggestion && input) {
|
||||
const memoryText = showMemorySuggestion("gemini", input, memorySuggestion)
|
||||
iconElement.dataset.memoriesData = memorySuggestion.memoriesData
|
||||
iconElement.dataset.supermemories = memoryText
|
||||
if (isAutoSearch) {
|
||||
setMemoryMarkerStatus(iconElement, "found")
|
||||
|
|
@ -430,6 +434,8 @@ async function getRelatedMemoriesForGemini(actionSource: string) {
|
|||
return
|
||||
}
|
||||
|
||||
clearPendingMemorySuggestion("gemini", input, iconElement)
|
||||
|
||||
if (isAutoSearch) {
|
||||
setMemoryMarkerStatus(iconElement, "none")
|
||||
} else {
|
||||
|
|
@ -440,6 +446,7 @@ async function getRelatedMemoriesForGemini(actionSource: string) {
|
|||
const iconElement = document.querySelector(
|
||||
`[id*="${ELEMENT_IDS.GEMINI_INPUT_BAR_ELEMENT}"]`,
|
||||
) as HTMLElement | null
|
||||
clearPendingMemorySuggestion("gemini", getGeminiPromptInput(), iconElement)
|
||||
if (iconElement) {
|
||||
if (
|
||||
actionSource === POSTHOG_EVENT_KEY.GEMINI_CHAT_MEMORIES_AUTO_SEARCHED
|
||||
|
|
|
|||
|
|
@ -0,0 +1,77 @@
|
|||
import { describe, expect, it } from "bun:test"
|
||||
import {
|
||||
acceptMemorySuggestion,
|
||||
buildSupermemoryText,
|
||||
clearPendingMemoryState,
|
||||
createMemorySuggestionPayload,
|
||||
serializeMemoriesForDataset,
|
||||
} from "./memory-suggestion"
|
||||
|
||||
describe("memory suggestion formatting", () => {
|
||||
it("does not build an injectable prompt for empty memories", () => {
|
||||
for (const memories of [[], [" ", "\n"], " ", null, undefined]) {
|
||||
expect(serializeMemoriesForDataset(memories)).toBe("")
|
||||
expect(buildSupermemoryText(memories)).toBe("")
|
||||
expect(createMemorySuggestionPayload(memories)).toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it("keeps the existing prompt format for non-empty memories", () => {
|
||||
const memories = ["1. First memory \n", "2. Second memory \n"]
|
||||
const suggestionText =
|
||||
"\n\nSupermemories of user (only for the reference): 1. First memory \n2. Second memory"
|
||||
|
||||
expect(buildSupermemoryText(memories)).toBe(suggestionText)
|
||||
expect(createMemorySuggestionPayload(memories)).toEqual({
|
||||
suggestionText,
|
||||
memoriesData: JSON.stringify(["1. First memory", "2. Second memory"]),
|
||||
})
|
||||
})
|
||||
|
||||
it("clears only pending memory state", () => {
|
||||
let popoverRemoved = false
|
||||
const input = {
|
||||
dataset: {
|
||||
supermemories: "old pending memories",
|
||||
supermemoriesInjected: "true",
|
||||
},
|
||||
}
|
||||
const icon = {
|
||||
dataset: {
|
||||
memoriesData: '["old pending memories"]',
|
||||
supermemories: "old suggestion",
|
||||
},
|
||||
querySelector(selector: string) {
|
||||
expect(selector).toBe("[data-supermemory-marker-popover]")
|
||||
return {
|
||||
remove() {
|
||||
popoverRemoved = true
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
clearPendingMemoryState(input, icon)
|
||||
|
||||
expect(input.dataset.supermemories).toBeUndefined()
|
||||
expect(input.dataset.supermemoriesInjected).toBe("true")
|
||||
expect(icon.dataset.memoriesData).toBeUndefined()
|
||||
expect(icon.dataset.supermemories).toBeUndefined()
|
||||
expect(popoverRemoved).toBe(true)
|
||||
|
||||
let prevented = false
|
||||
const accepted = acceptMemorySuggestion(
|
||||
{
|
||||
key: "Tab",
|
||||
preventDefault() {
|
||||
prevented = true
|
||||
},
|
||||
stopPropagation() {},
|
||||
} as KeyboardEvent,
|
||||
"chatgpt",
|
||||
input as unknown as HTMLElement,
|
||||
)
|
||||
expect(accepted).toBe(false)
|
||||
expect(prevented).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
@ -4,12 +4,17 @@ const SUGGESTION_ATTR = "data-supermemory-memory-suggestion"
|
|||
const SUPERMEMORY_PREFIX = "Supermemories of user (only for the reference):"
|
||||
const SUPERMEMORY_BLUE = "#1A88FF"
|
||||
|
||||
export function buildSupermemoryText(memories: unknown): string {
|
||||
const memoryText = Array.isArray(memories)
|
||||
? memories.join("").trim()
|
||||
: String(memories || "").trim()
|
||||
interface DatasetTarget {
|
||||
dataset: Record<string, string | undefined>
|
||||
}
|
||||
|
||||
return `\n\n${SUPERMEMORY_PREFIX} ${memoryText}`
|
||||
interface PendingMemoryIcon extends DatasetTarget {
|
||||
querySelector(selectors: string): { remove(): void } | null
|
||||
}
|
||||
|
||||
export interface MemorySuggestionPayload {
|
||||
suggestionText: string
|
||||
memoriesData: string
|
||||
}
|
||||
|
||||
function normalizeMemoryList(memories: unknown): string[] {
|
||||
|
|
@ -29,6 +34,39 @@ export function serializeMemoriesForDataset(memories: unknown): string {
|
|||
return list.length > 0 ? JSON.stringify(list) : ""
|
||||
}
|
||||
|
||||
export function createMemorySuggestionPayload(
|
||||
memories: unknown,
|
||||
): MemorySuggestionPayload | null {
|
||||
const memoriesData = serializeMemoriesForDataset(memories)
|
||||
if (!memoriesData) return null
|
||||
|
||||
const memoryText = Array.isArray(memories)
|
||||
? memories.join("").trim()
|
||||
: String(memories ?? "").trim()
|
||||
if (!memoryText) return null
|
||||
|
||||
return {
|
||||
suggestionText: `\n\n${SUPERMEMORY_PREFIX} ${memoryText}`,
|
||||
memoriesData,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildSupermemoryText(memories: unknown): string {
|
||||
return createMemorySuggestionPayload(memories)?.suggestionText ?? ""
|
||||
}
|
||||
|
||||
export function clearPendingMemoryState(
|
||||
input: DatasetTarget | null | undefined,
|
||||
icon?: PendingMemoryIcon | null,
|
||||
) {
|
||||
if (input) delete input.dataset.supermemories
|
||||
if (icon) {
|
||||
delete icon.dataset.memoriesData
|
||||
delete icon.dataset.supermemories
|
||||
icon.querySelector("[data-supermemory-marker-popover]")?.remove()
|
||||
}
|
||||
}
|
||||
|
||||
export function parseMemoriesFromDataset(
|
||||
raw: string | null | undefined,
|
||||
): string[] {
|
||||
|
|
@ -55,9 +93,9 @@ export function renumberIncludedMemories(memories: string[]): string[] {
|
|||
export function showMemorySuggestion(
|
||||
platform: string,
|
||||
input: SuggestionInput,
|
||||
memories: unknown,
|
||||
payload: MemorySuggestionPayload,
|
||||
): string {
|
||||
const suggestionText = buildSupermemoryText(memories)
|
||||
const suggestionText = payload.suggestionText
|
||||
input.dataset.supermemories = suggestionText
|
||||
delete input.dataset.supermemoriesInjected
|
||||
|
||||
|
|
@ -185,6 +223,15 @@ export function removeMemorySuggestion(platform: string) {
|
|||
}
|
||||
}
|
||||
|
||||
export function clearPendingMemorySuggestion(
|
||||
platform: string,
|
||||
input: SuggestionInput | null,
|
||||
icon?: HTMLElement | null,
|
||||
) {
|
||||
removeMemorySuggestion(platform)
|
||||
clearPendingMemoryState(input, icon)
|
||||
}
|
||||
|
||||
export function acceptMemorySuggestion(
|
||||
event: KeyboardEvent,
|
||||
platform: string,
|
||||
|
|
@ -230,10 +277,7 @@ export function clearMemorySuggestion(
|
|||
platform: string,
|
||||
input: SuggestionInput | null,
|
||||
) {
|
||||
removeMemorySuggestion(platform)
|
||||
if (input?.dataset.supermemories) {
|
||||
delete input.dataset.supermemories
|
||||
}
|
||||
clearPendingMemorySuggestion(platform, input)
|
||||
if (input?.dataset.supermemoriesInjected) {
|
||||
delete input.dataset.supermemoriesInjected
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ import {
|
|||
import { createT3InputBarElement, DOMUtils } from "../../utils/ui-components"
|
||||
import {
|
||||
buildSupermemoryText,
|
||||
clearPendingMemoryState,
|
||||
createMemorySuggestionPayload,
|
||||
parseMemoriesFromDataset,
|
||||
renumberIncludedMemories,
|
||||
serializeMemoriesForDataset,
|
||||
|
|
@ -35,6 +37,24 @@ function disposeT3IncludedPopup() {
|
|||
t3IncludedPopup = null
|
||||
}
|
||||
|
||||
function getT3PromptInput(): HTMLTextAreaElement | HTMLElement | null {
|
||||
const supermemoryContainer = document.querySelector(
|
||||
'[data-supermemory-icon-added="true"]',
|
||||
)
|
||||
const composerTextarea =
|
||||
supermemoryContainer?.parentElement?.previousElementSibling?.querySelector(
|
||||
"textarea",
|
||||
) as HTMLTextAreaElement | null
|
||||
|
||||
return (
|
||||
composerTextarea ||
|
||||
(document.querySelector('div[contenteditable="true"]') as
|
||||
| HTMLTextAreaElement
|
||||
| HTMLElement
|
||||
| null)
|
||||
)
|
||||
}
|
||||
|
||||
export function initializeT3() {
|
||||
if (!DOMUtils.isOnDomain(DOMAINS.T3)) {
|
||||
return
|
||||
|
|
@ -201,14 +221,16 @@ async function getRelatedMemoriesForT3(actionSource: string) {
|
|||
}
|
||||
}
|
||||
|
||||
const iconElement = document.querySelector(
|
||||
'[id*="sm-t3-input-bar-element"]',
|
||||
) as HTMLElement | null
|
||||
clearPendingMemoryState(getT3PromptInput(), iconElement)
|
||||
disposeT3IncludedPopup()
|
||||
|
||||
if (!userQuery.trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
const icon = document.querySelector('[id*="sm-t3-input-bar-element"]')
|
||||
|
||||
const iconElement = icon as HTMLElement
|
||||
|
||||
if (!iconElement) {
|
||||
console.warn("T3 icon element not found, cannot update feedback")
|
||||
return
|
||||
|
|
@ -232,32 +254,17 @@ async function getRelatedMemoriesForT3(actionSource: string) {
|
|||
timeoutPromise,
|
||||
])
|
||||
|
||||
if (response?.success && response?.data) {
|
||||
let textareaElement = null
|
||||
const supermemoryContainer = document.querySelector(
|
||||
'[data-supermemory-icon-added="true"]',
|
||||
)
|
||||
if (supermemoryContainer?.parentElement?.previousElementSibling) {
|
||||
textareaElement =
|
||||
supermemoryContainer.parentElement.previousElementSibling.querySelector(
|
||||
"textarea",
|
||||
)
|
||||
}
|
||||
const memorySuggestion = response?.success
|
||||
? createMemorySuggestionPayload(response.data)
|
||||
: null
|
||||
|
||||
if (!textareaElement) {
|
||||
textareaElement = document.querySelector(
|
||||
'div[contenteditable="true"]',
|
||||
) as HTMLElement
|
||||
}
|
||||
if (memorySuggestion) {
|
||||
const textareaElement = getT3PromptInput()
|
||||
|
||||
if (textareaElement) {
|
||||
textareaElement.dataset.supermemories = buildSupermemoryText(
|
||||
response.data,
|
||||
)
|
||||
textareaElement.dataset.supermemories = memorySuggestion.suggestionText
|
||||
|
||||
iconElement.dataset.memoriesData = serializeMemoriesForDataset(
|
||||
response.data,
|
||||
)
|
||||
iconElement.dataset.memoriesData = memorySuggestion.memoriesData
|
||||
|
||||
updateT3IconFeedback("Included Memories", iconElement)
|
||||
} else {
|
||||
|
|
@ -266,6 +273,8 @@ async function getRelatedMemoriesForT3(actionSource: string) {
|
|||
}
|
||||
} else {
|
||||
console.warn("No memories found or API response invalid for T3")
|
||||
clearPendingMemoryState(getT3PromptInput(), iconElement)
|
||||
disposeT3IncludedPopup()
|
||||
updateT3IconFeedback("No memories found", iconElement)
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -273,7 +282,9 @@ async function getRelatedMemoriesForT3(actionSource: string) {
|
|||
try {
|
||||
const icon = document.querySelector(
|
||||
'[id*="sm-t3-input-bar-element"]',
|
||||
) as HTMLElement
|
||||
) as HTMLElement | null
|
||||
clearPendingMemoryState(getT3PromptInput(), icon)
|
||||
disposeT3IncludedPopup()
|
||||
if (icon) {
|
||||
updateT3IconFeedback("Error fetching memories", icon)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue