macgregor/alexandria

View on GitHub
alexandria-core/src/main/java/com/github/macgregor/alexandria/AlexandriaSync.java

Summary

Maintainability
B
4 hrs
Test Coverage
package com.github.macgregor.alexandria;

import com.github.macgregor.alexandria.exceptions.AlexandriaException;
import com.github.macgregor.alexandria.remotes.Remote;
import lombok.*;
import lombok.experimental.Accessors;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.FileUtils;

import java.util.Arrays;
import java.util.List;
import java.util.Optional;

/**
 * Sync indexed documents with the configured remote.
 *
 * For each document, the state of the file is determined and the appropriate {@link Remote}
 * method is called. Some metadata will be updated by this class, but most is delegated to
 * the remote implementation as it is dependent on information from the rest response.
 *
 * @see Remote
 * @see com.github.macgregor.alexandria.Config.DocumentMetadata#determineState()
 */
@Slf4j
@ToString
@Getter @Setter @Accessors(fluent = true)
@NoArgsConstructor @AllArgsConstructor
public class AlexandriaSync {

    @NonNull private Context context;
    @NonNull private Remote remote;

    /**
     * Instantiate from the context and configure a remote given the {@link Config#remote}.
     *
     * @see Context#configureRemote()
     *
     * @param context  Alexandria context containing the remote config and indexed documents to sync
     * @throws AlexandriaException  Exception wrapping all exceptions thrown configuring the remote
     */
    public AlexandriaSync(Context context) throws AlexandriaException {
        this.context = context;
        this.remote = context.configureRemote();
    }

    /**
     * Execute the sync process with 2 passes allowing rendering that relies on state outside of the source document,
     * like resolving links for documents that dont exist yet, to work without the user invoking the command twice.
     *
     * This is a little bit of a hack to avoid complicating the sync process with a more elaborate ordering/state resolution
     * process that would likely involve more work on the {@link Remote} implementation which could get messy quickly.
     * Simply doing two passes should solve most cases and shouldn't be a huge performance issue. If large numbers of documents
     * are a problem, implementing parallel rest requests would be a better optimization to implement first.
     *
     * @throws AlexandriaException
     */
    public void syncWithRemote() throws AlexandriaException {
        log.debug("Initiating sync with remote.");

        context.makePathsAbsolute();

        log.info("Syncing with {}", context.config().remote().baseUrl().get());
        this.sync();

        if(remote.twoPassSync()) {
            log.info("Syncing with {} - Pass 2 (requested by {})",
                    context.config().remote().baseUrl().get(), remote.getClass().getSimpleName());
            this.sync();
        }
    }

    /**
     * Execute the sync process.
     *
     * For each document, the state of the file is determined and the appropriate {@link Remote}
     * method is called.
     * <ul>
     *     <li>DELETE: delete document from remote</li>
     *     <li>CREATE: create document with remote, calculates and sets {@code sourceChecksum} on metadata</li>
     *     <li>UPDATE: create document with remote, calculates and sets {@code sourceChecksum} on metadata</li>
     *     <li>CURRENT: ignore</li>
     *     <li>DELETED: ignore</li>
     * </ul>
     *
     * {@link Context#save(Context)} will be called after each document is handled to ensure an unexpected problem
     * in the batch wont make the local state differ from the remote state. For example, creating a document and not saving
     * the {@code remoteUri} would cause Alexandria to create a new document on the remote on the next run.
     *
     * Because {@link Remote} implementations can have behavior that results in different converted documents even if the
     * source has not changed (e.g. resolving remote links will render the remote uri only after the document they reference
     * is created), all documents are reconverted before determining their state. This may seem wasteful, especially if all
     * three Alexandria phases are run at once, it reduces algorithm complexity. If performance becomes a problem, consider
     * skipping {@link AlexandriaConvert#convert()} when run along with {@link AlexandriaSync#syncWithRemote()}.
     *
     * @see Remote
     * @see com.github.macgregor.alexandria.Config.DocumentMetadata#determineState()
     *
     * @throws AlexandriaException  Exception wrapping all exceptions thrown while syncing documents
     */
    protected void sync() throws AlexandriaException {
        BatchProcess<Config.DocumentMetadata> batchProcess = new BatchProcess<>(context);
        batchProcess.execute(context -> context.config().metadata().get(), (context, metadata) -> {
            log.debug(String.format("Syncing %s with remote.", metadata.sourceFileName()));
            remote.validateDocumentMetadata(metadata);

            List<Config.DocumentMetadata.State> unconvertableStates =
                    Arrays.asList(Config.DocumentMetadata.State.DELETED, Config.DocumentMetadata.State.DELETE);
            if(!unconvertableStates.contains(metadata.determineState())) {
                // always convert to catch when AlexandriaSync is run without AlexandriaConvert
                // this also catches things like markdown converters output changing when the source stays the same,
                // like resolving relative links to newly created remote URIs
                AlexandriaConvert.convert(context, metadata, remote.markdownConverter());
            }

            long currentChecksum;
            Config.DocumentMetadata.State state = metadata.determineState();
            switch(state){
                case DELETE:
                    remote.delete(metadata);
                    log.info(String.format("%s (remote: %s) deleted from remote. Local file will not be removed by Alexandria.", metadata.sourceFileName(), metadata.remoteUri().orElse(null)));
                    break;
                case CREATE:
                    if(Resources.fileContentsAreBlank(metadata.sourcePath().toString())){
                        log.info(String.format("%s has no contents, not creating on remote", metadata.sourceFileName()));
                    } else{
                        remote.create(metadata);
                        currentChecksum = FileUtils.checksumCRC32(metadata.sourcePath().toFile());
                        metadata.sourceChecksum(Optional.of(currentChecksum));
                        log.info(String.format("%s (remote: %s) created on remote", metadata.sourceFileName(), metadata.remoteUri().orElse(null)));
                    }
                    break;
                case UPDATE:
                    if(Resources.fileContentsAreBlank(metadata.sourcePath().toString())){
                        log.info(String.format("%s has no contents, not updating on remote", metadata.sourceFileName()));
                    } else {
                        remote.update(metadata);
                        currentChecksum = FileUtils.checksumCRC32(metadata.sourcePath().toFile());
                        metadata.sourceChecksum(Optional.of(currentChecksum));
                        log.info(String.format("%s (remote: %s) updated on remote.", metadata.sourceFileName(), metadata.remoteUri().orElse(null)));
                    }
                    break;
                case DELETED:
                case CURRENT:
                    log.info(String.format("%s (remote: %s) already current with remote: %s", metadata.sourceFileName(), metadata.remoteUri().orElse(null), state));
                    break;
            }
            Context.save(context);
        }, (context, exceptions) -> {
            log.info(String.format("Synced %d out of %d documents with remote %s",
                    context.documentCount() - exceptions.size(), context.documentCount(),
                    context.config().remote().baseUrl().orElse(null)));
            Context.save(context);
            return BatchProcess.EXCEPTIONS_UNHANDLED;
        });
    }
}