forked from MapComplete/MapComplete
chore: automated housekeeping...
This commit is contained in:
parent
d3b6c090f3
commit
332f960f86
57 changed files with 2884 additions and 1972 deletions
|
@ -46,8 +46,7 @@ class ToSlideshowJson {
|
|||
}
|
||||
|
||||
public convert() {
|
||||
const lines = readFileSync(this._source, "utf8")
|
||||
.split("\n")
|
||||
const lines = readFileSync(this._source, "utf8").split("\n")
|
||||
|
||||
const sections: string[][] = []
|
||||
let currentSection: string[] = []
|
||||
|
@ -159,10 +158,10 @@ export class GenerateDocs extends Script {
|
|||
this.generateEliDocs()
|
||||
this.generateBuiltinUnits()
|
||||
this.writeMarkdownFile("./Docs/Studio/SpecialInputElements.md", Validators.HelpText(), [
|
||||
"src/UI/InputElement/Validators.ts"
|
||||
"src/UI/InputElement/Validators.ts",
|
||||
])
|
||||
this.writeMarkdownFile("./Docs/Studio/Tags_format.md", TagUtils.generateDocs(), [
|
||||
"src/Logic/Tags/TagUtils.ts"
|
||||
"src/Logic/Tags/TagUtils.ts",
|
||||
])
|
||||
|
||||
this.writeMarkdownFile(
|
||||
|
@ -170,7 +169,7 @@ export class GenerateDocs extends Script {
|
|||
SpecialVisualizations.HelpMessage(),
|
||||
["src/UI/SpecialVisualizations.ts"],
|
||||
{
|
||||
tocMaxDepth: 3
|
||||
tocMaxDepth: 3,
|
||||
}
|
||||
)
|
||||
this.writeMarkdownFile(
|
||||
|
@ -181,7 +180,6 @@ export class GenerateDocs extends Script {
|
|||
)
|
||||
}
|
||||
|
||||
|
||||
// For dev
|
||||
{
|
||||
this.generateQueryParameterDocs()
|
||||
|
@ -208,13 +206,12 @@ export class GenerateDocs extends Script {
|
|||
ScriptUtils.erasableLog("Written docs for theme", theme.id)
|
||||
})
|
||||
|
||||
|
||||
this.generateOverviewsForAllSingleLayer("./Docs/nl/Layers", "nl")
|
||||
}
|
||||
|
||||
this.writeMarkdownFile("./Docs/ChangesetMeta.md", Changes.getDocs(), [
|
||||
"src/Logic/Osm/Changes.ts",
|
||||
"src/Logic/Osm/ChangesetHandler.ts"
|
||||
"src/Logic/Osm/ChangesetHandler.ts",
|
||||
])
|
||||
|
||||
new WikiPageGenerator().generate()
|
||||
|
@ -224,9 +221,11 @@ export class GenerateDocs extends Script {
|
|||
this.generateSidebar("nl")
|
||||
|
||||
this.generatedPaths.push(".gitignore")
|
||||
writeFileSync("./Docs/.gitignore", this.generatedPaths
|
||||
.map(p => p.replace("./Docs/", ""))
|
||||
.join("\n"), "utf-8")
|
||||
writeFileSync(
|
||||
"./Docs/.gitignore",
|
||||
this.generatedPaths.map((p) => p.replace("./Docs/", "")).join("\n"),
|
||||
"utf-8"
|
||||
)
|
||||
|
||||
console.log("Generated docs")
|
||||
}
|
||||
|
@ -238,7 +237,7 @@ export class GenerateDocs extends Script {
|
|||
options?: {
|
||||
noTableOfContents?: boolean
|
||||
tocMaxDepth?: number
|
||||
lang?: string,
|
||||
lang?: string
|
||||
noWarn?: boolean
|
||||
}
|
||||
): void {
|
||||
|
@ -269,25 +268,31 @@ export class GenerateDocs extends Script {
|
|||
md += "\n"
|
||||
}
|
||||
|
||||
const warnAutomated = options?.noWarn ? "" :
|
||||
"[//]: # (WARNING: this file is automatically generated. Please find the sources at the bottom and edit those sources)\n\n"
|
||||
const warnAutomated = options?.noWarn
|
||||
? ""
|
||||
: "[//]: # (WARNING: this file is automatically generated. Please find the sources at the bottom and edit those sources)\n\n"
|
||||
|
||||
const sources = autogenSource
|
||||
.map(
|
||||
(s) =>
|
||||
`[${s}](https://source.mapcomplete.org/MapComplete/MapComplete/src/branch/develop/${s})`
|
||||
)
|
||||
.join(", ")
|
||||
|
||||
const sources = autogenSource.map(
|
||||
(s) => `[${s}](https://source.mapcomplete.org/MapComplete/MapComplete/src/branch/develop/${s})`).join(", ")
|
||||
const generatedFrom = new TypedTranslation<{ sources; date }>({
|
||||
en: "This document is autogenerated from {sources} on {date}",
|
||||
nl: "Dit document werd gegenereerd op basis van {sources} op {date}",
|
||||
})
|
||||
.Subs({ sources, date: new Date().toDateString() })
|
||||
.textFor(lang)
|
||||
|
||||
const generatedFrom =
|
||||
new TypedTranslation<{ sources, date }>({
|
||||
en: "This document is autogenerated from {sources} on {date}",
|
||||
nl: "Dit document werd gegenereerd op basis van {sources} op {date}"
|
||||
}).Subs({ sources, date: new Date().toDateString() }).textFor(lang)
|
||||
|
||||
|
||||
writeFileSync(filename, warnAutomated + md + (options?.noWarn ? "" : "\n\n" + generatedFrom + "\n"))
|
||||
writeFileSync(
|
||||
filename,
|
||||
warnAutomated + md + (options?.noWarn ? "" : "\n\n" + generatedFrom + "\n")
|
||||
)
|
||||
this.generatedPaths.push(filename)
|
||||
}
|
||||
|
||||
|
||||
private generateEliDocs() {
|
||||
const eli = AvailableRasterLayers.editorLayerIndex()
|
||||
this.writeMarkdownFile(
|
||||
|
@ -295,8 +300,8 @@ export class GenerateDocs extends Script {
|
|||
[
|
||||
"# Layers in the Editor Layer Index",
|
||||
"This table gives a summary of ids, names and other metainformation of background imagery that is available in MapComplete and that can be used as (default) map background." +
|
||||
"These are sourced from [the Editor Layer Index](https://github.com/osmlab/editor-layer-index)", +
|
||||
"\n[See the online, interactive map here](https://osmlab.github.io/editor-layer-index/)",
|
||||
"These are sourced from [the Editor Layer Index](https://github.com/osmlab/editor-layer-index)",
|
||||
+"\n[See the online, interactive map here](https://osmlab.github.io/editor-layer-index/)",
|
||||
MarkdownUtils.table(
|
||||
["id", "name", "category", "Best", "attribution"],
|
||||
eli.map((f) => [
|
||||
|
@ -305,10 +310,10 @@ export class GenerateDocs extends Script {
|
|||
f.properties.category,
|
||||
f.properties.best ? "⭐" : "",
|
||||
f.properties.attribution?.html ?? f.properties.attribution?.text,
|
||||
]),
|
||||
])
|
||||
),
|
||||
].join("\n\n"),
|
||||
["./public/assets/data/editor-layer-index.json"],
|
||||
["./public/assets/data/editor-layer-index.json"]
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -367,7 +372,10 @@ export class GenerateDocs extends Script {
|
|||
* Generates documentation for the all the individual layers.
|
||||
* Inline layers are included (if the theme is public)
|
||||
*/
|
||||
private generateOverviewsForAllSingleLayer(targetDirectory: string = "./Docs/Layers", lang: string = "en"): void {
|
||||
private generateOverviewsForAllSingleLayer(
|
||||
targetDirectory: string = "./Docs/Layers",
|
||||
lang: string = "en"
|
||||
): void {
|
||||
const allLayers: LayerConfig[] = Array.from(AllSharedLayers.sharedLayers.values()).filter(
|
||||
(layer) => layer["source"] !== null
|
||||
)
|
||||
|
@ -434,19 +442,23 @@ export class GenerateDocs extends Script {
|
|||
mkdirSync(targetDirectory)
|
||||
}
|
||||
allLayers.forEach((layer) => {
|
||||
const element = layer.generateDocumentation({
|
||||
usedInThemes: themesPerLayer.get(layer.id),
|
||||
layerIsNeededBy: layerIsNeededBy,
|
||||
dependencies: DependencyCalculator.getLayerDependencies(layer),
|
||||
lang
|
||||
}).replaceAll("./Docs/Layers", targetDirectory)
|
||||
const element = layer
|
||||
.generateDocumentation({
|
||||
usedInThemes: themesPerLayer.get(layer.id),
|
||||
layerIsNeededBy: layerIsNeededBy,
|
||||
dependencies: DependencyCalculator.getLayerDependencies(layer),
|
||||
lang,
|
||||
})
|
||||
.replaceAll("./Docs/Layers", targetDirectory)
|
||||
const inlineSource = inlineLayers.get(layer.id)
|
||||
ScriptUtils.erasableLog("Exporting layer documentation for", layer.id)
|
||||
let source: string = `assets/layers/${layer.id}/${layer.id}.json`
|
||||
if (inlineSource !== undefined) {
|
||||
source = `assets/themes/${inlineSource}/${inlineSource}.json`
|
||||
}
|
||||
this.writeMarkdownFile(targetDirectory + "/" + layer.id + ".md", element, [source], { lang })
|
||||
this.writeMarkdownFile(targetDirectory + "/" + layer.id + ".md", element, [source], {
|
||||
lang,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -491,17 +503,24 @@ export class GenerateDocs extends Script {
|
|||
}
|
||||
|
||||
const docs: string[] = [
|
||||
"# Which tagrendering is used where?", "",
|
||||
"This document details where a tagRendering from one layer is reused in another layer, either by directly using it or by using a `{\"builtin\": id, \"override\": ...}` syntax",
|
||||
"# Which tagrendering is used where?",
|
||||
"",
|
||||
'This document details where a tagRendering from one layer is reused in another layer, either by directly using it or by using a `{"builtin": id, "override": ...}` syntax',
|
||||
"Having this overview supports e.g. refactoring efforts",
|
||||
"## Existing builtin tagrenderings", ""]
|
||||
"## Existing builtin tagrenderings",
|
||||
"",
|
||||
]
|
||||
|
||||
for (const [builtin, usedByLayers] of Array.from(layersUsingBuiltin.entries())) {
|
||||
docs.push(`### ${builtin}\n`)
|
||||
docs.push(usedByLayers.length + " usages")
|
||||
docs.push(`${usedByLayers.map((item) => ` - [${item}](./Docs/Layers/${item}.md)`).join("\n")}`)
|
||||
docs.push(
|
||||
`${usedByLayers.map((item) => ` - [${item}](./Docs/Layers/${item}.md)`).join("\n")}`
|
||||
)
|
||||
}
|
||||
this.writeMarkdownFile("./Docs/Studio/TagRendering_reuse_overview.md", docs.join("\n"), ["assets/layers/*.json"])
|
||||
this.writeMarkdownFile("./Docs/Studio/TagRendering_reuse_overview.md", docs.join("\n"), [
|
||||
"assets/layers/*.json",
|
||||
])
|
||||
}
|
||||
|
||||
private generateQueryParameterDocs() {
|
||||
|
@ -537,7 +556,7 @@ export class GenerateDocs extends Script {
|
|||
])
|
||||
}
|
||||
|
||||
private generateForTheme(theme: ThemeConfig, options?: { path?: string, lang?: string }): void {
|
||||
private generateForTheme(theme: ThemeConfig, options?: { path?: string; lang?: string }): void {
|
||||
const allLayers = AllSharedLayers.sharedLayers
|
||||
const layersToShow = theme.layers.filter(
|
||||
(l) => l.id !== "favourite" && Constants.added_by_default.indexOf(<any>l.id) < 0
|
||||
|
@ -563,38 +582,59 @@ export class GenerateDocs extends Script {
|
|||
if (allLayers.has(l.id)) {
|
||||
return `[${l.id}](../Layers/${l.id}.md)`
|
||||
}
|
||||
return `[${l.id} (${l.name?.textFor(lang)})](#${l.id.trim().replace(/ /g, "-")})`
|
||||
return `[${l.id} (${l.name?.textFor(lang)})](#${l.id
|
||||
.trim()
|
||||
.replace(/ /g, "-")})`
|
||||
})
|
||||
),
|
||||
new Translation(
|
||||
{
|
||||
en: "This theme is available in the following languages:",
|
||||
nl: "Deze kaart is beschikbaar in de volgende talen:",
|
||||
},
|
||||
).textFor(lang),
|
||||
MarkdownUtils.list(theme.language.filter((ln) => ln !== "_context").map(ln => {
|
||||
if (language_translations[ln]) {
|
||||
return ln + " (" + new Translation(language_translations[ln]).textFor(lang) + ")"
|
||||
} else {
|
||||
return ln
|
||||
}
|
||||
},
|
||||
)),
|
||||
new Translation({
|
||||
en: "This theme is available in the following languages:",
|
||||
nl: "Deze kaart is beschikbaar in de volgende talen:",
|
||||
}).textFor(lang),
|
||||
MarkdownUtils.list(
|
||||
theme.language
|
||||
.filter((ln) => ln !== "_context")
|
||||
.map((ln) => {
|
||||
if (language_translations[ln]) {
|
||||
return (
|
||||
ln +
|
||||
" (" +
|
||||
new Translation(language_translations[ln]).textFor(lang) +
|
||||
")"
|
||||
)
|
||||
} else {
|
||||
return ln
|
||||
}
|
||||
})
|
||||
),
|
||||
]
|
||||
|
||||
if (layersToInline.length > 0) {
|
||||
el.push(MarkdownUtils.title(1, new Translation({
|
||||
en: "Layers defined in this theme configuration file",
|
||||
nl: "Lagen gedefinieerd in dit kaartthema-bestand",
|
||||
})).textFor(lang))
|
||||
el.push(MarkdownUtils.list(layersToInline.map(l => `[${l.name?.textFor(lang) ?? ""} (\`${l.id}\`)](#${l.id})`)))
|
||||
|
||||
el.push(new Translation({
|
||||
en: "These layers can not be reused in different themes.",
|
||||
nl: "Deze lagen kunnen niet in andere kaartthemas hergebruikt worden",
|
||||
}).textFor(lang))
|
||||
el.push(
|
||||
...layersToInline.map((l) => l.generateDocumentation({ usedInThemes: null, lang })),
|
||||
MarkdownUtils.title(
|
||||
1,
|
||||
new Translation({
|
||||
en: "Layers defined in this theme configuration file",
|
||||
nl: "Lagen gedefinieerd in dit kaartthema-bestand",
|
||||
})
|
||||
).textFor(lang)
|
||||
)
|
||||
el.push(
|
||||
MarkdownUtils.list(
|
||||
layersToInline.map(
|
||||
(l) => `[${l.name?.textFor(lang) ?? ""} (\`${l.id}\`)](#${l.id})`
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
el.push(
|
||||
new Translation({
|
||||
en: "These layers can not be reused in different themes.",
|
||||
nl: "Deze lagen kunnen niet in andere kaartthemas hergebruikt worden",
|
||||
}).textFor(lang)
|
||||
)
|
||||
el.push(
|
||||
...layersToInline.map((l) => l.generateDocumentation({ usedInThemes: null, lang }))
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -606,7 +646,7 @@ export class GenerateDocs extends Script {
|
|||
path + "/" + theme.id + ".md",
|
||||
el.join("\n"),
|
||||
[`assets/themes/${theme.id}/${theme.id}.json`],
|
||||
{ noTableOfContents: true, lang },
|
||||
{ noTableOfContents: true, lang }
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -742,14 +782,16 @@ export class GenerateDocs extends Script {
|
|||
*/
|
||||
private generateSidebar(subdirectory = ""): string[] {
|
||||
const tr = Translations.t.app.back.textFor(subdirectory)
|
||||
const sidebar: string[] = [
|
||||
`<a href='https://mapcomplete.org' class='back-to-mc'>${tr}</a>`
|
||||
]
|
||||
const sidebar: string[] = [`<a href='https://mapcomplete.org' class='back-to-mc'>${tr}</a>`]
|
||||
const allFiles = ScriptUtils.readDirRecSync("./Docs/" + subdirectory)
|
||||
.filter(path => path.endsWith(".md"))
|
||||
.filter(path => !path.startsWith("_"))
|
||||
.filter(path => !path.startsWith("./Docs/nl/") || (path.startsWith("./Docs/" + subdirectory) && subdirectory !== ""))
|
||||
.map(path => path.substring("./Docs/".length + subdirectory.length + 1))
|
||||
.filter((path) => path.endsWith(".md"))
|
||||
.filter((path) => !path.startsWith("_"))
|
||||
.filter(
|
||||
(path) =>
|
||||
!path.startsWith("./Docs/nl/") ||
|
||||
(path.startsWith("./Docs/" + subdirectory) && subdirectory !== "")
|
||||
)
|
||||
.map((path) => path.substring("./Docs/".length + subdirectory.length + 1))
|
||||
const perDirectory = new Map<string, string[]>()
|
||||
|
||||
function addFile(dir: string, path: string) {
|
||||
|
@ -779,13 +821,15 @@ export class GenerateDocs extends Script {
|
|||
const directories: [string, Translation | string][] = [
|
||||
["", ""],
|
||||
["Layers", new Translation({ en: "Overview of layers", nl: "Overzicht van de lagen" })],
|
||||
["Themes", new Translation({ en: "Overview of map themes", nl: "Overzicht van de themas" })],
|
||||
[
|
||||
"Themes",
|
||||
new Translation({ en: "Overview of map themes", nl: "Overzicht van de themas" }),
|
||||
],
|
||||
["UserTests", "Usability tests with users"],
|
||||
["Studio", "For theme builders"],
|
||||
["Dev", "For developers"],
|
||||
]
|
||||
|
||||
|
||||
for (const [dir, title] of directories) {
|
||||
if (title === null) {
|
||||
continue
|
||||
|
@ -830,10 +874,9 @@ export class GenerateDocs extends Script {
|
|||
}
|
||||
|
||||
private generateNormalLayerOverview(type: "Layers" | "Themes", subdir = "") {
|
||||
|
||||
const layerinfo: [string, string, string][] = []
|
||||
const source: ReadonlyMap<string, LayerConfig> | AllKnownLayoutsLazy
|
||||
= type === "Layers" ? AllSharedLayers.sharedLayers : AllKnownLayouts.allKnownLayouts
|
||||
const source: ReadonlyMap<string, LayerConfig> | AllKnownLayoutsLazy =
|
||||
type === "Layers" ? AllSharedLayers.sharedLayers : AllKnownLayouts.allKnownLayouts
|
||||
const keys = Array.from(source.keys())
|
||||
keys.sort()
|
||||
|
||||
|
@ -841,7 +884,7 @@ export class GenerateDocs extends Script {
|
|||
const layer = source.get(id)
|
||||
let name: Translation
|
||||
if (type === "Layers") {
|
||||
const layer_ = (<LayerConfig>layer)
|
||||
const layer_ = <LayerConfig>layer
|
||||
if (!layer_.isNormal()) {
|
||||
continue
|
||||
}
|
||||
|
@ -849,36 +892,41 @@ export class GenerateDocs extends Script {
|
|||
} else {
|
||||
name = (<ThemeConfig>layer).title
|
||||
}
|
||||
layerinfo.push([`[${id}](./${type}/${id})`, name.textFor(subdir), (layer["shortDescription"] ?? layer.description)?.textFor(subdir)])
|
||||
layerinfo.push([
|
||||
`[${id}](./${type}/${id})`,
|
||||
name.textFor(subdir),
|
||||
(layer["shortDescription"] ?? layer.description)?.textFor(subdir),
|
||||
])
|
||||
}
|
||||
|
||||
const titles = {
|
||||
"Layers": new Translation({ en: "Layers", nl: "Lagen" }),
|
||||
"Themes": new Translation({ en: "Themes", nl: "Kaartthema's" })
|
||||
Layers: new Translation({ en: "Layers", nl: "Lagen" }),
|
||||
Themes: new Translation({ en: "Themes", nl: "Kaartthema's" }),
|
||||
}
|
||||
|
||||
const intro: Record<string, TypedTranslation<{ version }>> = {
|
||||
"Layers": new TypedTranslation<{ version }>({
|
||||
Layers: new TypedTranslation<{ version }>({
|
||||
en: "The following layers are available in MapComplete {version}:",
|
||||
nl: "De volgende lagen zijn beschikbaar in MapComplete {version}:"
|
||||
nl: "De volgende lagen zijn beschikbaar in MapComplete {version}:",
|
||||
}),
|
||||
"Themes": new TypedTranslation<{ version }>({
|
||||
Themes: new TypedTranslation<{ version }>({
|
||||
en: "The following themes are available in MapComplete {version}:",
|
||||
nl: "De volgende kaartthemas zijn beschikbaar in MapComplete {version}:"
|
||||
})
|
||||
nl: "De volgende kaartthemas zijn beschikbaar in MapComplete {version}:",
|
||||
}),
|
||||
}
|
||||
|
||||
const doc = ["# " + titles[type].textFor(subdir),
|
||||
intro[type].Subs({ version: Constants.vNumber }).textFor(subdir)
|
||||
, MarkdownUtils.table(
|
||||
["id", "name", "description"],
|
||||
layerinfo)
|
||||
const doc = [
|
||||
"# " + titles[type].textFor(subdir),
|
||||
intro[type].Subs({ version: Constants.vNumber }).textFor(subdir),
|
||||
MarkdownUtils.table(["id", "name", "description"], layerinfo),
|
||||
]
|
||||
const path = `./Docs/${subdir}/${type}`
|
||||
if (!existsSync(path)) {
|
||||
mkdirSync(path)
|
||||
}
|
||||
this.writeMarkdownFile(`${path}/README.md`, doc.join("\n\n"), [`./assets/${type.toLowerCase()}/*.json`])
|
||||
this.writeMarkdownFile(`${path}/README.md`, doc.join("\n\n"), [
|
||||
`./assets/${type.toLowerCase()}/*.json`,
|
||||
])
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -893,8 +941,7 @@ export class GenerateDocs extends Script {
|
|||
continue
|
||||
}
|
||||
if (!AllSharedLayers.sharedLayers.has(id)) {
|
||||
throw ("Privileged layer definition not found: " + id)
|
||||
|
||||
throw "Privileged layer definition not found: " + id
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -933,7 +980,8 @@ export class GenerateDocs extends Script {
|
|||
}
|
||||
|
||||
const el = [
|
||||
"# Special and priviliged layers", "",
|
||||
"# Special and priviliged layers",
|
||||
"",
|
||||
"MapComplete has a few data layers available which have special properties through builtin-hooks.",
|
||||
"They perform various tasks, such as showing the GPS-location and track on the screen or help in special elements such as the 'cut way'-element.",
|
||||
"As a theme builder, you can influence the behaviour of those layers by overriding them in your .json file",
|
||||
|
@ -942,14 +990,15 @@ export class GenerateDocs extends Script {
|
|||
),
|
||||
...Lists.noNull(
|
||||
Constants.priviliged_layers.map((id) => AllSharedLayers.sharedLayers.get(id))
|
||||
).map((l) =>
|
||||
l.generateDocumentation({
|
||||
usedInThemes: themesPerLayer.get(l.id),
|
||||
layerIsNeededBy: layerIsNeededBy,
|
||||
dependencies: DependencyCalculator.getLayerDependencies(l),
|
||||
addedByDefault: Constants.added_by_default.indexOf(<any>l.id) >= 0,
|
||||
canBeIncluded: Constants.no_include.indexOf(<any>l.id) < 0,
|
||||
}) + "\n"
|
||||
).map(
|
||||
(l) =>
|
||||
l.generateDocumentation({
|
||||
usedInThemes: themesPerLayer.get(l.id),
|
||||
layerIsNeededBy: layerIsNeededBy,
|
||||
dependencies: DependencyCalculator.getLayerDependencies(l),
|
||||
addedByDefault: Constants.added_by_default.indexOf(<any>l.id) >= 0,
|
||||
canBeIncluded: Constants.no_include.indexOf(<any>l.id) < 0,
|
||||
}) + "\n"
|
||||
),
|
||||
].join("\n")
|
||||
this.writeMarkdownFile("./Docs/Studio/SpecialLayers.md", el, [
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue