mirror of
https://github.com/NationalSecurityAgency/ghidra.git
synced 2025-10-04 18:29:37 +02:00
add example bundles extension
This commit is contained in:
parent
e7661ada57
commit
2a21ce4d97
17 changed files with 539 additions and 0 deletions
0
Ghidra/Extensions/bundle_examples/Module.manifest
Normal file
0
Ghidra/Extensions/bundle_examples/Module.manifest
Normal file
116
Ghidra/Extensions/bundle_examples/build.gradle
Normal file
116
Ghidra/Extensions/bundle_examples/build.gradle
Normal file
|
@ -0,0 +1,116 @@
|
|||
/* This extension is different from the others. It produces a zip containing
|
||||
* directories of source bundles and jar bundles.
|
||||
* - Each source directory is added as a sourceset so that the eclipse plugin
|
||||
* can add them to the generated project.
|
||||
* - the source destined to be included as jars are compiled.
|
||||
*/
|
||||
|
||||
apply from: "$rootProject.projectDir/gradle/javaProject.gradle"
|
||||
apply plugin: 'eclipse'
|
||||
|
||||
// there is no main jar
|
||||
jar.enabled=false
|
||||
|
||||
eclipse.project.name = 'Xtra Bundle Examples'
|
||||
|
||||
|
||||
dependencies {
|
||||
compile project(':Base')
|
||||
}
|
||||
|
||||
|
||||
def srcDirs = []
|
||||
file(project.projectDir).eachDirMatch(~/.*scripts_.*/) { srcDirs << it.name }
|
||||
|
||||
srcDirs.each {dirName ->
|
||||
sourceSets.create(dirName) {
|
||||
java {
|
||||
srcDir {
|
||||
dirName
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// create and return a jar task for the given source directory
|
||||
def makeJarTask(dirName) {
|
||||
return tasks.create("build${dirName}", Jar) {
|
||||
archiveBaseName = dirName
|
||||
archiveFileName = "${dirName}.jar"
|
||||
ext.dirName=dirName
|
||||
|
||||
|
||||
from(sourceSets[dirName].output) {
|
||||
include "**"
|
||||
}
|
||||
manifest {
|
||||
def manifestFile=file("${dirName}/META-INF/MANIFEST.MF")
|
||||
// if there is a source manifest, use it
|
||||
if(manifestFile.exists())
|
||||
from manifestFile
|
||||
else // otherwise, use a default manifest
|
||||
attributes \
|
||||
"Bundle-Name": dirName,
|
||||
"Bundle-SymbolicName": dirName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def jarTasks=[
|
||||
makeJarTask("scripts_jar1"),
|
||||
makeJarTask("scripts_jar2")
|
||||
]
|
||||
|
||||
eclipse {
|
||||
classpath {
|
||||
// jar1 and jar2 implement the same classes (with different OSGi package versions)
|
||||
// adding both as source directories would cause errors in eclipse, so remove jar2.
|
||||
sourceSets-=[sourceSets.scripts_jar2]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// we need a alternative to the zipExtensions task from
|
||||
// "$rootProject.projectDir/gradle/support/extensionCommon.gradle"
|
||||
task zipExtensions(type: Zip, dependsOn:jarTasks) {
|
||||
def p = this.project
|
||||
archiveFileName = "${rootProject.ext.ZIP_NAME_PREFIX}_${p.name}.zip"
|
||||
destinationDir rootProject.ext.DISTRIBUTION_DIR
|
||||
|
||||
duplicatesStrategy 'exclude'
|
||||
|
||||
from '.'
|
||||
|
||||
srcDirs.each { f ->
|
||||
include f + '/**'
|
||||
}
|
||||
|
||||
include "scripts_*.jar"
|
||||
|
||||
for(jarTask in jarTasks) {
|
||||
from relativePath(jarTask.archiveFile)
|
||||
exclude jarTask.dirName
|
||||
}
|
||||
|
||||
into p.name
|
||||
}
|
||||
|
||||
// Registratino with rootProject.createInstallationZip is ususally done in
|
||||
// "$rootProject.projectDir/gradle/distributableGhidraExtension.gradle", but
|
||||
// since we define a custom zipExtensions task (and can't overwrite it), we do
|
||||
// the registration here.
|
||||
rootProject.createInstallationZip {
|
||||
from (this.project.zipExtensions) {
|
||||
into {
|
||||
ZIP_DIR_PREFIX + "/Extensions/Ghidra"
|
||||
}
|
||||
}
|
||||
doLast {
|
||||
this.project.zipExtensions.outputs.each {
|
||||
delete it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
7
Ghidra/Extensions/bundle_examples/certification.manifest
Normal file
7
Ghidra/Extensions/bundle_examples/certification.manifest
Normal file
|
@ -0,0 +1,7 @@
|
|||
##VERSION: 2.0
|
||||
Module.manifest||GHIDRA||||END|
|
||||
build.gradle||GHIDRA||||END|
|
||||
extension.properties||GHIDRA||||END|
|
||||
scripts_jar1/META-INF/MANIFEST.MF||GHIDRA||||END|
|
||||
scripts_jar2/META-INF/MANIFEST.MF||GHIDRA||||END|
|
||||
scripts_with_manifest/META-INF/MANIFEST.MF||GHIDRA||||END|
|
5
Ghidra/Extensions/bundle_examples/extension.properties
Normal file
5
Ghidra/Extensions/bundle_examples/extension.properties
Normal file
|
@ -0,0 +1,5 @@
|
|||
name=Bundle Examples
|
||||
description=This zip contains example script directories and jar bundles that demonstrate dynamic modularity (OSGi) features in Ghidra scripting. From a code browser menu, select Window -> Bundle Manager. Then select the plus (+) to add bundles. Navigate to the install path for this extension zip, and add all of the directories and jars from the root. In the script manager, select the category Examples/Bundle - each script demonstrates a different feature.
|
||||
author=Ghidra Team
|
||||
createdOn=10/14/2020
|
||||
version=@extversion@
|
|
@ -0,0 +1,7 @@
|
|||
Manifest-Version: 1.0
|
||||
Bundle-ManifestVersion: 2
|
||||
Require-Capability: osgi.ee;filter:="(&(osgi.ee=JavaSE)(version=11))"
|
||||
Bundle-SymbolicName: org.jarlib
|
||||
Bundle-Version: 1.0.0
|
||||
Bundle-Name: Example library
|
||||
Export-Package: org.jarlib;version=1.0.0
|
|
@ -0,0 +1,22 @@
|
|||
/* ###
|
||||
* IP: GHIDRA
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.jarlib;
|
||||
|
||||
public class JarUtil {
|
||||
public static String getVersion() {
|
||||
return "1.0";
|
||||
}
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
Manifest-Version: 1.0
|
||||
Bundle-ManifestVersion: 2
|
||||
Require-Capability: osgi.ee;filter:="(&(osgi.ee=JavaSE)(version=11))"
|
||||
Bundle-SymbolicName: org.jarlib
|
||||
Bundle-Version: 2.0.0
|
||||
Bundle-Name: Example library
|
||||
Export-Package: org.jarlib;version=2.0.0
|
|
@ -0,0 +1,22 @@
|
|||
/* ###
|
||||
* IP: GHIDRA
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.jarlib;
|
||||
|
||||
public class JarUtil {
|
||||
public static String getVersion() {
|
||||
return "2.0";
|
||||
}
|
||||
}
|
|
@ -0,0 +1,31 @@
|
|||
/* ###
|
||||
* IP: GHIDRA
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
// Intra-bundle dependency example.
|
||||
//@category Examples.Bundle
|
||||
|
||||
import org.other.lib.Util; // defined in this bundle, no need to @importpackage
|
||||
|
||||
import ghidra.app.script.GhidraScript;
|
||||
|
||||
public class IntraBundleExampleScript extends GhidraScript {
|
||||
@Override
|
||||
public void run() throws Exception {
|
||||
println("This script shows the use of " + Util.class.getCanonicalName() +
|
||||
" from within the same bundle.");
|
||||
|
||||
Util.doStuff(this);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
/* ###
|
||||
* IP: GHIDRA
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.other.lib;
|
||||
|
||||
import ghidra.app.script.GhidraScript;
|
||||
|
||||
public class Util {
|
||||
/**
|
||||
* An example of a utility function that scripts might want to use.
|
||||
*
|
||||
* @param script the calling script
|
||||
*/
|
||||
public static void doStuff(GhidraScript script) {
|
||||
script.println("in " + Util.class.getCanonicalName());
|
||||
}
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
/* ###
|
||||
* IP: GHIDRA
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
// Inter-bundle dependency example.
|
||||
//@category Examples.Bundle
|
||||
//@importpackage org.other.lib
|
||||
|
||||
import org.other.lib.Util; // from another bundle, use @importpackage
|
||||
|
||||
import ghidra.app.script.GhidraScript;
|
||||
|
||||
public class InterBundleExampleScript extends GhidraScript {
|
||||
@Override
|
||||
public void run() throws Exception {
|
||||
println("This script shows the use of " + Util.class.getCanonicalName() +
|
||||
" from a different bundle.");
|
||||
println(
|
||||
"In this case, the dependency is declared with the metadata comment \"//@importpackage org.other.lib\"");
|
||||
|
||||
Util.doStuff(this);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,37 @@
|
|||
/* ###
|
||||
* IP: GHIDRA
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
// Example use of jar bundle
|
||||
//@category Examples.Bundle
|
||||
//@importpackage org.jarlib
|
||||
|
||||
import org.jarlib.JarUtil;
|
||||
|
||||
import ghidra.app.script.GhidraScript;
|
||||
|
||||
public class UsesJarExampleScript extends GhidraScript {
|
||||
|
||||
@Override
|
||||
protected void run() throws Exception {
|
||||
println("This script shows the use of " + JarUtil.class.getCanonicalName() + ".");
|
||||
println(" a class defined in an external jar bundle.");
|
||||
println("There are two versions of the jar in the bundle examples directory,");
|
||||
println(" since \"@importpackage\" declaration doesn't specify a version, either");
|
||||
println(" of the jar bundles, scripts_jar1.jar or scripts_jar2.jar works.");
|
||||
println(" Try enabling only one of the \"scripts_jar*\" bundles and rerun this script.");
|
||||
|
||||
println("Currently using JarUtil version " + JarUtil.getVersion());
|
||||
}
|
||||
}
|
|
@ -0,0 +1,37 @@
|
|||
/* ###
|
||||
* IP: GHIDRA
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
// Example use of jar bundle with a version constraint
|
||||
//@category Examples.Bundle
|
||||
//@importpackage org.jarlib;version="[2,3)"
|
||||
|
||||
import org.jarlib.JarUtil;
|
||||
|
||||
import ghidra.app.script.GhidraScript;
|
||||
|
||||
public class UsesJarByVersionExampleScript extends GhidraScript {
|
||||
|
||||
@Override
|
||||
protected void run() throws Exception {
|
||||
println("This script shows the use of " + JarUtil.class.getCanonicalName() + ".");
|
||||
println(" a class defined in an external jar bundle.");
|
||||
println("There are two versions of the jar in the bundle examples directory,");
|
||||
println(" since \"@importpackage\" declaration doesn't specify a version, either");
|
||||
println(" of the jar bundles, scripts_jar1.jar or scripts_jar2.jar works.");
|
||||
println(" Try enabling only one of the \"scripts_jar*\" bundles and rerun this script.");
|
||||
|
||||
println("Currently using JarUtil version " + JarUtil.getVersion());
|
||||
}
|
||||
}
|
|
@ -0,0 +1,55 @@
|
|||
/* ###
|
||||
* IP: GHIDRA
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
// Example script that cleans up actions with a bundle activator.
|
||||
//@category Examples.Bundle
|
||||
|
||||
import docking.ActionContext;
|
||||
import docking.action.*;
|
||||
import ghidra.app.script.GhidraScript;
|
||||
import ghidra.app.services.CodeViewerService;
|
||||
import ghidra.app.services.ConsoleService;
|
||||
import ghidra.framework.plugintool.PluginTool;
|
||||
import ghidra.program.util.ProgramLocation;
|
||||
import internal.MyActivator;
|
||||
import resources.Icons;
|
||||
|
||||
public class ActivatorExampleScript extends GhidraScript {
|
||||
|
||||
@Override
|
||||
protected void run() throws Exception {
|
||||
PluginTool tool = state.getTool();
|
||||
println("This script is from a bundle with a custom activator class, " +
|
||||
MyActivator.class.getCanonicalName() + ".");
|
||||
println("When the bundle is activated, its start method is called.");
|
||||
println("When deactivated, the activator's stop method is called.");
|
||||
println("To demonstrate how it can be useful, this script adds an action to the toolbar,");
|
||||
println(" it's a gear icon with tooltip \"Added by script!!\".");
|
||||
println("The activator will remove the action if this bundle is deactivated,");
|
||||
println(" e.g. if this script is modified and the bundle needs to be reloaded.");
|
||||
|
||||
DockingAction action = new DockingAction("Added by script!!", null, false) {
|
||||
@Override
|
||||
public void actionPerformed(ActionContext context) {
|
||||
ConsoleService console = tool.getService(ConsoleService.class);
|
||||
CodeViewerService codeviewer = tool.getService(CodeViewerService.class);
|
||||
ProgramLocation loc = codeviewer.getCurrentLocation();
|
||||
console.getStdOut().printf("Current location is %s\n", loc);
|
||||
}
|
||||
};
|
||||
action.setToolBarData(new ToolBarData(Icons.CONFIGURE_FILTER_ICON));
|
||||
MyActivator.addAction(tool, action);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,88 @@
|
|||
/* ###
|
||||
* IP: GHIDRA
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package internal;
|
||||
|
||||
import org.osgi.framework.BundleContext;
|
||||
|
||||
import docking.action.DockingAction;
|
||||
import ghidra.app.plugin.core.osgi.GhidraBundleActivator;
|
||||
import ghidra.framework.plugintool.PluginTool;
|
||||
import ghidra.util.Msg;
|
||||
import ghidra.util.Swing;
|
||||
|
||||
/**
|
||||
* An example BundleActivator that manages a {@link DockingAction}.
|
||||
*/
|
||||
public class MyActivator extends GhidraBundleActivator {
|
||||
|
||||
private static PluginTool storedTool;
|
||||
private static DockingAction storedAction;
|
||||
|
||||
/**
|
||||
* add {@code action} to {@code tool} and store both to remove later.
|
||||
*
|
||||
* Note: this can be used exactly once per bundle lifecycle, i.e. between start & stop.
|
||||
*
|
||||
* @param tool the tool to add {@code action} to
|
||||
* @param action the action to add to {@code tool}
|
||||
* @return false if this add operation has already been performed
|
||||
*/
|
||||
public static boolean addAction(PluginTool tool, DockingAction action) {
|
||||
if (storedTool != null || storedAction != null) {
|
||||
return false;
|
||||
}
|
||||
storedTool = tool;
|
||||
storedAction = action;
|
||||
Swing.runNow(() -> {
|
||||
storedTool.addAction(storedAction);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by Ghidra when bundle is activated.
|
||||
*
|
||||
* @param bundleContext the context for this bundle
|
||||
* @param api placeholder for future Ghidra API
|
||||
*/
|
||||
@Override
|
||||
protected void start(BundleContext bundleContext, Object api) {
|
||||
if (storedAction != null) {
|
||||
Msg.showError(this, null, "Activator error!", "storedAction non-null on bundle start!");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by Ghidra when bundle is deactivated.
|
||||
*
|
||||
* @param bundleContext the context for this bundle
|
||||
* @param api placeholder for future Ghidra API
|
||||
*/
|
||||
@Override
|
||||
protected void stop(BundleContext bundleContext, Object api) {
|
||||
if (storedAction != null) {
|
||||
storedAction.dispose();
|
||||
if (storedTool == null) {
|
||||
Msg.showError(this, null, "Activator error!", "storedTool is null!");
|
||||
}
|
||||
else {
|
||||
storedTool.removeAction(storedAction);
|
||||
}
|
||||
storedTool = null;
|
||||
storedAction = null;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
/* ###
|
||||
* IP: GHIDRA
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
// Inter-bundle dependency example where import is done with bundle manifest.
|
||||
//@category Examples.Bundle
|
||||
|
||||
import org.other.lib.Util; // from another bundle, import done in META-INF/MANIFEST.MF
|
||||
|
||||
import ghidra.app.script.GhidraScript;
|
||||
|
||||
public class InterBundleManifestExampleScript extends GhidraScript {
|
||||
@Override
|
||||
public void run() throws Exception {
|
||||
println("This script shows the use of " + Util.class.getCanonicalName() +
|
||||
" from a different bundle.");
|
||||
println(
|
||||
"In this case, the dependency is declared in the source file, \"META-INF/MANIFEST.MF\"");
|
||||
println(" see the line starting \"Import-Package\"");
|
||||
|
||||
Util.doStuff(this);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
Manifest-Version: 1.0
|
||||
Bundle-ManifestVersion: 2
|
||||
Export-Package: com.mystuff.exports
|
||||
Import-Package: org.other.lib,ghidra.app.script,ghidra.app.plugin.core.osgi
|
||||
Require-Capability: osgi.ee;filter:="(&(osgi.ee=JavaSE)(version=11))"
|
||||
Bundle-SymbolicName: examples.scripts_with_manifest
|
||||
Bundle-Name: Example Ghidra script directory with manifest.
|
||||
Bundle-Version: 1.0
|
Loading…
Add table
Add a link
Reference in a new issue