mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-07 02:14:32 +00:00
file generator
This commit is contained in:
parent
f80bf7a423
commit
c41dc21292
3 changed files with 504 additions and 0 deletions
|
|
@ -0,0 +1,63 @@
|
|||
#!/usr/bin/env bun
|
||||
|
||||
import { SimulationFileGenerator } from "./file-generator"
|
||||
|
||||
function arg(name: string) {
|
||||
const prefix = `--${name}=`
|
||||
return Bun.argv.find((item) => item.startsWith(prefix))?.slice(prefix.length)
|
||||
}
|
||||
|
||||
function numberArg(name: string, fallback: number) {
|
||||
const value = Number(arg(name))
|
||||
return Number.isFinite(value) ? value : fallback
|
||||
}
|
||||
|
||||
function weight(name: string) {
|
||||
const value = Number(arg(name))
|
||||
return Number.isFinite(value) ? value : undefined
|
||||
}
|
||||
|
||||
const generated = SimulationFileGenerator.generateFiles({
|
||||
seed: numberArg("seed", Math.random() * 10000 | 0),
|
||||
count: numberArg("count", 10),
|
||||
maxDepth: numberArg("max-depth", 3),
|
||||
maxWidth: numberArg("max-width", 5),
|
||||
root: arg("root") ?? "src",
|
||||
weights: {
|
||||
txt: weight("txt"),
|
||||
ts: weight("ts"),
|
||||
shallow: weight("shallow"),
|
||||
deep: weight("deep"),
|
||||
},
|
||||
})
|
||||
|
||||
const patches = SimulationFileGenerator.generatePatches(generated, {
|
||||
seed: numberArg("patch-seed", numberArg("seed", 1) + 1),
|
||||
count: numberArg("patch-count", 3),
|
||||
patchDir: arg("patch-dir") ?? ".simulation/patches",
|
||||
weights: {
|
||||
editTxt: weight("edit-txt"),
|
||||
editTs: weight("edit-ts"),
|
||||
},
|
||||
})
|
||||
|
||||
if (Bun.argv.includes("--json")) {
|
||||
console.log(JSON.stringify({ generated, patches }, null, 2))
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
console.log(`seed: ${generated.seed}`)
|
||||
console.log(`files: ${generated.entries.length}`)
|
||||
for (const entry of generated.entries) {
|
||||
console.log(`${entry.path}: ${entry.content}\n`)
|
||||
}
|
||||
|
||||
console.log(`\npatch seed: ${patches.seed}`)
|
||||
console.log(`patches: ${patches.patches.length}`)
|
||||
for (const patch of patches.patches) {
|
||||
console.log('--------------------------------------------------------------------')
|
||||
console.log(patch.patch)
|
||||
}
|
||||
|
||||
console.log("\nTry: bun src/testing/simulation/file-generator.play.ts --seed=42 --count=5 --ts=4 --txt=1 --deep=3")
|
||||
console.log("JSON: bun src/testing/simulation/file-generator.play.ts --json")
|
||||
397
packages/opencode/src/testing/simulation/file-generator.ts
Normal file
397
packages/opencode/src/testing/simulation/file-generator.ts
Normal file
|
|
@ -0,0 +1,397 @@
|
|||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
|
||||
export interface Weights {
|
||||
readonly txt?: number
|
||||
readonly ts?: number
|
||||
readonly shallow?: number
|
||||
readonly deep?: number
|
||||
readonly editTxt?: number
|
||||
readonly editTs?: number
|
||||
}
|
||||
|
||||
export interface GenerateFilesOptions {
|
||||
readonly seed?: number
|
||||
readonly count?: number
|
||||
readonly maxDepth?: number
|
||||
readonly maxWidth?: number
|
||||
readonly root?: string
|
||||
readonly weights?: Weights
|
||||
}
|
||||
|
||||
export interface GeneratedFile {
|
||||
readonly path: string
|
||||
readonly kind: "txt" | "ts"
|
||||
readonly content: string
|
||||
}
|
||||
|
||||
export interface GeneratedFiles {
|
||||
readonly seed: number
|
||||
readonly files: Record<string, string>
|
||||
readonly entries: readonly GeneratedFile[]
|
||||
}
|
||||
|
||||
export interface GeneratePatchesOptions {
|
||||
readonly seed?: number
|
||||
readonly count?: number
|
||||
readonly patchDir?: string
|
||||
readonly weights?: Weights
|
||||
}
|
||||
|
||||
export interface GeneratedPatch {
|
||||
readonly path: string
|
||||
readonly target: string
|
||||
readonly before: string
|
||||
readonly after: string
|
||||
readonly patch: string
|
||||
}
|
||||
|
||||
export interface GeneratedPatches {
|
||||
readonly seed: number
|
||||
readonly files: Record<string, string>
|
||||
readonly patchedFiles: Record<string, string>
|
||||
readonly patches: readonly GeneratedPatch[]
|
||||
}
|
||||
|
||||
type Random = () => number
|
||||
|
||||
const defaults = {
|
||||
txt: 1,
|
||||
ts: 1,
|
||||
shallow: 3,
|
||||
deep: 1,
|
||||
editTxt: 1,
|
||||
editTs: 1,
|
||||
}
|
||||
|
||||
function random(seed: number): Random {
|
||||
let current = seed
|
||||
return () => {
|
||||
current = (current * 1664525 + 1013904223) >>> 0
|
||||
return current / 0x100000000
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSeed(value: number | undefined) {
|
||||
return value ?? Math.floor(Math.random() * 0xffffffff) + 1
|
||||
}
|
||||
|
||||
function integer(rng: Random, min: number, max: number) {
|
||||
return min + Math.floor(rng() * (max - min + 1))
|
||||
}
|
||||
|
||||
function pickWeighted<T extends string>(rng: Random, entries: readonly (readonly [T, number])[]) {
|
||||
const total = entries.reduce((sum, item) => sum + Math.max(0, item[1]), 0)
|
||||
const threshold = rng() * (total || entries.length)
|
||||
const picked = entries.reduce<{ value: T; remaining: number }>(
|
||||
(state, item) =>
|
||||
state.remaining <= 0 ? state : { value: item[0], remaining: state.remaining - Math.max(0, item[1]) },
|
||||
{ value: entries[0][0], remaining: threshold },
|
||||
)
|
||||
return picked.remaining <= 0 ? picked.value : entries.at(-1)![0]
|
||||
}
|
||||
|
||||
function slug(rng: Random) {
|
||||
const words = ["alpha", "bravo", "cedar", "delta", "ember", "field", "glade", "harbor", "iris", "juniper"]
|
||||
return `${words[integer(rng, 0, words.length - 1)]}-${integer(rng, 1, 99)}`
|
||||
}
|
||||
|
||||
function generatedPath(
|
||||
rng: Random,
|
||||
input: { root: string; kind: "txt" | "ts"; maxDepth: number; maxWidth: number; weights: Weights },
|
||||
) {
|
||||
const depthBias = pickWeighted(rng, [
|
||||
["shallow", input.weights.shallow ?? defaults.shallow],
|
||||
["deep", input.weights.deep ?? defaults.deep],
|
||||
])
|
||||
const depth = depthBias === "shallow" ? integer(rng, 0, Math.min(1, input.maxDepth)) : integer(rng, 1, input.maxDepth)
|
||||
return path.posix.join(
|
||||
input.root,
|
||||
...Array.from({ length: depth }, () => `dir-${integer(rng, 1, input.maxWidth)}`),
|
||||
`${slug(rng)}.${input.kind}`,
|
||||
)
|
||||
}
|
||||
|
||||
function txtContent(rng: Random, name: string) {
|
||||
return Array.from(
|
||||
{ length: integer(rng, 2, 6) },
|
||||
(_, index) => `${name} note ${index + 1}: ${slug(rng)} ${slug(rng)}.`,
|
||||
).join("\n")
|
||||
}
|
||||
|
||||
function tsContent(rng: Random, name: string) {
|
||||
const fn = name.replace(/[^a-zA-Z0-9]/g, "_")
|
||||
const templates = [
|
||||
() => [
|
||||
`export const ${fn}Value = ${integer(rng, 1, 100)}`,
|
||||
"",
|
||||
`export function ${fn}Message(input = \"${slug(rng)}\") {`,
|
||||
` return \`${name}: \${input}\``,
|
||||
"}",
|
||||
"",
|
||||
],
|
||||
() => [
|
||||
`export interface ${fn}Record {`,
|
||||
" readonly id: string",
|
||||
" readonly enabled: boolean",
|
||||
" readonly tags: readonly string[]",
|
||||
"}",
|
||||
"",
|
||||
`export const ${fn}Records: readonly ${fn}Record[] = [`,
|
||||
` { id: \"${slug(rng)}\", enabled: ${rng() > 0.5}, tags: [\"${slug(rng)}\", \"${slug(rng)}\"] },`,
|
||||
` { id: \"${slug(rng)}\", enabled: ${rng() > 0.5}, tags: [] },`,
|
||||
"]",
|
||||
"",
|
||||
`export function ${fn}Enabled() {`,
|
||||
` return ${fn}Records.filter((item) => item.enabled)`,
|
||||
"}",
|
||||
"",
|
||||
],
|
||||
() => [
|
||||
`export type ${fn}Event =`,
|
||||
` | { readonly type: \"created\"; readonly id: string; readonly count: number }`,
|
||||
` | { readonly type: \"updated\"; readonly id: string; readonly fields: readonly string[] }`,
|
||||
` | { readonly type: \"deleted\"; readonly id: string }`,
|
||||
"",
|
||||
`export function ${fn}Label(event: ${fn}Event) {`,
|
||||
` if (event.type === \"created\") return \`created:\${event.id}:\${event.count}\``,
|
||||
` if (event.type === \"updated\") return \`updated:\${event.id}:\${event.fields.length}\``,
|
||||
` return \`deleted:\${event.id}\``,
|
||||
"}",
|
||||
"",
|
||||
`export const ${fn}Sample: ${fn}Event = { type: \"created\", id: \"${slug(rng)}\", count: ${integer(rng, 1, 20)} }`,
|
||||
"",
|
||||
],
|
||||
() => [
|
||||
`const ${fn}Defaults = {`,
|
||||
` retries: ${integer(rng, 1, 5)},`,
|
||||
` timeout: ${integer(rng, 100, 900)},`,
|
||||
` label: \"${slug(rng)}\",`,
|
||||
"} as const",
|
||||
"",
|
||||
`export async function load${fn}(input: Partial<typeof ${fn}Defaults> = {}) {`,
|
||||
` const config = { ...${fn}Defaults, ...input }`,
|
||||
" await Promise.resolve()",
|
||||
" return {",
|
||||
" ...config,",
|
||||
" ready: config.retries > 0 && config.timeout > 0,",
|
||||
" }",
|
||||
"}",
|
||||
"",
|
||||
],
|
||||
() => [
|
||||
`export class ${fn}Store {`,
|
||||
" #items = new Map<string, number>()",
|
||||
"",
|
||||
" add(key: string, value: number) {",
|
||||
" this.#items.set(key, (this.#items.get(key) ?? 0) + value)",
|
||||
" return this",
|
||||
" }",
|
||||
"",
|
||||
" snapshot() {",
|
||||
" return Object.fromEntries(this.#items.entries())",
|
||||
" }",
|
||||
"}",
|
||||
"",
|
||||
`export const ${fn}StoreInstance = new ${fn}Store().add(\"${slug(rng)}\", ${integer(rng, 1, 10)})`,
|
||||
"",
|
||||
],
|
||||
() => [
|
||||
`export const ${fn}Matrix = [`,
|
||||
` [${integer(rng, 1, 9)}, ${integer(rng, 1, 9)}, ${integer(rng, 1, 9)}],`,
|
||||
` [${integer(rng, 1, 9)}, ${integer(rng, 1, 9)}, ${integer(rng, 1, 9)}],`,
|
||||
"] as const",
|
||||
"",
|
||||
`export const ${fn}Total = ${fn}Matrix.flatMap((row) => row).reduce((sum, value) => sum + value, 0)`,
|
||||
"",
|
||||
`export const ${fn}Lookup = new Map<string, number>([`,
|
||||
` [\"${slug(rng)}\", ${integer(rng, 10, 99)}],`,
|
||||
` [\"${slug(rng)}\", ${integer(rng, 10, 99)}],`,
|
||||
"])",
|
||||
"",
|
||||
],
|
||||
]
|
||||
return templates[integer(rng, 0, templates.length - 1)]().join("\n")
|
||||
}
|
||||
|
||||
function mutate(rng: Random, file: GeneratedFile) {
|
||||
const addHeavy = rng() > 0.5
|
||||
if (file.kind === "txt") {
|
||||
const lines = file.content.trimEnd().split("\n")
|
||||
const removeCount = Math.min(addHeavy ? integer(rng, 0, 1) : integer(rng, 2, 5), Math.max(0, lines.length - 1))
|
||||
const removed = new Set(Array.from({ length: removeCount }, () => integer(rng, 0, Math.max(0, lines.length - 1))))
|
||||
const editAt = integer(rng, 0, Math.max(0, lines.length - 1))
|
||||
const edited = lines
|
||||
.filter((_, index) => lines.length <= 1 || !removed.has(index))
|
||||
.map((line, index) => (index === editAt ? `${line} Extra detail: ${slug(rng)} ${slug(rng)}.` : line))
|
||||
return [
|
||||
...edited,
|
||||
"",
|
||||
...Array.from({ length: addHeavy ? integer(rng, 3, 6) : integer(rng, 0, 2) }, (_, index) =>
|
||||
[
|
||||
`## mutation ${integer(rng, 1, 999)}.${index + 1}`,
|
||||
`${slug(rng)} ${slug(rng)} ${slug(rng)}.`,
|
||||
`status=${rng() > 0.5 ? "active" : "pending"}`,
|
||||
].join("\n"),
|
||||
),
|
||||
"",
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
const lines = file.content.trimEnd().split("\n")
|
||||
const removable = lines
|
||||
.map((line, index) => ({ line, index }))
|
||||
.filter((item) => item.line.trim() && !item.line.trim().endsWith("{") && !item.line.trim().startsWith("}"))
|
||||
const removeCount = Math.min(addHeavy ? integer(rng, 0, 2) : integer(rng, 3, 8), Math.max(0, removable.length - 1))
|
||||
const removed = new Set(
|
||||
Array.from({ length: removeCount }, () => removable[integer(rng, 0, removable.length - 1)]?.index).filter(
|
||||
(item): item is number => item !== undefined,
|
||||
),
|
||||
)
|
||||
const renamed = lines
|
||||
.filter((_, index) => !removed.has(index))
|
||||
.map((line) =>
|
||||
line.includes("Value =") && rng() > 0.35 ? line.replace(/= \d+/, `= ${integer(rng, 101, 999)}`) : line,
|
||||
)
|
||||
const blocks = Array.from({ length: addHeavy ? integer(rng, 3, 6) : integer(rng, 0, 2) }, () => {
|
||||
const id = `generated${integer(rng, 100, 999)}`
|
||||
const templates = [
|
||||
() => [
|
||||
`export const ${id}Config = {`,
|
||||
` id: "${slug(rng)}",`,
|
||||
` retries: ${integer(rng, 1, 5)},`,
|
||||
` flags: ["${slug(rng)}", "${slug(rng)}"],`,
|
||||
"} as const",
|
||||
],
|
||||
() => [
|
||||
`export function ${id}Normalize(input: readonly string[]) {`,
|
||||
` return input.map((item) => item.trim()).filter(Boolean).join("${rng() > 0.5 ? "," : "|"}")`,
|
||||
"}",
|
||||
],
|
||||
() => [
|
||||
`export type ${id}State =`,
|
||||
` | { readonly ok: true; readonly value: "${slug(rng)}" }`,
|
||||
` | { readonly ok: false; readonly reason: "${slug(rng)}" }`,
|
||||
],
|
||||
() => [
|
||||
`export const ${id}Items = [`,
|
||||
` { key: "${slug(rng)}", value: ${integer(rng, 1, 50)} },`,
|
||||
` { key: "${slug(rng)}", value: ${integer(rng, 1, 50)} },`,
|
||||
` { key: "${slug(rng)}", value: ${integer(rng, 1, 50)} },`,
|
||||
"] as const",
|
||||
],
|
||||
]
|
||||
return templates[integer(rng, 0, templates.length - 1)]().join("\n")
|
||||
})
|
||||
const insertAt = rng() > 0.5 ? 0 : renamed.length
|
||||
return [
|
||||
...renamed.slice(0, insertAt),
|
||||
"",
|
||||
...blocks.flatMap((block) => [block, ""]),
|
||||
...renamed.slice(insertAt),
|
||||
"",
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
function gitPatch(file: string, before: string, after: string) {
|
||||
const dir = mkdtempSync(path.join(os.tmpdir(), "opencode-simulation-diff-"))
|
||||
mkdirSync(path.join(dir, "a", path.dirname(file)), { recursive: true })
|
||||
mkdirSync(path.join(dir, "b", path.dirname(file)), { recursive: true })
|
||||
writeFileSync(path.join(dir, "a", file), before)
|
||||
writeFileSync(path.join(dir, "b", file), after)
|
||||
const result = Bun.spawnSync(["git", "-C", dir, "diff", "--no-index", "--no-prefix", "--", `a/${file}`, `b/${file}`])
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
if (result.exitCode > 1) throw new Error(new TextDecoder().decode(result.stderr))
|
||||
return new TextDecoder().decode(result.stdout)
|
||||
}
|
||||
|
||||
function isGeneratedFiles(input: GeneratedFiles | Record<string, string>): input is GeneratedFiles {
|
||||
return Array.isArray(Reflect.get(input, "entries"))
|
||||
}
|
||||
|
||||
export function generateFiles(options: GenerateFilesOptions = {}): GeneratedFiles {
|
||||
const resolvedSeed = resolveSeed(options.seed)
|
||||
const rng = random(resolvedSeed)
|
||||
const weights = { ...defaults, ...options.weights }
|
||||
const count = options.count ?? 12
|
||||
const maxDepth = Math.max(0, options.maxDepth ?? 3)
|
||||
const maxWidth = Math.max(1, options.maxWidth ?? 5)
|
||||
const root = options.root ?? "src"
|
||||
const entries = Array.from({ length: count }).reduce<GeneratedFile[]>((result) => {
|
||||
const kind = pickWeighted(rng, [
|
||||
["txt", weights.txt],
|
||||
["ts", weights.ts],
|
||||
])
|
||||
const file = generatedPath(rng, { root, kind, maxDepth, maxWidth, weights })
|
||||
if (result.some((item) => item.path === file)) return result
|
||||
const name = path.posix.basename(file, path.posix.extname(file)).replace(/-/g, "_")
|
||||
return [...result, { path: file, kind, content: kind === "txt" ? txtContent(rng, name) : tsContent(rng, name) }]
|
||||
}, [])
|
||||
|
||||
return {
|
||||
seed: resolvedSeed,
|
||||
entries,
|
||||
files: Object.fromEntries(entries.map((entry) => [entry.path, entry.content])),
|
||||
}
|
||||
}
|
||||
|
||||
export function generatePatches(
|
||||
input: GeneratedFiles | Record<string, string>,
|
||||
options: GeneratePatchesOptions = {},
|
||||
): GeneratedPatches {
|
||||
const resolvedSeed = resolveSeed(options.seed)
|
||||
const rng = random(resolvedSeed)
|
||||
const weights = { ...defaults, ...options.weights }
|
||||
const sourceEntries: GeneratedFile[] = isGeneratedFiles(input)
|
||||
? [...input.entries]
|
||||
: Object.entries(input).map(([file, content]) => ({
|
||||
path: file,
|
||||
kind: file.endsWith(".ts") ? ("ts" as const) : ("txt" as const),
|
||||
content,
|
||||
}))
|
||||
const entries = sourceEntries
|
||||
.filter((entry) => entry.path.endsWith(".txt") || entry.path.endsWith(".ts"))
|
||||
.sort((a, b) => a.path.localeCompare(b.path))
|
||||
const selected = Array.from({ length: Math.min(options.count ?? 4, entries.length) }).reduce<GeneratedFile[]>(
|
||||
(result) => {
|
||||
const candidates = entries.filter((entry) => !result.some((item) => item.path === entry.path))
|
||||
if (candidates.length === 0) return result
|
||||
const targetKind = pickWeighted(rng, [
|
||||
["txt", weights.editTxt],
|
||||
["ts", weights.editTs],
|
||||
])
|
||||
const preferred = candidates.filter((entry) => entry.kind === targetKind)
|
||||
return [
|
||||
...result,
|
||||
(preferred.length ? preferred : candidates)[
|
||||
integer(rng, 0, (preferred.length ? preferred : candidates).length - 1)
|
||||
],
|
||||
]
|
||||
},
|
||||
[],
|
||||
)
|
||||
const patches = selected.map((entry, index) => {
|
||||
const after = mutate(rng, entry)
|
||||
const patch = gitPatch(entry.path, entry.content, after)
|
||||
return {
|
||||
path: path.posix.join(
|
||||
options.patchDir ?? ".simulation/patches",
|
||||
`${String(index + 1).padStart(3, "0")}-${path.posix.basename(entry.path)}.patch`,
|
||||
),
|
||||
target: entry.path,
|
||||
before: entry.content,
|
||||
after,
|
||||
patch,
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
seed: resolvedSeed,
|
||||
files: Object.fromEntries(patches.map((patch) => [patch.path, patch.patch])),
|
||||
patchedFiles: Object.fromEntries(patches.map((patch) => [patch.target, patch.after])),
|
||||
patches,
|
||||
}
|
||||
}
|
||||
|
||||
export * as SimulationFileGenerator from "./file-generator"
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { parsePatch } from "diff"
|
||||
import { SimulationFileGenerator } from "../../../src/testing/simulation/file-generator"
|
||||
|
||||
describe("SimulationFileGenerator", () => {
|
||||
test("generates deterministic txt and ts files", () => {
|
||||
const first = SimulationFileGenerator.generateFiles({ seed: 42, count: 8 })
|
||||
const second = SimulationFileGenerator.generateFiles({ seed: 42, count: 8 })
|
||||
|
||||
expect(first).toEqual(second)
|
||||
expect(first.entries.length).toBe(8)
|
||||
expect(first.entries.some((entry) => entry.kind === "txt")).toBe(true)
|
||||
expect(first.entries.some((entry) => entry.kind === "ts")).toBe(true)
|
||||
expect(Object.keys(first.files)).toEqual(first.entries.map((entry) => entry.path))
|
||||
})
|
||||
|
||||
test("uses weights to steer file kind and tree depth", () => {
|
||||
const generated = SimulationFileGenerator.generateFiles({
|
||||
seed: 7,
|
||||
count: 6,
|
||||
maxDepth: 4,
|
||||
weights: { txt: 0, ts: 1, shallow: 0, deep: 1 },
|
||||
})
|
||||
|
||||
expect(generated.entries.every((entry) => entry.kind === "ts")).toBe(true)
|
||||
expect(generated.entries.every((entry) => entry.path.split("/").length > 2)).toBe(true)
|
||||
})
|
||||
|
||||
test("generates patch files for existing generated files", () => {
|
||||
const generated = SimulationFileGenerator.generateFiles({ seed: 3, count: 5 })
|
||||
const patches = SimulationFileGenerator.generatePatches(generated, { seed: 9, count: 3, patchDir: ".git/patches" })
|
||||
|
||||
expect(patches.patches.length).toBe(3)
|
||||
expect(Object.keys(patches.files)).toEqual(patches.patches.map((patch) => patch.path))
|
||||
expect(Object.keys(patches.patchedFiles)).toEqual(patches.patches.map((patch) => patch.target))
|
||||
for (const patch of patches.patches) {
|
||||
expect(generated.files[patch.target]).toBe(patch.before)
|
||||
expect(patch.after).not.toBe(patch.before)
|
||||
expect(patch.path.startsWith(".git/patches/")).toBe(true)
|
||||
expect(patch.patch.startsWith(`diff --git a/${patch.target} b/${patch.target}`)).toBe(true)
|
||||
expect(parsePatch(patch.patch)[0].oldFileName).toBe(`a/${patch.target}`)
|
||||
}
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue