alexandria-core/src/main/java/com/github/macgregor/alexandria/Context.java
package com.github.macgregor.alexandria;
import com.github.macgregor.alexandria.exceptions.AlexandriaException;
import com.github.macgregor.alexandria.markdown.MarkdownConverter;
import com.github.macgregor.alexandria.remotes.Remote;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.NonNull;
import lombok.experimental.Accessors;
import lombok.extern.slf4j.Slf4j;
import java.io.IOException;
import java.nio.file.Path;
import java.util.*;
/**
* Runtime context containing arguments passed from the user agent (e.g. search path, files to include or exclude, etc).
*
* This information is not persisted by Alexandria as it is considered specific to the runtime environment at execution
* that is not necessarily suitable distribution to others. Absolute paths are a good example of things that would
* break others if they were committed to git.
*
* Any paths in the context should be absolute, while paths in {@link Config} are relative.
*/
@Data
@Slf4j
@Accessors(fluent = true)
@AllArgsConstructor @NoArgsConstructor
public class Context {
/** Absolute path to the Alexandria {@link Config} file to load/save to. */
@NonNull protected Path configPath;
/** Absolute path to the project base where Alexandria is being executed */
@NonNull protected Path projectBase;
/** Absolute paths to use when searching for documents to index */
@NonNull protected List<Path> searchPath;
/** Absolute path to output converted documents to. If not set, documents are converted in place where they are found. Default: none. */
protected Optional<Path> outputPath = Optional.empty();
/** File include patterns to use when searching for documents to index. See {@link PathFinder}. Default: *.md */
protected List<String> include = new ArrayList<>(Arrays.asList("*.md"));
/** File exclude patterns to use when searching for documents to index. See {@link PathFinder}. Default: none */
protected List<String> exclude = new ArrayList<>();
/** Enables adding a footer to converted documents warning readers edits to files on the remote will be overridden. Default: true */
protected boolean disclaimerFooterEnabled = true;
/** Path to a custom footer (markdown) file. If not set, a standard template is used. */
protected Optional<Path> disclaimerFooterPath = Optional.empty();
/** Working Aleandria config containing document index and remote config. */
@NonNull protected Config config = new Config();
/** Alexandria config originally loaded from the file system, kept in case of failures saving back to the filesystem. */
@NonNull protected Config originalConfig = new Config();
/** Cache for tracking absolute converted file paths for indexed metadata. Default: empty map. */
protected Map<Config.DocumentMetadata, Path> convertedPaths = new HashMap<>();
/** Remote that has been configured and initialized, can be used to retrieve the remote for any class or method that has the context */
protected Optional<Remote> remote = Optional.empty();
/**
* Sets the path to the Alexandria config file. <b>Must be an absolute path</b>.
*
* Resolving relative paths is system dependent. If we dont have a predictable absolute path to use as a reference
* point we may get into some weird states. It is up to the caller instantiating the context to properly set this
* absolute path so that Alexandria can accurately create absolute and relative paths as it works.
*
* @param configPath An absolute path to the Alexandria config file. Used to resolve all other relative and absolute paths.
* @return Alexandria context
*/
public Context configPath(Path configPath){
if(!configPath.isAbsolute()){
this.configPath = configPath.toAbsolutePath();
} else {
this.configPath = configPath;
}
return this;
}
/**
* Resolve a relative path to an absolute one using the {@link #configPath} directory. Useful for resolving relative
* {@link com.github.macgregor.alexandria.Config.DocumentMetadata#sourcePath}.
*
* @param relativePath relative path to convert to absolute
* @return the absolute path
*/
public Path resolveRelativePath(Path relativePath){
return configPath.getParent().resolve(relativePath);
}
/**
* Convenience method for determining the size of the documentation index.
*
* @return {@link Config#metadata} size if present, otherwise 0
*/
public int documentCount(){
return config.metadata().isPresent() ? config.metadata().get().size() : 0;
}
/**
* Convenience method to retrieve the absolute converted file path in the cache for the given document.
*
* @param metadata indexed document to look for
* @return The converted path if it exist in the cache, otherwise Optional.empty()
*/
public Optional<Path> convertedPath(Config.DocumentMetadata metadata){
return convertedPaths.containsKey(metadata) ? Optional.of(convertedPaths.get(metadata)) : Optional.empty();
}
/**
* Convenience method to add a absolute converted file path to the cache.
*
* @param metadata indexed metadata that has been converted.
* @param path absolute path to the converted html file for the document.
*/
public void convertedPath(Config.DocumentMetadata metadata, Path path){
convertedPaths.put(metadata, path);
}
/**
* Safetly add new document metadata to the index, converting the source path to absolute if necessary.
*
* @param metadata metadata to add
* @return Alexandria context.
*/
public Context addMetadata(Config.DocumentMetadata metadata){
if(!config.metadata().isPresent()){
config.metadata(Optional.of(new ArrayList<>()));
}
metadata.sourcePath(Resources.absolutePath(configPath.getParent(), metadata.sourcePath()));
config.metadata().get().add(metadata);
return this;
}
/**
* Return the matching {@link Config.DocumentMetadata} for a {@link Path} if it exists.
*
* @param path path to the potential document, will be made absolute as needed
* @return the matching {@link com.github.macgregor.alexandria.Config.DocumentMetadata} if it exists or
* Optional.empty() if it doesnt
*/
public Optional<Config.DocumentMetadata> isIndexed(Path path){
Path absolutePath = Resources.absolutePath(configPath().getParent(), path);
if(config.metadata().isPresent()){
for(Config.DocumentMetadata metadata : config.metadata().get()){
if(metadata.sourcePath().equals(absolutePath)){
return Optional.of(metadata);
}
}
}
return Optional.empty();
}
/**
* Convenience method for making a path relative to the {@link Context#configPath} absolute.
*
* @param path relative path to be made absolute. It is assumed to be relative to {@link Context#configPath}
* @return absolute representation of the provided {@link Path}
*/
public Path absolutePath(Path path){
return Resources.absolutePath(configPath().getParent(), path);
}
/**
* Make all {@link Config} and {@link Context} paths absolute relative to {@link #configPath}.
*
* @return Alexandria context.
*/
public Context makePathsAbsolute(){
if( configPath != null){
projectBase = Resources.absolutePath(configPath.getParent(), projectBase);
outputPath = Optional.ofNullable(Resources.absolutePath(configPath.getParent(), outputPath.orElse(null)));
searchPath = (List<Path>) Resources.absolutePath(configPath.getParent(), searchPath);
if(disclaimerFooterPath.isPresent()){
disclaimerFooterPath = Optional.of(Resources.absolutePath(configPath.getParent(), disclaimerFooterPath.get()));
}
if(config.metadata().isPresent()){
config.metadata().get()
.stream()
.forEach(m -> {
m.sourcePath(Resources.absolutePath(configPath.getParent(), m.sourcePath()));
});
}
}
return this;
}
/**
* Make all {@link Config} and {@link Context} paths relative to {@link #configPath}.
*
* @return Alexandria context.
*/
public Context makePathsRelative(){
if( configPath != null){
projectBase = Resources.relativeTo(configPath.getParent(), projectBase);
outputPath = Optional.ofNullable(Resources.relativeTo(configPath.getParent(), outputPath.orElse(null)));
searchPath = (List<Path>) Resources.relativeTo(configPath.getParent(), searchPath);
if(disclaimerFooterPath.isPresent()){
disclaimerFooterPath = Optional.of(Resources.relativeTo(configPath.getParent(), disclaimerFooterPath.get()));
}
if(config.metadata().isPresent()){
config.metadata().get()
.stream()
.forEach(m -> {
m.sourcePath(Resources.relativeTo(configPath.getParent(), m.sourcePath()));
});
}
}
return this;
}
/**
* Get tags for the document resolving default tags and document tags.
*
* @param metadata document metadata to get tags for
* @return list of tags to add to the request or empty list if none are set
*/
public List<String> getTagsForDocument(Config.DocumentMetadata metadata){
List<String> tags = new ArrayList();
if(config.defaultTags().isPresent()){
tags.addAll(config.defaultTags().get());
}
if(metadata.tags().isPresent()){
tags.addAll(metadata.tags().get());
}
return tags;
}
/**
* Get extra properties for a document, resolving default remote extra props and document extra props.
*
* @param metadata document metadata to get extra properties for
* @return map containing extra properties to use for processing a request
*/
public Map<String, String> getExtraPropertiesForDocument(Config.DocumentMetadata metadata){
Map<String, String> extraProps = new HashMap<>();
if(config.remote().defaultExtraProps().isPresent()){
extraProps.putAll(config.remote().defaultExtraProps().get());
}
if(metadata.extraProps().isPresent()){
extraProps.putAll(metadata.extraProps().get());
}
return extraProps;
}
/**
* Instantiate a {@link Remote} implementation based on the {@link Config#remote}.
*
* The class instantiation logic is very simple, but should be adequate for this simple use case. Essentially we just
* pick the right class using the fully qualified class name in {@link com.github.macgregor.alexandria.Config.RemoteConfig#clazz}.
* Implementation specific configuration and validation is delegated to the implementing class by calling
* {@link Remote#configure(Config.RemoteConfig)} and {@link Remote#validateRemoteConfig()}.
*
* Only instantiates the remote implementation once, future calls to this method will return the value of {@link #remote}.
*
* @see com.github.macgregor.alexandria.remotes.NoopRemote
*
* @return configured remote ready for use
* @throws AlexandriaException Exception wrapping any exception thrown instantiation, configuring or validating the remote
*/
protected Remote configureRemote() throws AlexandriaException {
if(this.remote.isPresent()){
return this.remote.get();
}
Remote remote = Reflection.create(config().remote().clazz());
Reflection.maybeImplementsInterface(remote, ContextAware.class)
.ifPresent(r -> r.alexandriaContext(this));
MarkdownConverter converter = Reflection.create(config().remote().converterClazz());
Reflection.maybeImplementsInterface(converter, ContextAware.class)
.ifPresent(c -> c.alexandriaContext(this));
remote.markdownConverter(converter);
remote.configure(config().remote());
remote.validateRemoteConfig();
this.remote = Optional.of(remote);
return remote;
}
/**
* Initialize Alexandria's {@link Context}, loading the {@link Config} from the given file path.
*
* Required context paths will be set to the directory of {@code filePath} and should be appropriately
* overridden before performing any operations. If the path doesnt exist, a blank {@link Config}
* will be created and saved to {@code filePath} when saving.
*
* @param filePath path to the config file where remote details and document metadata will be saved
* @return Initialized Alexandria context instance that will be provided to operations
* @throws IOException problems converting strings to paths or general file loading problems
*/
public static Context load(String filePath) throws IOException {
Context context = new Context();
Path path = Resources.path(filePath, false).toAbsolutePath();
context.configPath(path);
context.projectBase(path.getParent());
context.searchPath(Collections.singletonList(path.getParent()));
if(path.toFile().exists()) {
Config originalConfig = Jackson.yamlMapper().readValue(path.toFile(), Config.class);
Config config = Jackson.yamlMapper().readValue(path.toFile(), Config.class);
context.config(config);
context.originalConfig(originalConfig);
log.debug(String.format("Loaded configuration from %s", path.toString()));
} else{
log.debug(String.format("Created default configuration for new file %s", path.toString()));
}
context.makePathsAbsolute();
return context;
}
/**
* Save the current context config (metadata and remote configuration) to disk.
*
* Not all information is saved, only the config field. See {@link Config} and {@link Context}.
*
* @param context context containing configuration to save
* @throws IOException problems saving the file
*/
public static void save(Context context) throws IOException {
context.makePathsRelative();
Config toSave = context.originalConfig;
toSave.metadata(context.config.metadata());
Jackson.yamlMapper().writeValue(context.configPath().toFile(), toSave);
context.makePathsAbsolute();
log.debug(String.format("Saved configuration to %s", context.configPath().toString()));
}
/**
* Interface indicating that a class needs the Alexandria {@link Context}.
*
* This is mostly an informational class as there is no Dependency Injection framework to
* automatically add context but {@link Context#configureRemote()} will examine the {@link Remote}
* and {@link MarkdownConverter} classes to see if they implment this interface, providing the
* {@link Context} to them if it is found.
*/
public interface ContextAware {
/**
* Provides the {@link Context} to the implementing class.
*
* @param context initialized Alexandria {@link Context}
*/
void alexandriaContext(Context context);
/**
* Return the configured Alexandria {@link Context}.
*
* May return null if called before {@link ContextAware#alexandriaContext(Context)} has been called.
* @return Alexandria {@link Context}, nullable
*/
Context alexandriaContext();
}
}