All Downloads are FREE. Search and download functionalities are using the official Maven repository.

io.quarkus.docs.generation.AssembleDownstreamDocumentation Maven / Gradle / Ivy

There is a newer version: 3.17.0
Show newest version
package io.quarkus.docs.generation;

//These are here to allow running the script directly from command line/IDE
//The real deps and call are in the pom.xml
//DEPS org.jboss.logging:jboss-logging:3.4.1.Final
//DEPS com.fasterxml.jackson.core:jackson-databind:2.12.3
//DEPS com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.8.0.rc1
import java.io.File;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Optional;
import java.util.Set;
import java.util.TreeSet;
import java.util.regex.Pattern;
import java.util.stream.Collectors;

import org.jboss.logging.Logger;

import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator;

import io.quarkus.docs.generation.ReferenceIndexGenerator.Index;

public class AssembleDownstreamDocumentation {

    private static final Logger LOG = Logger.getLogger(AssembleDownstreamDocumentation.class);

    private static final Path SOURCE_DOC_PATH = Path.of("src", "main", "asciidoc");
    private static final Path DOC_PATH = Path.of("target", "asciidoc", "sources");
    private static final Path INCLUDES_PATH = DOC_PATH.resolve("_includes");
    private static final Path GENERATED_DOC_FILES_PATH = Path.of("target", "quarkus-generated-doc");
    private static final Path IMAGES_PATH = DOC_PATH.resolve("images");
    private static final Path TARGET_ROOT_DIRECTORY = Path.of("target", "downstream-tree");
    private static final Path TARGET_IMAGES_DIRECTORY = TARGET_ROOT_DIRECTORY.resolve("images");
    private static final Path TARGET_INCLUDES_DIRECTORY = TARGET_ROOT_DIRECTORY.resolve("_includes");
    private static final Path TARGET_GENERATED_DIRECTORY = TARGET_ROOT_DIRECTORY.resolve("_generated");
    private static final Path TARGET_LISTING = Path.of("target", "downstream-files.txt");
    private static final Set EXCLUDED_FILES = Set.of(
            DOC_PATH.resolve("_attributes-local.adoc"));

    private static final String ADOC_SUFFIX = ".adoc";
    private static final Pattern XREF_GUIDE_PATTERN = Pattern.compile("xref:([^\\.#\\[ ]+)\\" + ADOC_SUFFIX);
    private static final Pattern XREF_PATTERN = Pattern.compile("xref:([^\\[]+)\\[]");
    private static final Pattern ANGLE_BRACKETS_WITHOUT_DESCRIPTION_PATTERN = Pattern.compile("<<([a-z0-9_\\-#\\.]+?)>>",
            Pattern.CASE_INSENSITIVE);
    private static final Pattern ANGLE_BRACKETS_WITH_DESCRIPTION_PATTERN = Pattern.compile("<<([a-z0-9_\\-#\\.]+?),([^>]+?)>>",
            Pattern.CASE_INSENSITIVE);
    private static final Pattern ANCHOR_PATTERN = Pattern.compile("^\\[#([a-z0-9_-]+)]$",
            Pattern.CASE_INSENSITIVE + Pattern.MULTILINE);
    private static final String SOURCE_BLOCK_PREFIX = "[source";
    private static final String SOURCE_BLOCK_DELIMITER = "--";
    private static final Pattern FOOTNOTE_PATTERN = Pattern.compile("footnote:([a-z0-9_-]+)\\[(\\])?");

    private static final String PROJECT_NAME_ATTRIBUTE = "{project-name}";
    private static final String RED_HAT_BUILD_OF_QUARKUS = "Red Hat build of Quarkus";

    private static final String QUARKUS_IO_GUIDES_ATTRIBUTE = "{quarkusio-guides}";

    private static final Map TABS_REPLACEMENTS = Map.of(
            Pattern.compile(
                    "((\\*) [^\n]+\n\\+)?\n\\[source,\\s?xml,\\s?role=\"primary asciidoc-tabs-target-sync-cli asciidoc-tabs-target-sync-maven\"\\]\n\\.pom.xml\n----\n((([^-]+\\-?)+\n)+?)----\n(\\+?)\n\\[source,\\s?gradle,\\s?role=\"secondary asciidoc-tabs-target-sync-gradle\"\\]\n\\.build.gradle\n----\n((([^-]+\\-?)+\n)+?)----",
                    Pattern.CASE_INSENSITIVE | Pattern.MULTILINE),
            "$1\n$2* Using Maven:\n+\n--\n[source,xml]\n----\n$3----\n--\n+\n$2* Using Gradle:\n+\n--\n[source,gradle]\n----\n$7----\n--",
            Pattern.compile(
                    "\\[source,\\s?bash,\\s?subs=attributes\\+,\\s?role=\"primary asciidoc-tabs-sync-cli\"\\]\n\\.CLI\n(----)\n((([^-]+\\-?\\-?)+\n)+?)(----)",
                    Pattern.CASE_INSENSITIVE | Pattern.MULTILINE),
            "* Using the Quarkus CLI:\n+\n--\n[source, bash, subs=attributes+]\n----\n$2----\n--",
            Pattern.compile(
                    "\\[source,\\s?bash,\\s?subs=attributes\\+,\\s?role=\"secondary asciidoc-tabs-sync-maven\"\\]\n\\.Maven\n(----)\n((([^-]+\\-?\\-?)+\n)+?)(----)",
                    Pattern.CASE_INSENSITIVE | Pattern.MULTILINE),
            "* Using Maven:\n+\n--\n[source, bash, subs=attributes+]\n----\n$2----\n--",
            Pattern.compile(
                    "\\[source,\\s?bash,\\s?subs=attributes\\+,\\s?role=\"secondary asciidoc-tabs-sync-gradle\"\\]\n\\.Gradle\n(----)\n((([^-]+\\-?\\-?)+\n)+?)(----)",
                    Pattern.CASE_INSENSITIVE | Pattern.MULTILINE),
            "* Using Gradle:\n+\n--\n[source, bash, subs=attributes+]\n----\n$2----\n--",
            Pattern.compile(
                    "\\[role=\"primary\\s?asciidoc-tabs-sync-cli\"\\]\n\\.CLI\n\\*\\*\\*\\*\n\\[source,\\s?bash,\\s?subs=attributes\\+\\]\n----\n((([^-]+\\-?\\-?)+\n)+?)----\n((([^*]+\\*?\\*?)+\n)+?)\\*\\*\\*\\*",
                    Pattern.CASE_INSENSITIVE | Pattern.MULTILINE),
            "* Using the Quarkus CLI:\n+\n--\n[source, bash, subs=attributes+]\n----\n$1----\n$4--",
            Pattern.compile(
                    "\\[role=\"secondary\\s?asciidoc-tabs-sync-maven\"\\]\n\\.Maven\n\\*\\*\\*\\*\n\\[source,\\s?bash,\\s?subs=attributes\\+\\]\n----\n((([^-]+\\-?\\-?)+\n)+?)----\n((([^*]+\\*?\\*?)+\n)+?)\\*\\*\\*\\*",
                    Pattern.CASE_INSENSITIVE | Pattern.MULTILINE),
            "* Using Maven:\n+\n--\n[source, bash, subs=attributes+]\n----\n$1----\n$4--");

