Refactoring, more abstraction

Signed-off-by: Minecon724 <git@m724.eu>
This commit is contained in:
Minecon724 2025-03-10 17:50:47 +01:00
parent d6c33b4992
commit 62eb9903c0
Signed by untrusted user who does not match committer: Minecon724
GPG key ID: 3CCC4D267742C8E8
15 changed files with 306 additions and 119 deletions

1
.idea/vcs.xml generated
View file

@ -2,6 +2,7 @@
<project version="4"> <project version="4">
<component name="VcsDirectoryMappings"> <component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" /> <mapping directory="$PROJECT_DIR$" vcs="Git" />
<mapping directory="$PROJECT_DIR$/example_workdir" vcs="Git" />
<mapping directory="$PROJECT_DIR$/m724" vcs="Git" /> <mapping directory="$PROJECT_DIR$/m724" vcs="Git" />
</component> </component>
</project> </project>

View file

@ -6,17 +6,19 @@
package eu.m724.blog; package eu.m724.blog;
import eu.m724.blog.compress.CommonsCompressor;
import eu.m724.blog.compress.CompressException;
import eu.m724.blog.compress.FileCompressor; import eu.m724.blog.compress.FileCompressor;
import eu.m724.blog.compress.NoSuchAlgorithmException; import eu.m724.blog.compress.NoSuchAlgorithmException;
import eu.m724.blog.object.Article; import eu.m724.blog.object.Article;
import eu.m724.blog.object.Feed; import eu.m724.blog.object.Feed;
import eu.m724.blog.object.RenderOptions; import eu.m724.blog.object.RenderOptions;
import eu.m724.blog.object.Site; import eu.m724.blog.object.Site;
import eu.m724.blog.server.Server;
import eu.m724.blog.template.TemplateRenderer; import eu.m724.blog.template.TemplateRenderer;
import org.apache.commons.compress.compressors.CompressorException; import eu.m724.blog.vc.GitVersionControl;
import eu.m724.blog.vc.VersionControl;
import org.apache.commons.io.file.PathUtils; import org.apache.commons.io.file.PathUtils;
import org.eclipse.jgit.api.Git;
import org.eclipse.jgit.lib.RepositoryBuilder;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@ -29,18 +31,18 @@ import java.util.stream.Collectors;
/** /**
* The {@code BlogBuilder} class facilitates building a static blog by managing templates, * The {@code BlogBuilder} class facilitates building a static blog by managing templates,
* assets, articles, and rendering output files. It uses a Git repository as the * assets, articles, and rendering output files. It uses a version control (Git etc.) repository as the
* source for the blog's content and configuration. * source for the blog's content and configuration.
*/ */
public class BlogBuilder { public class BlogBuilder {
private static final Logger LOGGER = LoggerFactory.getLogger(BlogBuilder.class); private static final Logger LOGGER = LoggerFactory.getLogger(BlogBuilder.class);
private final Git git; private final Site site;
private final RenderOptions renderOptions;
private final VersionControl versionControl;
private final Path workingDirectory; private final Path workingDirectory;
private Site site;
private TemplateRenderer template; private TemplateRenderer template;
private RenderOptions renderOptions;
private Minifier minifier; private Minifier minifier;
private Path templateDirectory; private Path templateDirectory;
@ -50,12 +52,14 @@ public class BlogBuilder {
/** /**
* Constructs a {@link BlogBuilder} instance using the provided Git repository. * Constructs a {@link BlogBuilder} instance using the provided Git repository.
* *
* @param git the Git repository to be used for the blog. * @param versionControl the version control repository to be used for the blog.
*/ */
public BlogBuilder(Git git) { private BlogBuilder(Site site, RenderOptions renderOptions, Path workingDirectory, VersionControl versionControl) {
this.git = git; this.site = site;
this.renderOptions = renderOptions;
this.versionControl = versionControl;
this.workingDirectory = git.getRepository().getDirectory().toPath().getParent(); this.workingDirectory = workingDirectory;
this.templateDirectory = workingDirectory.resolve("template"); this.templateDirectory = workingDirectory.resolve("template");
this.outputDirectory = workingDirectory.resolve("generated_out"); this.outputDirectory = workingDirectory.resolve("generated_out");
} }
@ -64,19 +68,19 @@ public class BlogBuilder {
* Creates a new {@link BlogBuilder} instance for the specified working directory. * Creates a new {@link BlogBuilder} instance for the specified working directory.
* The directory is expected to be a Git repository. * The directory is expected to be a Git repository.
* *
* @param workingDirectory the root path of the blog, which must contain a Git repository. * @param directory the root path of the blog, which must be a Git repository.
* @return a {@link BlogBuilder} instance * @return a {@link BlogBuilder} instance
* @throws IOException if there is an error accessing the Git repository * @throws IOException if there is an error accessing the Git repository
*/ */
public static BlogBuilder fromPath(Path workingDirectory) throws IOException { public static BlogBuilder fromGitRepository(Path directory) throws IOException {
var repository = new RepositoryBuilder() return BlogBuilder.fromDirectory(directory, new GitVersionControl(directory));
.setGitDir(workingDirectory.resolve(".git").toFile()) }
.build();
var git = new Git(repository);
// public static BlogBuilder fromDirectory(Path directory, VersionControl versionControl) throws IOException {
var site = Site.fromConfig(directory.resolve("site.yml"));
var renderOptions = RenderOptions.fromConfig(directory.resolve("render.yml"));
return new BlogBuilder(git); return new BlogBuilder(site, renderOptions, directory, versionControl);
} }
/** /**
@ -130,14 +134,7 @@ public class BlogBuilder {
* @throws IOException if an I/O error occurs * @throws IOException if an I/O error occurs
*/ */
public void build() throws IOException { public void build() throws IOException {
LOGGER.debug("Loading site..."); if (renderOptions.minify()) {
if (site == null)
this.site = Site.fromConfig(workingDirectory.resolve("site.yml"));
if (renderOptions == null)
this.renderOptions = RenderOptions.fromConfig(workingDirectory.resolve("render.yml"));
if (this.site.minify()) {
try { try {
this.minifier = new Minifier(); this.minifier = new Minifier();
} catch (NoClassDefFoundError e) { } catch (NoClassDefFoundError e) {
@ -193,8 +190,7 @@ public class BlogBuilder {
continue; continue;
} }
path = articleDirectory.relativize(path); var article = Article.fromFile(versionControl, path);
var article = Article.fromFile(git, path);
if (article.draft() && !renderDrafts) { if (article.draft() && !renderDrafts) {
LOGGER.info("[Article {}] Draft. Ignoring, because you didn't specify to render drafts.", path.getFileName()); LOGGER.info("[Article {}] Draft. Ignoring, because you didn't specify to render drafts.", path.getFileName());
@ -202,7 +198,7 @@ public class BlogBuilder {
} }
var render = template.renderArticle(article); var render = template.renderArticle(article);
var outFile = outputDirectory.resolve("article").resolve(path); var outFile = outputDirectory.resolve("article").resolve(path.getFileName());
try { try {
Files.createDirectory(outFile.getParent()); Files.createDirectory(outFile.getParent());
@ -229,8 +225,9 @@ public class BlogBuilder {
if (renderOptions.remapAssets()) { if (renderOptions.remapAssets()) {
var assetHashes = CacheBuster.copyTree(userAssetsDir, outputUserAssetsDir); var assetHashes = CacheBuster.copyTree(userAssetsDir, outputUserAssetsDir);
// This looks weird, but it's necessary if we want to use a single map
assetHashes.forEach((k, v) -> { assetHashes.forEach((k, v) -> {
fileHashes.put("assets/" + k, v); // TODO this feels like a hack fileHashes.put("assets/" + k, v);
}); });
} else { } else {
FileUtils.copyTree(userAssetsDir, outputUserAssetsDir); FileUtils.copyTree(userAssetsDir, outputUserAssetsDir);
@ -239,8 +236,9 @@ public class BlogBuilder {
if (renderOptions.remapTemplateStatic()) { if (renderOptions.remapTemplateStatic()) {
var templateStaticHashes = CacheBuster.copyTree(templateStaticDir, outputTemplateStaticDir); var templateStaticHashes = CacheBuster.copyTree(templateStaticDir, outputTemplateStaticDir);
// This looks weird, but it's necessary if we want to use a single map
templateStaticHashes.forEach((k, v) -> { templateStaticHashes.forEach((k, v) -> {
fileHashes.put("static/" + k, v); // TODO this feels like a hack fileHashes.put("static/" + k, v);
}); });
} else { } else {
FileUtils.copyTree(templateStaticDir, outputTemplateStaticDir); FileUtils.copyTree(templateStaticDir, outputTemplateStaticDir);
@ -259,7 +257,7 @@ public class BlogBuilder {
for (var algorithm : renderOptions.compress()) { for (var algorithm : renderOptions.compress()) {
try { try {
var compressor = new FileCompressor(algorithm); var compressor = new CommonsCompressor(algorithm);
compressors.add(compressor); compressors.add(compressor);
} catch (NoSuchAlgorithmException e) { } catch (NoSuchAlgorithmException e) {
LOGGER.error("No such compression algorithm, ignoring: {}", e.getAlgorithm()); LOGGER.error("No such compression algorithm, ignoring: {}", e.getAlgorithm());
@ -276,8 +274,8 @@ public class BlogBuilder {
for (var path : tree) { for (var path : tree) {
try { try {
compressor.compress(path); compressor.compress(path);
} catch (CompressorException e) { } catch (CompressException e) {
LOGGER.error("Error compressing \"{}\" to \"{}\": {}", path, compressor.getAlgorithm(), e.getMessage()); LOGGER.error("Error compressing \"{}\" to \"{}\": {}", path, compressor.getAlgorithmName(), e.getMessage());
} }
} }
} }

View file

@ -38,7 +38,10 @@ public class Main {
var start = System.nanoTime(); var start = System.nanoTime();
var builder = BlogBuilder.fromPath(workingDirectory)
LOGGER.debug("Loading site...");
var builder = BlogBuilder.fromGitRepository(workingDirectory)
.templateDirectory(templateDirectory) .templateDirectory(templateDirectory)
.outputDirectory(outputDirectory) .outputDirectory(outputDirectory)
.renderDrafts(renderDrafts); .renderDrafts(renderDrafts);

View file

@ -0,0 +1,25 @@
/*
* Copyright (c) 2025 blog-software-java developers
* blog-software-java is licensed under the GNU General Public License. See the LICENSE.md file
* in the project root for the full license text.
*/
package eu.m724.blog;
import org.snakeyaml.engine.v2.api.Load;
import org.snakeyaml.engine.v2.api.LoadSettings;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
public class YamlLoader {
private static final Load LOAD = new Load(LoadSettings.builder().build());
public static Map<String, Object> loadMap(Path file) throws IOException {
try (var inputStream = Files.newInputStream(file)) {
return (Map<String, Object>) LOAD.loadFromInputStream(inputStream);
}
}
}

View file

@ -0,0 +1,58 @@
/*
* Copyright (c) 2025 blog-software-java developers
* blog-software-java is licensed under the GNU General Public License. See the LICENSE.md file
* in the project root for the full license text.
*/
package eu.m724.blog.compress;
import org.apache.commons.compress.compressors.CompressorException;
import org.apache.commons.compress.compressors.CompressorStreamFactory;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.Files;
import java.nio.file.Path;
public class CommonsCompressor extends FileCompressor {
/**
* Constructs a {@link FileCompressor} instance using the provided algorithm.
*
* @param algorithm The algorithm, for valid names see {@link CompressorStreamFactory}
* @throws NoSuchAlgorithmException If that algorithm is unavailable or the name is invalid.
*/
public CommonsCompressor(String algorithm) throws NoSuchAlgorithmException {
super(algorithm);
try {
var os = new CompressorStreamFactory().createCompressorOutputStream(algorithm, OutputStream.nullOutputStream());
os.close();
} catch (NoClassDefFoundError | CompressorException e) {
throw new NoSuchAlgorithmException(algorithm, e);
} catch (IOException e) {
throw new RuntimeException("Unexpected IOException closing test output stream", e);
}
}
@Override
public void compress(Path file) throws IOException, CompressException {
var destination = file.resolveSibling(file.getFileName() + "." + getAlgorithmName());
compress(file, destination);
}
@Override
public void compress(Path source, Path destination) throws IOException, CompressException {
if (Files.exists(destination))
throw new FileAlreadyExistsException(destination.toString());
try (
var outputStream = new CompressorStreamFactory()
.createCompressorOutputStream(getAlgorithmName(), Files.newOutputStream(destination))
) {
Files.copy(source, outputStream);
} catch (CompressorException e) {
throw new CompressException(e);
}
}
}

View file

@ -0,0 +1,14 @@
/*
* Copyright (c) 2025 blog-software-java developers
* blog-software-java is licensed under the GNU General Public License. See the LICENSE.md file
* in the project root for the full license text.
*/
package eu.m724.blog.compress;
// TODO really runtime exception?
public class CompressException extends RuntimeException {
public CompressException(Throwable cause) {
super(cause);
}
}

View file

@ -6,53 +6,20 @@
package eu.m724.blog.compress; package eu.m724.blog.compress;
import org.apache.commons.compress.compressors.CompressorException;
import org.apache.commons.compress.compressors.CompressorStreamFactory;
import java.io.IOException; import java.io.IOException;
import java.io.OutputStream;
import java.nio.file.*; import java.nio.file.*;
public class FileCompressor { public abstract class FileCompressor {
private final String algorithm; private final String algorithmName;
/** protected FileCompressor(String algorithmName) {
* Constructs a {@link FileCompressor} instance using the provided algorithm. this.algorithmName = algorithmName;
*
* @param algorithm The algorithm, for valid names see {@link CompressorStreamFactory}
* @throws NoSuchAlgorithmException If that algorithm is unavailable or the name is invalid.
*/
public FileCompressor(String algorithm) throws NoSuchAlgorithmException {
this.algorithm = algorithm;
try {
var os = new CompressorStreamFactory().createCompressorOutputStream(algorithm, OutputStream.nullOutputStream());
os.close();
} catch (NoClassDefFoundError | CompressorException e) {
throw new NoSuchAlgorithmException(algorithm, e);
} catch (IOException e) {
throw new RuntimeException("Unexpected IOException closing test output stream", e);
}
} }
public void compress(Path source) throws IOException, CompressorException { public String getAlgorithmName() {
var destination = source.resolveSibling(source.getFileName() + "." + algorithm); return algorithmName;
compress(source, destination);
} }
public void compress(Path source, Path destination) throws IOException, CompressorException { abstract public void compress(Path file) throws IOException, CompressException;
if (Files.exists(destination)) abstract public void compress(Path source, Path destination) throws IOException, CompressException;
throw new FileAlreadyExistsException(destination.toString());
try (
var outputStream = new CompressorStreamFactory()
.createCompressorOutputStream(algorithm, Files.newOutputStream(destination))
) {
Files.copy(source, outputStream);
}
}
public String getAlgorithm() {
return algorithm;
}
} }

View file

@ -6,8 +6,8 @@
package eu.m724.blog.object; package eu.m724.blog.object;
import org.eclipse.jgit.api.Git; import eu.m724.blog.vc.VersionControl;
import org.eclipse.jgit.api.errors.GitAPIException; import eu.m724.blog.vc.VersionControlException;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@ -56,17 +56,16 @@ public record Article(
* The method extracts metadata properties, content, and versioning information * The method extracts metadata properties, content, and versioning information
* based on the Git history of the file. * based on the Git history of the file.
* *
* @param git the Git repository used to retrieve versioning and commit information * @param versionControl the version control repository used to retrieve versioning and commit information
* @param path the relative path to the file within the "articles" directory * @param path the relative path to the file within the "articles" directory
* @return a {@link Article} object populated with data extracted from the specified file * @return a {@link Article} object populated with data extracted from the specified file
* @throws IOException if an error occurs during file reading * @throws IOException if an error occurs during file reading
*/ */
public static Article fromFile(Git git, Path path) throws IOException { public static Article fromFile(VersionControl versionControl, Path path) throws IOException {
/* read properties before filtering */ /* read properties before filtering */
var slug = path.getFileName().toString().split("\\.")[0]; var slug = path.getFileName().toString().split("\\.")[0];
path = Path.of("articles").resolve(path); var lines = Files.readAllLines(path);
var lines = Files.readAllLines(git.getRepository().getDirectory().toPath().getParent().resolve(path));
var properties = new HashMap<String, String>(); var properties = new HashMap<String, String>();
@ -121,18 +120,18 @@ public record Article(
ZonedDateTime modifiedAt = Instant.ofEpochMilli(0).atZone(ZoneOffset.UTC); ZonedDateTime modifiedAt = Instant.ofEpochMilli(0).atZone(ZoneOffset.UTC);
try { try {
for (var commit : git.log().addPath(path.toString()).call()) { for (var change : versionControl.getChanges(path)) {
createdBy = commit.getAuthorIdent().getName(); createdBy = change.author();
createdAt = Instant.ofEpochSecond(commit.getCommitTime()).atZone(ZoneOffset.UTC); createdAt = change.time();
if (revisions++ == 0) { if (revisions++ == 0) {
modifiedBy = createdBy; modifiedBy = createdBy;
modifiedAt = createdAt; modifiedAt = createdAt;
} }
} }
} catch (GitAPIException e) { } catch (VersionControlException e) {
draft = true; draft = true;
LOGGER.warn("[Article {}] Draft because of a Git exception: {}", slug, e.getMessage()); LOGGER.warn("[Article {}] Draft because of a VC exception: {}", slug, e.getMessage());
} }
return new Article(slug, title, summary, draft, revisions, createdBy, createdAt, modifiedBy, modifiedAt, custom, content); return new Article(slug, title, summary, draft, revisions, createdBy, createdAt, modifiedBy, modifiedAt, custom, content);

View file

@ -6,41 +6,61 @@
package eu.m724.blog.object; package eu.m724.blog.object;
import org.snakeyaml.engine.v2.api.Load; import eu.m724.blog.YamlLoader;
import org.snakeyaml.engine.v2.api.LoadSettings;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map;
/**
* Options that are relevant to rendering, not about the {@link Site}.
*
* @param compress list of compression algorithms to compress output
* @param remapAssets whether to remap user assets, add a unique hash to the file name to bypass cache
* @param remapTemplateStatic whether to remap template static assets, add a unique hash to the file name to bypass cache
* @param minify whether to minify output
*/
public record RenderOptions( public record RenderOptions(
List<String> compress, // TODO rename? List<String> compress, // TODO rename?
boolean remapAssets,
boolean remapTemplateStatic, boolean remapTemplateStatic,
boolean remapAssets
boolean minify
) { ) {
private static final RenderOptions DEFAULT = new RenderOptions(
List.of("gz", "zstd"),
false,
true,
true
);
/** /**
* Creates a {@link Site} object by reading and parsing the configuration file at the specified path.<br> * Creates a {@link Site} object by reading and parsing the configuration file at the specified file.<br>
* The configuration file must be a JSON file. * The configuration file must be a JSON file.
* *
* @param path the path to the configuration file * @param file the file to the configuration file
* @return a {@link Site} object initialized with the data from the configuration file * @return a {@link Site} object initialized with the data from the configuration file
* @throws IOException if an error occurs during file reading * @throws IOException if an error occurs during file reading
*/ */
public static RenderOptions fromConfig(Path path) throws IOException { public static RenderOptions fromConfig(Path file) throws IOException {
var load = new Load(LoadSettings.builder().build()); var yaml = YamlLoader.loadMap(file);
var yaml = (Map<String, Object>) load.loadFromInputStream(Files.newInputStream(path));
/* ---- */
List<String> compress = (List<String>) yaml.getOrDefault("compress", DEFAULT.compress());
List<String> compress = (List<String>) yaml.getOrDefault("compress", new ArrayList<>());
boolean remapTemplateStatic = (boolean) yaml.getOrDefault("remapTemplateStatic", true);
// assets are not remapped by default, because they might be hotlinked // assets are not remapped by default, because they might be hotlinked
boolean remapAssets = (boolean) yaml.getOrDefault("remapAssets", false); boolean remapAssets = (boolean) yaml.getOrDefault("remapAssets", DEFAULT.remapAssets());
boolean remapTemplateStatic = (boolean) yaml.getOrDefault("remapTemplateStatic", DEFAULT.remapTemplateStatic());
var minify = (boolean) yaml.getOrDefault("minify", DEFAULT.minify());
/* ---- */
return new RenderOptions( return new RenderOptions(
compress, remapTemplateStatic, remapAssets compress, remapTemplateStatic, remapAssets, minify
); );
} }
} }

View file

@ -6,11 +6,9 @@
package eu.m724.blog.object; package eu.m724.blog.object;
import org.snakeyaml.engine.v2.api.Load; import eu.m724.blog.YamlLoader;
import org.snakeyaml.engine.v2.api.LoadSettings;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.Map; import java.util.Map;
@ -21,7 +19,6 @@ import java.util.Map;
* @param baseUrl the base URL of the site * @param baseUrl the base URL of the site
* @param directory The directory that the site is installed into. Like "https://example.com/blog" turns to "/blog" * @param directory The directory that the site is installed into. Like "https://example.com/blog" turns to "/blog"
* @param templateArticles whether to parse articles with Pebble templating * @param templateArticles whether to parse articles with Pebble templating
* @param minify whether to minify HTML, CSS, JS, etc.
* @param custom a map of additional custom properties * @param custom a map of additional custom properties
*/ */
public record Site( public record Site(
@ -31,28 +28,33 @@ public record Site(
String directory, String directory,
boolean templateArticles, boolean templateArticles,
boolean minify,
Map<String, Object> custom Map<String, Object> custom
) { ) {
private static final Site DEFAULT = new Site(
"Misconfigured blog",
"/",
"/",
false,
Map.of()
);
/** /**
* Creates a {@link Site} object by reading and parsing the configuration file at the specified path.<br> * Creates a {@link Site} object by reading and parsing the configuration file at the specified path.<br>
* The configuration file must be a JSON file. * The configuration file must be a JSON file.
* *
* @param path the path to the configuration file * @param file the path to the configuration file
* @return a {@link Site} object initialized with the data from the configuration file * @return a {@link Site} object initialized with the data from the configuration file
* @throws IOException if an error occurs during file reading * @throws IOException if an error occurs during file reading
*/ */
public static Site fromConfig(Path path) throws IOException { public static Site fromConfig(Path file) throws IOException {
var load = new Load(LoadSettings.builder().build()); var yaml = YamlLoader.loadMap(file);
var yaml = (Map<String, Object>) load.loadFromInputStream(Files.newInputStream(path));
String name = (String) yaml.get("name"); String name = (String) yaml.getOrDefault("name", DEFAULT.name());
String baseUrl = (String) yaml.getOrDefault("baseUrl", "/"); String baseUrl = (String) yaml.getOrDefault("baseUrl", DEFAULT.baseUrl());
var templateArticles = (boolean) yaml.getOrDefault("templateArticles", false); var templateArticles = (boolean) yaml.getOrDefault("templateArticles", DEFAULT.templateArticles());
var minify = (boolean) yaml.getOrDefault("minify", true);
String directory = "/"; String directory = DEFAULT.directory();
if (baseUrl != null) { if (baseUrl != null) {
var temp = baseUrl.substring(baseUrl.indexOf(':') + 3); var temp = baseUrl.substring(baseUrl.indexOf(':') + 3);
var slashIndex = temp.indexOf('/'); var slashIndex = temp.indexOf('/');
@ -62,7 +64,7 @@ public record Site(
} }
return new Site( return new Site(
name, baseUrl, directory, templateArticles, minify, yaml name, baseUrl, directory, templateArticles, yaml
); );
} }
} }

View file

@ -4,7 +4,7 @@
* in the project root for the full license text. * in the project root for the full license text.
*/ */
package eu.m724.blog; package eu.m724.blog.server;
import com.sun.net.httpserver.HttpServer; import com.sun.net.httpserver.HttpServer;
import com.sun.net.httpserver.SimpleFileServer; import com.sun.net.httpserver.SimpleFileServer;

View file

@ -0,0 +1,14 @@
/*
* Copyright (c) 2025 blog-software-java developers
* blog-software-java is licensed under the GNU General Public License. See the LICENSE.md file
* in the project root for the full license text.
*/
package eu.m724.blog.vc;
import java.time.ZonedDateTime;
public record Change(
String author,
ZonedDateTime time
) { }

View file

@ -0,0 +1,56 @@
/*
* Copyright (c) 2025 blog-software-java developers
* blog-software-java is licensed under the GNU General Public License. See the LICENSE.md file
* in the project root for the full license text.
*/
package eu.m724.blog.vc;
import org.eclipse.jgit.api.Git;
import org.eclipse.jgit.api.errors.GitAPIException;
import org.eclipse.jgit.lib.RepositoryBuilder;
import java.io.IOException;
import java.nio.file.Path;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.List;
public class GitVersionControl implements VersionControl {
private final Git git;
private final Path directory;
public GitVersionControl(Path directory) throws IOException {
this.directory = directory;
var repository = new RepositoryBuilder()
.setGitDir(directory.resolve(".git").toFile())
.build();
this.git = new Git(repository);
}
@Override
public List<Change> getChanges(Path path) throws VersionControlException {
path = path.normalize();
if (path.startsWith(directory)) {
path = directory.relativize(path);
}
var changes = new ArrayList<Change>();
try {
for (var commit : git.log().addPath(path.toString()).call()) {
var author = commit.getAuthorIdent().getName();
var time = Instant.ofEpochSecond(commit.getCommitTime()).atZone(ZoneOffset.UTC);
changes.add(new Change(author, time));
}
} catch (GitAPIException e) {
throw new VersionControlException(e);
}
return changes;
}
}

View file

@ -0,0 +1,15 @@
/*
* Copyright (c) 2025 blog-software-java developers
* blog-software-java is licensed under the GNU General Public License. See the LICENSE.md file
* in the project root for the full license text.
*/
package eu.m724.blog.vc;
import java.nio.file.Path;
import java.util.List;
public interface VersionControl {
List<Change> getChanges(Path path) throws VersionControlException;
}

View file

@ -0,0 +1,15 @@
/*
* Copyright (c) 2025 blog-software-java developers
* blog-software-java is licensed under the GNU General Public License. See the LICENSE.md file
* in the project root for the full license text.
*/
package eu.m724.blog.vc;
// TODO runtime exception really?
public class VersionControlException extends RuntimeException {
public VersionControlException(Throwable cause) {
// TODO maybe I need to raise message here too
super(cause);
}
}