mirror of
https://github.com/apple/pkl.git
synced 2026-04-25 09:48:41 +02:00
Added support for an alternative current dir mode in pkldoc (#824)
Some systems have trouble with handling symlinks, which breaks the current directory links created by Pkldoc. In this PR, we add an alternative mode which creates a full copy of the latest published version contents in the current directory instead. Co-authored-by: Dan Chao <dan.chao@apple.com>
This commit is contained in:
@@ -232,10 +232,18 @@ Relative URIs are resolved against the working directory.
|
|||||||
[%collapsible]
|
[%collapsible]
|
||||||
====
|
====
|
||||||
Default: (none) +
|
Default: (none) +
|
||||||
Example: `pkldoc`
|
Example: `pkldoc` +
|
||||||
The directory where generated documentation is placed.
|
The directory where generated documentation is placed.
|
||||||
====
|
====
|
||||||
|
|
||||||
|
.--no-symlinks
|
||||||
|
[%collapsible]
|
||||||
|
====
|
||||||
|
Create copies of files and directories instead of symbolic links.
|
||||||
|
In particular, this affects how the "current" directories containing documentation content for the last generated version should be created.
|
||||||
|
By default, a symbolic link is created pointing to the last generated version. If symlinks are disabled, a full copy of the last generated version is created.
|
||||||
|
====
|
||||||
|
|
||||||
Common CLI options:
|
Common CLI options:
|
||||||
|
|
||||||
include::../../pkl-cli/partials/cli-common-options.adoc[]
|
include::../../pkl-cli/partials/cli-common-options.adoc[]
|
||||||
|
|||||||
@@ -534,6 +534,17 @@ Example: `outputDir = layout.projectDirectory.dir("pkl-docs")` +
|
|||||||
The directory where generated documentation is placed.
|
The directory where generated documentation is placed.
|
||||||
====
|
====
|
||||||
|
|
||||||
|
.noSymlinks: Property<Boolean>
|
||||||
|
[%collapsible]
|
||||||
|
====
|
||||||
|
Default: `false` +
|
||||||
|
Example: `noSymlinks = true` +
|
||||||
|
Create copies of files and directories instead of symbolic links.
|
||||||
|
In particular, this affects how the "current" directories containing documentation content for the last generated version should be created.
|
||||||
|
By default, a symbolic link is created pointing to the last generated version.
|
||||||
|
If symlinks are disabled, a full copy of the last generated version is created.
|
||||||
|
====
|
||||||
|
|
||||||
Common properties:
|
Common properties:
|
||||||
|
|
||||||
include::../partials/gradle-modules-properties.adoc[]
|
include::../partials/gradle-modules-properties.adoc[]
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ import java.nio.charset.Charset
|
|||||||
import java.nio.file.*
|
import java.nio.file.*
|
||||||
import java.nio.file.attribute.FileAttribute
|
import java.nio.file.attribute.FileAttribute
|
||||||
import java.util.stream.Stream
|
import java.util.stream.Stream
|
||||||
|
import kotlin.io.path.copyTo
|
||||||
|
import kotlin.io.path.createParentDirectories
|
||||||
|
import kotlin.io.path.exists
|
||||||
|
|
||||||
// not stored to avoid build-time initialization by native-image
|
// not stored to avoid build-time initialization by native-image
|
||||||
val currentWorkingDir: Path
|
val currentWorkingDir: Path
|
||||||
@@ -51,6 +54,19 @@ fun Path.writeString(
|
|||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
fun Path.readString(charset: Charset = Charsets.UTF_8): String = Files.readString(this, charset)
|
fun Path.readString(charset: Charset = Charsets.UTF_8): String = Files.readString(this, charset)
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
fun Path.copyRecursively(target: Path) {
|
||||||
|
if (exists()) {
|
||||||
|
target.createParentDirectories()
|
||||||
|
walk().use { paths ->
|
||||||
|
paths.forEach { src ->
|
||||||
|
val dst = target.resolve(this@copyRecursively.relativize(src))
|
||||||
|
src.copyTo(dst, overwrite = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private val isWindows by lazy { System.getProperty("os.name").contains("Windows") }
|
private val isWindows by lazy { System.getProperty("os.name").contains("Windows") }
|
||||||
|
|
||||||
/** Copy implementation from IoUtils.toNormalizedPathString */
|
/** Copy implementation from IoUtils.toNormalizedPathString */
|
||||||
|
|||||||
@@ -270,6 +270,7 @@ class CliDocGenerator(private val options: CliDocGeneratorOptions) : CliCommand(
|
|||||||
versionComparator,
|
versionComparator,
|
||||||
options.normalizedOutputDir,
|
options.normalizedOutputDir,
|
||||||
options.isTestMode,
|
options.isTestMode,
|
||||||
|
options.noSymlinks,
|
||||||
)
|
)
|
||||||
.run()
|
.run()
|
||||||
} catch (e: DocGeneratorException) {
|
} catch (e: DocGeneratorException) {
|
||||||
|
|||||||
@@ -36,6 +36,17 @@ constructor(
|
|||||||
* files (e.g., when stdlib line numbers change).
|
* files (e.g., when stdlib line numbers change).
|
||||||
*/
|
*/
|
||||||
val isTestMode: Boolean = false,
|
val isTestMode: Boolean = false,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disables creation of symlinks, replacing them with regular files and directories.
|
||||||
|
*
|
||||||
|
* In particular, this affects creation of the "current" directory which contains documentation
|
||||||
|
* for the latest version of the package.
|
||||||
|
*
|
||||||
|
* `false` will make the current directory into a symlink to the actual version directory. `true`,
|
||||||
|
* however, will create a full copy instead.
|
||||||
|
*/
|
||||||
|
val noSymlinks: Boolean = false,
|
||||||
) {
|
) {
|
||||||
/** [outputDir] after undergoing normalization. */
|
/** [outputDir] after undergoing normalization. */
|
||||||
val normalizedOutputDir: Path = base.normalizedWorkingDir.resolveSafely(outputDir)
|
val normalizedOutputDir: Path = base.normalizedWorkingDir.resolveSafely(outputDir)
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import java.io.IOException
|
|||||||
import java.net.URI
|
import java.net.URI
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
import kotlin.io.path.*
|
import kotlin.io.path.*
|
||||||
|
import org.pkl.commons.copyRecursively
|
||||||
import org.pkl.core.ModuleSchema
|
import org.pkl.core.ModuleSchema
|
||||||
import org.pkl.core.PClassInfo
|
import org.pkl.core.PClassInfo
|
||||||
import org.pkl.core.Version
|
import org.pkl.core.Version
|
||||||
@@ -50,6 +51,7 @@ class DocGenerator(
|
|||||||
|
|
||||||
/** A comparator for package versions. */
|
/** A comparator for package versions. */
|
||||||
versionComparator: Comparator<String>,
|
versionComparator: Comparator<String>,
|
||||||
|
|
||||||
/** The directory where generated documentation is placed. */
|
/** The directory where generated documentation is placed. */
|
||||||
private val outputDir: Path,
|
private val outputDir: Path,
|
||||||
|
|
||||||
@@ -60,8 +62,21 @@ class DocGenerator(
|
|||||||
* files (e.g., when stdlib line numbers change).
|
* files (e.g., when stdlib line numbers change).
|
||||||
*/
|
*/
|
||||||
private val isTestMode: Boolean = false,
|
private val isTestMode: Boolean = false,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disables creation of symbolic links, using copies of files and directories instead of them.
|
||||||
|
*
|
||||||
|
* In particular, determines how to create the "current" directory which contains documentation
|
||||||
|
* for the latest version of the package.
|
||||||
|
*
|
||||||
|
* `false` will make the current directory into a symlink to the actual version directory. `true`,
|
||||||
|
* however, will create a full copy instead.
|
||||||
|
*/
|
||||||
|
private val noSymlinks: Boolean = false,
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
|
const val CURRENT_DIRECTORY_NAME = "current"
|
||||||
|
|
||||||
internal fun List<PackageData>.current(
|
internal fun List<PackageData>.current(
|
||||||
versionComparator: Comparator<String>
|
versionComparator: Comparator<String>
|
||||||
): List<PackageData> {
|
): List<PackageData> {
|
||||||
@@ -102,7 +117,7 @@ class DocGenerator(
|
|||||||
val packagesData = packageDataGenerator.readAll()
|
val packagesData = packageDataGenerator.readAll()
|
||||||
val currentPackagesData = packagesData.current(descendingVersionComparator)
|
val currentPackagesData = packagesData.current(descendingVersionComparator)
|
||||||
|
|
||||||
createSymlinks(currentPackagesData)
|
createCurrentDirectories(currentPackagesData)
|
||||||
|
|
||||||
htmlGenerator.generateSite(currentPackagesData)
|
htmlGenerator.generateSite(currentPackagesData)
|
||||||
searchIndexGenerator.generateSiteIndex(currentPackagesData)
|
searchIndexGenerator.generateSiteIndex(currentPackagesData)
|
||||||
@@ -117,14 +132,21 @@ class DocGenerator(
|
|||||||
outputDir.resolve(IoUtils.encodePath("$name/$version")).deleteRecursively()
|
outputDir.resolve(IoUtils.encodePath("$name/$version")).deleteRecursively()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createSymlinks(currentPackagesData: List<PackageData>) {
|
private fun createCurrentDirectories(currentPackagesData: List<PackageData>) {
|
||||||
for (packageData in currentPackagesData) {
|
for (packageData in currentPackagesData) {
|
||||||
val basePath = outputDir.resolve(packageData.ref.pkg.pathEncoded)
|
val basePath = outputDir.resolve(packageData.ref.pkg.pathEncoded)
|
||||||
val src = basePath.resolve(packageData.ref.version)
|
val src = basePath.resolve(packageData.ref.version)
|
||||||
val dest = basePath.resolve("current")
|
val dst = basePath.resolve(CURRENT_DIRECTORY_NAME)
|
||||||
if (dest.exists() && dest.isSameFileAs(src)) continue
|
|
||||||
dest.deleteIfExists()
|
if (noSymlinks) {
|
||||||
dest.createSymbolicLinkPointingTo(IoUtils.relativize(src, basePath))
|
dst.deleteRecursively()
|
||||||
|
src.copyRecursively(dst)
|
||||||
|
} else {
|
||||||
|
if (!dst.exists() || !dst.isSameFileAs(src)) {
|
||||||
|
dst.deleteRecursively()
|
||||||
|
dst.createSymbolicLinkPointingTo(IoUtils.relativize(src, basePath))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import com.github.ajalt.clikt.parameters.arguments.argument
|
|||||||
import com.github.ajalt.clikt.parameters.arguments.convert
|
import com.github.ajalt.clikt.parameters.arguments.convert
|
||||||
import com.github.ajalt.clikt.parameters.arguments.multiple
|
import com.github.ajalt.clikt.parameters.arguments.multiple
|
||||||
import com.github.ajalt.clikt.parameters.groups.provideDelegate
|
import com.github.ajalt.clikt.parameters.groups.provideDelegate
|
||||||
|
import com.github.ajalt.clikt.parameters.options.flag
|
||||||
import com.github.ajalt.clikt.parameters.options.option
|
import com.github.ajalt.clikt.parameters.options.option
|
||||||
import com.github.ajalt.clikt.parameters.options.required
|
import com.github.ajalt.clikt.parameters.options.required
|
||||||
import com.github.ajalt.clikt.parameters.types.path
|
import com.github.ajalt.clikt.parameters.types.path
|
||||||
@@ -30,6 +31,7 @@ import org.pkl.commons.cli.cliMain
|
|||||||
import org.pkl.commons.cli.commands.BaseCommand
|
import org.pkl.commons.cli.commands.BaseCommand
|
||||||
import org.pkl.commons.cli.commands.BaseOptions.Companion.parseModuleName
|
import org.pkl.commons.cli.commands.BaseOptions.Companion.parseModuleName
|
||||||
import org.pkl.commons.cli.commands.ProjectOptions
|
import org.pkl.commons.cli.commands.ProjectOptions
|
||||||
|
import org.pkl.commons.cli.commands.single
|
||||||
import org.pkl.core.Release
|
import org.pkl.core.Release
|
||||||
|
|
||||||
/** Main method for the Pkldoc CLI. */
|
/** Main method for the Pkldoc CLI. */
|
||||||
@@ -57,11 +59,24 @@ class DocCommand :
|
|||||||
.path()
|
.path()
|
||||||
.required()
|
.required()
|
||||||
|
|
||||||
|
private val noSymlinks: Boolean by
|
||||||
|
option(
|
||||||
|
names = arrayOf("--no-symlinks"),
|
||||||
|
help = "Create copies of directories and files instead of symbolic links.",
|
||||||
|
)
|
||||||
|
.single()
|
||||||
|
.flag(default = false)
|
||||||
|
|
||||||
private val projectOptions by ProjectOptions()
|
private val projectOptions by ProjectOptions()
|
||||||
|
|
||||||
override fun run() {
|
override fun run() {
|
||||||
val options =
|
val options =
|
||||||
CliDocGeneratorOptions(baseOptions.baseOptions(modules, projectOptions), outputDir, true)
|
CliDocGeneratorOptions(
|
||||||
|
baseOptions.baseOptions(modules, projectOptions),
|
||||||
|
outputDir,
|
||||||
|
true,
|
||||||
|
noSymlinks,
|
||||||
|
)
|
||||||
CliDocGenerator(options).run()
|
CliDocGenerator(options).run()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ package org.pkl.doc
|
|||||||
import com.google.common.jimfs.Configuration
|
import com.google.common.jimfs.Configuration
|
||||||
import com.google.common.jimfs.Jimfs
|
import com.google.common.jimfs.Jimfs
|
||||||
import java.net.URI
|
import java.net.URI
|
||||||
|
import java.nio.file.FileSystem
|
||||||
import java.nio.file.Files
|
import java.nio.file.Files
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
import kotlin.io.path.*
|
import kotlin.io.path.*
|
||||||
@@ -35,13 +36,14 @@ import org.pkl.commons.test.FileTestUtils
|
|||||||
import org.pkl.commons.test.PackageServer
|
import org.pkl.commons.test.PackageServer
|
||||||
import org.pkl.commons.test.listFilesRecursively
|
import org.pkl.commons.test.listFilesRecursively
|
||||||
import org.pkl.commons.toPath
|
import org.pkl.commons.toPath
|
||||||
|
import org.pkl.commons.walk
|
||||||
import org.pkl.core.Version
|
import org.pkl.core.Version
|
||||||
import org.pkl.core.util.IoUtils
|
import org.pkl.core.util.IoUtils
|
||||||
import org.pkl.doc.DocGenerator.Companion.current
|
import org.pkl.doc.DocGenerator.Companion.current
|
||||||
|
|
||||||
class CliDocGeneratorTest {
|
class CliDocGeneratorTest {
|
||||||
companion object {
|
companion object {
|
||||||
private val tempFileSystem by lazy { Jimfs.newFileSystem(Configuration.unix()) }
|
private val tempFileSystem: FileSystem by lazy { Jimfs.newFileSystem(Configuration.unix()) }
|
||||||
|
|
||||||
private val tmpOutputDir by lazy {
|
private val tmpOutputDir by lazy {
|
||||||
tempFileSystem.getPath("/work/output").apply { createDirectories() }
|
tempFileSystem.getPath("/work/output").apply { createDirectories() }
|
||||||
@@ -107,7 +109,7 @@ class CliDocGeneratorTest {
|
|||||||
|
|
||||||
private val binaryFileExtensions = setOf("woff2", "png", "svg")
|
private val binaryFileExtensions = setOf("woff2", "png", "svg")
|
||||||
|
|
||||||
private fun runDocGenerator(outputDir: Path, cacheDir: Path?) {
|
private fun runDocGenerator(outputDir: Path, cacheDir: Path?, noSymlinks: Boolean = false) {
|
||||||
CliDocGenerator(
|
CliDocGenerator(
|
||||||
CliDocGeneratorOptions(
|
CliDocGeneratorOptions(
|
||||||
CliBaseOptions(
|
CliBaseOptions(
|
||||||
@@ -125,6 +127,7 @@ class CliDocGeneratorTest {
|
|||||||
),
|
),
|
||||||
outputDir = outputDir,
|
outputDir = outputDir,
|
||||||
isTestMode = true,
|
isTestMode = true,
|
||||||
|
noSymlinks = noSymlinks,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.run()
|
.run()
|
||||||
@@ -254,15 +257,35 @@ class CliDocGeneratorTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `creates a symlink called current`(@TempDir tempDir: Path) {
|
fun `creates a symlink called current by default`(@TempDir tempDir: Path) {
|
||||||
PackageServer.populateCacheDir(tempDir)
|
PackageServer.populateCacheDir(tempDir)
|
||||||
runDocGenerator(actualOutputDir, tempDir)
|
runDocGenerator(actualOutputDir, tempDir)
|
||||||
|
|
||||||
val expectedSymlink = actualOutputDir.resolve("com.package1/current")
|
val expectedSymlink = actualOutputDir.resolve("com.package1/current")
|
||||||
val expectedDestination = actualOutputDir.resolve("com.package1/1.2.3")
|
val expectedDestination = actualOutputDir.resolve("com.package1/1.2.3")
|
||||||
org.junit.jupiter.api.Assertions.assertTrue(Files.isSymbolicLink(expectedSymlink))
|
|
||||||
org.junit.jupiter.api.Assertions.assertTrue(
|
assertThat(expectedSymlink).isSymbolicLink().matches {
|
||||||
Files.isSameFile(expectedSymlink, expectedDestination)
|
Files.isSameFile(it, expectedDestination)
|
||||||
)
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `creates a copy of the latest output called current when symlinks are disabled`(
|
||||||
|
@TempDir tempDir: Path
|
||||||
|
) {
|
||||||
|
PackageServer.populateCacheDir(tempDir)
|
||||||
|
runDocGenerator(actualOutputDir, tempDir, noSymlinks = true)
|
||||||
|
|
||||||
|
val currentDirectory = actualOutputDir.resolve("com.package1/current")
|
||||||
|
val sourceDirectory = actualOutputDir.resolve("com.package1/1.2.3")
|
||||||
|
|
||||||
|
assertThat(currentDirectory).isDirectory()
|
||||||
|
assertThat(currentDirectory.isSymbolicLink()).isFalse()
|
||||||
|
|
||||||
|
val expectedFiles = sourceDirectory.walk().map(sourceDirectory::relativize).toList()
|
||||||
|
val actualFiles = currentDirectory.walk().map(currentDirectory::relativize).toList()
|
||||||
|
|
||||||
|
assertThat(actualFiles).hasSameElementsAs(expectedFiles)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -261,8 +261,14 @@ public class PklPlugin implements Plugin<Project> {
|
|||||||
.getBuildDirectory()
|
.getBuildDirectory()
|
||||||
.map(it -> it.dir("pkldoc").dir(spec.getName())));
|
.map(it -> it.dir("pkldoc").dir(spec.getName())));
|
||||||
|
|
||||||
|
spec.getNoSymlinks().convention(false);
|
||||||
|
|
||||||
createModulesTask(PkldocTask.class, spec)
|
createModulesTask(PkldocTask.class, spec)
|
||||||
.configure(task -> task.getOutputDir().set(spec.getOutputDir()));
|
.configure(
|
||||||
|
task -> {
|
||||||
|
task.getOutputDir().set(spec.getOutputDir());
|
||||||
|
task.getNoSymlinks().set(spec.getNoSymlinks());
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* Copyright © 2024 Apple Inc. and the Pkl project authors. All rights reserved.
|
* Copyright © 2024-2025 Apple Inc. and the Pkl project authors. All rights reserved.
|
||||||
*
|
*
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* you may not use this file except in compliance with the License.
|
* you may not use this file except in compliance with the License.
|
||||||
@@ -16,8 +16,11 @@
|
|||||||
package org.pkl.gradle.spec;
|
package org.pkl.gradle.spec;
|
||||||
|
|
||||||
import org.gradle.api.file.DirectoryProperty;
|
import org.gradle.api.file.DirectoryProperty;
|
||||||
|
import org.gradle.api.provider.Property;
|
||||||
|
|
||||||
/** Configuration options for Pkldoc generators. Documented in user manual. */
|
/** Configuration options for Pkldoc generators. Documented in user manual. */
|
||||||
public interface PkldocSpec extends ModulesSpec {
|
public interface PkldocSpec extends ModulesSpec {
|
||||||
DirectoryProperty getOutputDir();
|
DirectoryProperty getOutputDir();
|
||||||
|
|
||||||
|
Property<Boolean> getNoSymlinks();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* Copyright © 2024 Apple Inc. and the Pkl project authors. All rights reserved.
|
* Copyright © 2024-2025 Apple Inc. and the Pkl project authors. All rights reserved.
|
||||||
*
|
*
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* you may not use this file except in compliance with the License.
|
* you may not use this file except in compliance with the License.
|
||||||
@@ -16,6 +16,8 @@
|
|||||||
package org.pkl.gradle.task;
|
package org.pkl.gradle.task;
|
||||||
|
|
||||||
import org.gradle.api.file.DirectoryProperty;
|
import org.gradle.api.file.DirectoryProperty;
|
||||||
|
import org.gradle.api.provider.Property;
|
||||||
|
import org.gradle.api.tasks.Input;
|
||||||
import org.gradle.api.tasks.OutputDirectory;
|
import org.gradle.api.tasks.OutputDirectory;
|
||||||
import org.pkl.doc.CliDocGenerator;
|
import org.pkl.doc.CliDocGenerator;
|
||||||
import org.pkl.doc.CliDocGeneratorOptions;
|
import org.pkl.doc.CliDocGeneratorOptions;
|
||||||
@@ -24,11 +26,17 @@ public abstract class PkldocTask extends ModulesTask {
|
|||||||
@OutputDirectory
|
@OutputDirectory
|
||||||
public abstract DirectoryProperty getOutputDir();
|
public abstract DirectoryProperty getOutputDir();
|
||||||
|
|
||||||
|
@Input
|
||||||
|
public abstract Property<Boolean> getNoSymlinks();
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void doRunTask() {
|
protected void doRunTask() {
|
||||||
new CliDocGenerator(
|
new CliDocGenerator(
|
||||||
new CliDocGeneratorOptions(
|
new CliDocGeneratorOptions(
|
||||||
getCliBaseOptions(), getOutputDir().get().getAsFile().toPath()))
|
getCliBaseOptions(),
|
||||||
|
getOutputDir().get().getAsFile().toPath(),
|
||||||
|
false,
|
||||||
|
getNoSymlinks().get()))
|
||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user