    public static void main(String[] args) throws Exception {
        if (!Files.isDirectory(DOC_PATH)) {
            throw new IllegalStateException(
                    "Transformed AsciiDoc sources directory does not exist. Have you built the documentation?");
        }
        if (!Files.isDirectory(GENERATED_DOC_FILES_PATH)) {
            throw new IllegalStateException("Generated files directory `" + GENERATED_DOC_FILES_PATH
                    + "` does not exist. Have you built the documentation?");
        }
        Path referenceIndexPath = Path.of(args[0]);
        if (!Files.isReadable(Path.of(args[0]))) {
            throw new IllegalStateException("Reference index does not exist? Have you built the documentation?");
        }

        ObjectMapper om = new ObjectMapper(new YAMLFactory().enable(YAMLGenerator.Feature.MINIMIZE_QUOTES));
        Index referenceIndex = om.readValue(referenceIndexPath.toFile(), Index.class);

        Map> linkRewritingErrors = new LinkedHashMap<>();
        Map titlesByReference = referenceIndex.getReferences().stream()
                .collect(Collectors.toMap(s -> s.getReference(), s -> s.getTitle()));

        try {
            deleteDirectory(TARGET_ROOT_DIRECTORY);
            Files.deleteIfExists(TARGET_LISTING);

            ObjectMapper yamlObjectMapper = new ObjectMapper(new YAMLFactory());
            yamlObjectMapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);

            String configFilePath = System.getenv("DOWNSTREAM_CONFIG_FILE");
            if (configFilePath == null) {
                configFilePath = "downstreamdoc.yaml";
            }
            ConfigFile configFile = yamlObjectMapper.readValue(new File(configFilePath), ConfigFile.class);

            String additionals = System.getenv("DOWNSTREAM_ADDITIONALS");
            if (additionals != null) {
                String[] additional_files = additionals.split(",");
                LOG.info("Additional files: " + Arrays.toString(additional_files));
                for (String file : additional_files) {
                    configFile.guides.add(file);
                }
            }

            String excludes = System.getenv("DOWNSTREAM_EXCLUDES");
            if (excludes != null) {
                String[] excludePatterns = excludes.split(",");
                LOG.info("Excluding patterns: " + Arrays.toString(excludePatterns));
                for (String pattern : excludePatterns) {
                    Pattern regexPattern = Pattern.compile(pattern);
                    configFile.guides.removeIf(guide -> regexPattern.matcher(guide).find());
                }
            }

            Set guides = new TreeSet<>();
            Set simpleIncludes = new TreeSet<>();
            Set includes = new TreeSet<>();
            Set generatedDocFiles = new TreeSet<>();
            Set images = new TreeSet<>();

            Set allResolvedPaths = new TreeSet<>();

            Set downstreamGuides = new TreeSet<>();

            for (String guide : new TreeSet<>(configFile.guides)) {
                Path guidePath = DOC_PATH.resolve(SOURCE_DOC_PATH.relativize(Path.of(guide)));

                if (!Files.isReadable(guidePath)) {
                    LOG.error("Unable to read file " + guidePath);
                    continue;
                }

                downstreamGuides.add(guidePath.getFileName().toString());
                allResolvedPaths.add(guidePath);

                GuideContent guideContent = new GuideContent(guidePath);
                getFiles(guideContent, guidePath);

                guides.add(guidePath);
                simpleIncludes.addAll(guideContent.simpleIncludes);
                includes.addAll(guideContent.includes);
                generatedDocFiles.addAll(guideContent.generatedDocFiles);
                images.addAll(guideContent.images);
            }

            Files.createDirectories(TARGET_ROOT_DIRECTORY);

            for (Path guide : guides) {
                System.out.println("[INFO] Processing guide " + guide.getFileName());
                copyAsciidoc(guide, TARGET_ROOT_DIRECTORY.resolve(guide.getFileName()), downstreamGuides, titlesByReference,
                        linkRewritingErrors);
            }
            for (Path simpleInclude : simpleIncludes) {
                Path sourceFile = DOC_PATH.resolve(simpleInclude);

                if (EXCLUDED_FILES.contains(sourceFile)) {
                    continue;
                }
                if (!Files.isReadable(sourceFile)) {
                    LOG.error("Unable to read include " + sourceFile);
                }
                allResolvedPaths.add(sourceFile);
                Path targetFile = TARGET_ROOT_DIRECTORY.resolve(simpleInclude);
                Files.createDirectories(targetFile.getParent());
                copyAsciidoc(sourceFile, targetFile, downstreamGuides, titlesByReference, linkRewritingErrors);
            }
            for (Path include : includes) {
                Path sourceFile = INCLUDES_PATH.resolve(include);
                if (EXCLUDED_FILES.contains(sourceFile)) {
                    continue;
                }
                if (!Files.isReadable(sourceFile)) {
                    LOG.error("Unable to read include " + sourceFile);
                }
                allResolvedPaths.add(sourceFile);
                Path targetFile = TARGET_INCLUDES_DIRECTORY.resolve(include);
                Files.createDirectories(targetFile.getParent());
                copyAsciidoc(sourceFile, targetFile, downstreamGuides, titlesByReference, linkRewritingErrors);
            }

            copyGeneratedFiles(linkRewritingErrors, titlesByReference, allResolvedPaths,
                    downstreamGuides, GENERATED_DOC_FILES_PATH, generatedDocFiles);

            for (Path image : images) {
                Path sourceFile = IMAGES_PATH.resolve(image);
                if (EXCLUDED_FILES.contains(sourceFile)) {
                    continue;
                }
                if (!Files.isReadable(sourceFile)) {
                    LOG.error("Unable to read image " + sourceFile);
                }
                allResolvedPaths.add(sourceFile);
                Path targetFile = TARGET_IMAGES_DIRECTORY.resolve(image);
                Files.createDirectories(targetFile.getParent());
                Files.copy(sourceFile, targetFile, StandardCopyOption.REPLACE_EXISTING);
            }

            Files.writeString(TARGET_LISTING,
                    allResolvedPaths.stream().map(p -> p.toString()).collect(Collectors.joining("\n")));

            if (!linkRewritingErrors.isEmpty()) {
                System.out.println();
                System.out.println("################################################");
                System.out.println("# Errors occurred while transforming references");
                System.out.println("################################################");
                System.out.println();

                for (Entry> errorEntry : linkRewritingErrors.entrySet()) {
                    System.out.println("- " + errorEntry.getKey());
                    for (String error : errorEntry.getValue()) {
                        System.out.println("    . " + error);
                    }
                }

                System.out.println();
                System.exit(1);
            }

            LOG.info("Downstream documentation tree is available in: " + TARGET_ROOT_DIRECTORY);
            LOG.info("Downstream documentation listing is available in: " + TARGET_LISTING);
        } catch (IOException e) {
            throw new UncheckedIOException("An error occurred while generating the downstream tree", e);
        }
    }

    private static void copyGeneratedFiles(Map> linkRewritingErrors, Map titlesByReference,
            Set allResolvedPaths, Set downstreamGuides, Path generatedSourceFilesDirectory,
            Set generatedFiles)
            throws IOException {
        for (Path generatedConfigDocFile : generatedFiles) {
            Path sourceFile = generatedSourceFilesDirectory.resolve(generatedConfigDocFile);
            if (EXCLUDED_FILES.contains(sourceFile)) {
                continue;
            }
            if (!Files.isReadable(sourceFile)) {
                LOG.error("Unable to read generated file " + sourceFile);
            }
            allResolvedPaths.add(sourceFile);
            Path targetFile = TARGET_GENERATED_DIRECTORY.resolve(generatedConfigDocFile);
            Files.createDirectories(targetFile.getParent());
            copyAsciidoc(sourceFile, targetFile, downstreamGuides, titlesByReference, linkRewritingErrors);
        }
    }

    private static void getFiles(GuideContent guideContent, Path currentFile) throws IOException {
        List lines = Files.readAllLines(currentFile);

        for (String line : lines) {
            Optional possibleInclude = extractPath(line, "include::{includes}");
            if (possibleInclude.isPresent()) {
                guideContent.includes.add(possibleInclude.get());
                getFurtherIncludes(guideContent, INCLUDES_PATH.resolve(possibleInclude.get()));
                continue;
            }
            Optional possibleGeneratedConfigDocFile = extractPath(line, "include::{generated-dir}");
            if (possibleGeneratedConfigDocFile.isPresent()) {
                guideContent.generatedDocFiles.add(possibleGeneratedConfigDocFile.get());
                continue;
            }
            Optional possibleSimpleInclude = extractPath(line, "include::");
            if (possibleSimpleInclude.isPresent()) {
                guideContent.simpleIncludes.add(possibleSimpleInclude.get());
                getFiles(guideContent, currentFile.getParent().resolve(possibleSimpleInclude.get()));
                continue;
            }
            Optional possibleImage = extractPath(line, "image::");
            if (possibleImage.isPresent()) {
                guideContent.images.add(possibleImage.get());
                continue;
            }
        }
    }

    private static void getFurtherIncludes(GuideContent guideContent, Path currentFile) throws IOException {
        List lines = Files.readAllLines(currentFile);

        for (String line : lines) {
            Optional possibleInclude = extractPath(line, "include::");
            if (possibleInclude.isPresent()) {
                guideContent.includes.add(possibleInclude.get());
                getFurtherIncludes(guideContent, currentFile.getParent().resolve(possibleInclude.get()));
                continue;
            }
            Optional possibleImage = extractPath(line, "image::");
            if (possibleImage.isPresent()) {
                guideContent.images.add(possibleImage.get());
                continue;
            }
        }
    }

    private static Optional extractPath(String asciidoc, String prefix) {
        if (!asciidoc.startsWith(prefix)) {
            return Optional.empty();
        }

        String path = asciidoc.substring(prefix.length(), asciidoc.indexOf('['));

        if (path.startsWith("/")) {
            path = path.substring(1);
        }

        return Optional.of(Path.of(path));
    }

    private static void deleteDirectory(Path directory) throws IOException {
        if (!Files.isDirectory(directory)) {
            return;
        }

        Files.walk(directory)
                .sorted(Comparator.reverseOrder())
                .map(Path::toFile)
                .forEach(File::delete);
    }

    private static void copyAsciidoc(Path sourceFile, Path targetFile, Set downstreamGuides,
            Map titlesByReference,
            Map> linkRewritingErrors) throws IOException {
        List guideLines = Files.readAllLines(sourceFile);

        StringBuilder rewrittenGuide = new StringBuilder();
        StringBuilder currentBuffer = new StringBuilder();
        boolean inSourceBlock = false;
        boolean findDelimiter = false;
        String currentSourceBlockDelimiter = "----";
        int lineNumber = 0;
        boolean documentTitleFound = false;

        for (String line : guideLines) {
            lineNumber++;

            if (!documentTitleFound && line.startsWith("= ")) {
                // anything in the buffer needs to be appended
                // we don't need to rewrite it as before the title we can only have the preamble
                // and we don't want to change anything in the preamble
                // if at some point we want to adjust the preamble, make sure to do it in a separate method and not reuse rewriteContent
                if (currentBuffer.length() > 0) {
                    rewrittenGuide.append(currentBuffer);
                    currentBuffer.setLength(0);
                }

                // this is the document title
                rewrittenGuide.append(line.replace(PROJECT_NAME_ATTRIBUTE, RED_HAT_BUILD_OF_QUARKUS) + "\n");
                documentTitleFound = true;
                continue;
            }
            if (inSourceBlock) {
                if (findDelimiter) {
                    rewrittenGuide.append(line + "\n");
                    if (line.isBlank() || line.startsWith(".")) {
                        continue;
                    }
                    if (!line.startsWith(SOURCE_BLOCK_DELIMITER)) {
                        throw new IllegalStateException("Unable to find source block delimiter in file "
                                + sourceFile + " at line " + lineNumber);
                    }
                    currentSourceBlockDelimiter = line.stripTrailing();
                    findDelimiter = false;
                    continue;
                }

                if (line.stripTrailing().equals(currentSourceBlockDelimiter)) {
                    inSourceBlock = false;
                }
                rewrittenGuide.append(line + "\n");
                continue;
            }
            if (line.startsWith(SOURCE_BLOCK_PREFIX)) {
                inSourceBlock = true;
                findDelimiter = true;

                if (currentBuffer.length() > 0) {
                    rewrittenGuide.append(
                            rewriteContent(sourceFile.getFileName().toString(), currentBuffer.toString(), downstreamGuides,
                                    titlesByReference, linkRewritingErrors));
                    currentBuffer.setLength(0);
                }
                rewrittenGuide.append(line + "\n");
                continue;
            }

            currentBuffer.append(line + "\n");
        }

        if (currentBuffer.length() > 0) {
            rewrittenGuide.append(
                    rewriteContent(sourceFile.getFileName().toString(), currentBuffer.toString(), downstreamGuides,
                            titlesByReference, linkRewritingErrors));
        }

        String rewrittenGuideWithoutTabs = rewrittenGuide.toString().trim();

        for (Entry tabReplacement : TABS_REPLACEMENTS.entrySet()) {
            rewrittenGuideWithoutTabs = tabReplacement.getKey().matcher(rewrittenGuideWithoutTabs)
                    .replaceAll(tabReplacement.getValue());
        }

        Files.writeString(targetFile, rewrittenGuideWithoutTabs.trim());
    }

    private static String rewriteContent(String fileName,
            String content,
            Set downstreamGuides,
            Map titlesByReference,
            Map> errors) {
        content = XREF_PATTERN.matcher(content).replaceAll(mr -> {
            String reference = getQualifiedReference(fileName, mr.group(1));
            String title = titlesByReference.get(reference);
            if (title == null || title.isBlank()) {
                addError(errors, fileName, "Unable to find title for: " + mr.group() + " [" + reference + "]");
                title = "~~ unknown title ~~";
            }
            return "xref:" + trimReference(mr.group(1)) + "[" + escapeXrefTitleForReplaceAll(title) + "]";
        });

        content = ANGLE_BRACKETS_WITHOUT_DESCRIPTION_PATTERN.matcher(content).replaceAll(mr -> {
            String reference = getQualifiedReference(fileName, mr.group(1));
            String title = titlesByReference.get(reference);
            if (title == null || title.isBlank()) {
                addError(errors, fileName, "Unable to find title for: " + mr.group() + " [" + reference + "]");
                title = "~~ unknown title ~~";
            }
            return "xref:" + trimReference(mr.group(1)) + "[" + escapeXrefTitleForReplaceAll(title) + "]";
        });

        content = ANGLE_BRACKETS_WITH_DESCRIPTION_PATTERN.matcher(content).replaceAll(mr -> {
            return "xref:" + trimReference(mr.group(1)) + "[" + escapeXrefTitleForReplaceAll(mr.group(2)) + "]";
        });

        content = XREF_GUIDE_PATTERN.matcher(content).replaceAll(mr -> {
            if (downstreamGuides.contains(mr.group(1) + ADOC_SUFFIX)) {
                return mr.group(0);
            }

            return "link:" + QUARKUS_IO_GUIDES_ATTRIBUTE + "/" + mr.group(1);
        });

        content = ANCHOR_PATTERN.matcher(content).replaceAll(mr -> {
            return "[[" + mr.group(1) + "]]";
        });

        content = FOOTNOTE_PATTERN.matcher(content).replaceAll(mr -> {
            if (mr.group(2) != null) {
                return "footnoteref:[" + mr.group(1) + "]";
            }

            return "footnoteref:[" + mr.group(1) + ", ";
        });

        return content;
    }

    private static String escapeXrefTitleForReplaceAll(String title) {
        return title.trim().replace("]", "\\\\]");
    }

    private static String trimReference(String reference) {
        reference = normalizeAdoc(reference);

        if (reference.startsWith("#")) {
            return reference.substring(1);
        }

        if (reference.contains(".adoc")) {
            return reference;
        }

        if (reference.contains("#")) {
            int hashIndex = reference.indexOf('#');
            return reference.substring(0, hashIndex) + ".adoc" + reference.substring(hashIndex);
        }

        return reference;
    }

    private static String getQualifiedReference(String fileName, String reference) {
        reference = normalizeAdoc(reference);

        if (reference.startsWith("#")) {
            return fileName + reference;
        }

        if (reference.contains(".adoc")) {
            return reference;
        }

        if (reference.contains("#")) {
            int hashIndex = reference.indexOf('#');
            return reference.substring(0, hashIndex) + ".adoc" + reference.substring(hashIndex);
        }

        return fileName + "#" + reference;
    }

    private static String normalizeAdoc(String adoc) {
        if (adoc.startsWith("./")) {
            return adoc.substring(2);
        }

        return adoc;
    }

    private static void addError(Map> errors, String fileName, String error) {
        errors.computeIfAbsent(fileName, f -> new ArrayList<>())
                .add(error);
    }

    public static class GuideContent {

        public Path guide;
        public Set simpleIncludes = new TreeSet<>();
        public Set includes = new TreeSet<>();
        public Set images = new TreeSet<>();
        public Set generatedDocFiles = new TreeSet<>();

        public GuideContent(Path guide) {
            this.guide = guide;
        }
    }

    public static class ConfigFile {

        public List guides;
    }
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy