TexArchiveService.java

/*
 * Copyright © 2022-2025 The CTAN Team and individual authors
 *
 * This file is distributed under the 3-clause BSD license.
 * See file LICENSE for details.
 */
package org.ctan.site.services.texarchive;

import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

import org.ctan.site.CtanConfiguration;
import org.ctan.site.services.DateUtils;
import org.ctan.site.services.texarchive.PkgService.PkgTo;
import org.ctan.site.services.texarchive.readme.ReadmeReader;
import org.ctan.site.services.util.ConfigUtils;
import org.ctan.site.stores.TexArchiveNotesStore;

import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Builder.Default;
import lombok.Getter;
import lombok.NonNull;

/**
 * The class <code>TexarchiveService</code> contains the service to access the
 * <span>T<span style=
 * "text-transform:uppercase;font-size:90%;vertical-align:-0.4ex;
 * margin-left:-0.2em;margin-right:-0.1em;line-height: 0;" >e</span>X</span>
 * archive directory.
 *
 * @author <a href="mailto:gene@ctan.org">Gerd Neugebauer</a>
 */
public class TexArchiveService {

    /**
     * The class <code>FileTo</code> contains the transport object for the list
     * resource.
     */
    @Getter
    @Builder
    @AllArgsConstructor
    public static class FileTo {

        private String name;

        private long size;

        private String symlink;

        private String mtime;

        private char type;
    }

    /**
     * The class <code>ReadmeTo</code> contains the transport object for the
     * index resource.
     */
    @Getter
    @Builder
    @AllArgsConstructor
    public static class ReadmeTo {

        private String name;

        private String content;
    }

    /**
     * The class <code>TexarchiveListTo</code> contains a transport object for
     * the list resource.
     */
    @Getter
    @Builder
    @AllArgsConstructor
    @SuppressFBWarnings(value = "EI_EXPOSE_REP")
    public static class TexarchiveListTo {

        private String text;

        private PkgTo pkg;

        private ReadmeTo readme;

        private String lang;

        private int page;

        @Default
        private int size = 64;

        private boolean download;

        private List<FileTo> files;
    }

    /**
     * The field <code>base</code> contains directory containing the TeX
     * archive.
     */
    private String base = null;

    /**
     * The field <code>texArchiveFileInfoStore</code> contains the info store.
     */
    private TexArchiveNotesStore texArchiveNotesStore;

    /**
     * The field <code>defaultLang</code> contains the default language.
     */
    private String defaultLang;

    /**
     * The field <code>pkgService</code> contains the package service.
     */
    private PkgService pkgService;

    /**
     * This is the constructor for the class <code>TexarchiveService</code>.
     *
     * @param config the CTAN configuration
     * @param pkgService the package service
     * @param texArchiveFileInfoStore the store for infos
     */
    @SuppressFBWarnings(value = {"CT_CONSTRUCTOR_THROW", "EI_EXPOSE_REP2"})
    public TexArchiveService(CtanConfiguration config,
        @NonNull PkgService pkgService,
        @NonNull TexArchiveNotesStore texArchiveFileInfoStore) {

        this.pkgService = pkgService;
        this.defaultLang = ConfigUtils.defaultLanguage(config);
        this.texArchiveNotesStore = texArchiveFileInfoStore;
        base = config.getTexArchive().getDirectory();
        if (base == null) {
            throw new IllegalArgumentException(
                "Missing configuration texarchive.directory");
        }
        if (!new File(base).isDirectory()) {
            throw new IllegalArgumentException(
                "texarchive.directory isn't a directory");
        }
        if (!base.endsWith("/")) {
            base = base + "/";
        }
    }
    // /**
    // * The method <code>getIndex</code> provides means to retrieve an index
    // file
    // * for a given directory in the TeX archive.
    // *
    // * @param path the path in the TeX archive
    // * @return the data for the index file
    // */
    // public ReadmeTo getIndex(String path) {
    //
    // if (path == null
    // || "..".equals(path)
    // || path.contains("/../")
    // || path.startsWith("../")
    // || path.endsWith("/..")) {
    // throw new IllegalArgumentException();
    // }
    // var dir = new File(base + path);
    // if (!dir.isDirectory()) {
    // return null;
    // }
    //
    // return findIndex(dir);
    // }

    /**
     * The method <code>getList</code> provides means to retrieve the listing
     * for one directory.
     *
     * @param path the path in the <span>T<span style=
     *     "text-transform:uppercase;font-size:90%;vertical-align:-0.4ex;
     *     margin-left:-0.2em;margin-right:-0.1em;line-height: 0;" >e</span>
     *     X</span> archive
     * @param lang the language for the notes
     * @param page the number of the page starting with 0
     * @param size the page size
     * @return a list of files and directories
     */
    public TexarchiveListTo getList(String path, String lang, int page,
        int size) {

        if (path == null
            || "..".equals(path)
            || path.contains("/../")
            || path.startsWith("../")
            || path.endsWith("/..")) {
            throw new IllegalArgumentException();
        }
        if (size < 1) {
            size = 64;
        }
        var dir = new File(base + path);
        if (!dir.isDirectory()) {
            return null;
        }
        if (lang == null) {
            lang = defaultLang;
        }
        var list = dir
            .listFiles(
                (file, s) -> !s.endsWith(".zip") && !s.startsWith("."));
        if (list == null) {
            return null;
        }
        // Arrays.sort(list,
        // (a, b) -> a.getName().compareToIgnoreCase(b.getName()));
        List<FileTo> files = Arrays.asList(list).stream()
            .sorted((a, b) -> a.getName().compareToIgnoreCase(b.getName()))
            // .skip(page * size)
            // .limit(size)
            .map((File x) -> FileTo.builder()
                .name(x.getName())
                .size(x.length())
                .type(type(x))
                .symlink(symlink(x))
                .mtime(DateUtils.formatDateTime(x.lastModified()))
                .build())
            .collect(Collectors.toList());
        var pkgTo = ("".equals(path)
            ? null
            : pkgService.getPkgByPath(path, lang));
        var info = texArchiveNotesStore.getByPath("/" + path);
        ReadmeTo readme = null;
        readme = ReadmeReader.findIndex(dir);
        String text;
        if (pkgTo != null) {
            text = pkgTo.getCaption();
        } else if (info == null) {
            text = "";
        } else {
            text = switch (lang) {
                case "en" -> info.getNoteEn();
                case "de" -> info.getNoteDe();
                default -> "";
            };
        }
        return TexarchiveListTo.builder()
            .download(path.length() > 1
                && new File(base + path + ".zip").isFile())
            .files(files)
            .lang(lang)
            .page(page)
            .pkg(pkgTo)
            .readme(readme)
            .size(size)
            .text(text)
            .build();
    }

    /**
     * The method <code>symlink</code> provides means to extract the target of a
     * symbolic link.
     *
     * @param file the file to analyse
     * @return the path the symlink is pointing to or the empty string
     */
    private String symlink(File file) {

        try {
            var path = file.toPath();
            if (Files.isSymbolicLink(path)) {
                return Files.readSymbolicLink(path).toString();
            }
        } catch (IOException e) {
            // will not happen
        }
        return "";
    }

    /**
     * The method <code>type</code> provides means to retrieve the file type as
     * a single character.
     *
     * @param file the file
     * @return <code>l</code> if the file is a symlink<br>
     *     <code>d</code> if the file is a directory<br>
     *     <code>f</code> else
     */
    private char type(File file) {

        if (Files.isSymbolicLink(file.toPath())) {
            return 'l';
        }
        return file.isDirectory() ? 'd' : 'f';
    }
}