Skip to content

Commit 5ac05f3

Browse files
committed
🐛 Better impl and tests for URIs; Resolves #421
1 parent 5f31ad6 commit 5ac05f3

File tree

8 files changed

+158
-112
lines changed

8 files changed

+158
-112
lines changed

src/main/java/dev/ebullient/convert/config/CompendiumConfig.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -433,6 +433,7 @@ private enum ConfigKeys {
433433
useDiceRoller,
434434
exclude,
435435
excludePattern,
436+
fallbackPaths(List.of("fallback-paths")),
436437
from,
437438
fullSource(List.of("convert", "full-source")),
438439
images,
@@ -502,6 +503,7 @@ static class ImageOptions {
502503
String internalRoot;
503504
Boolean copyInternal;
504505
Boolean copyExternal;
506+
final Map<String, String> fallbackPaths = new HashMap<>();
505507

506508
public ImageOptions() {
507509
}
@@ -511,6 +513,7 @@ public ImageOptions(ImageOptions images, ImageOptions images2) {
511513
copyExternal = images.copyExternal;
512514
copyInternal = images.copyInternal;
513515
internalRoot = images.internalRoot;
516+
fallbackPaths.putAll(images.fallbackPaths);
514517
}
515518
if (images2 != null) {
516519
copyExternal = images2.copyExternal == null
@@ -522,6 +525,7 @@ public ImageOptions(ImageOptions images, ImageOptions images2) {
522525
internalRoot = images2.internalRoot == null
523526
? internalRoot
524527
: images2.internalRoot;
528+
fallbackPaths.putAll(images2.fallbackPaths);
525529
}
526530
}
527531

@@ -532,6 +536,10 @@ public boolean copyExternal() {
532536
public boolean copyInternal() {
533537
return copyInternal != null && copyInternal;
534538
}
539+
540+
public Map<String, String> fallbackPaths() {
541+
return Collections.unmodifiableMap(fallbackPaths);
542+
}
535543
}
536544

537545
@RegisterForReflection

src/main/java/dev/ebullient/convert/config/TtrpgConfig.java

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -95,9 +95,11 @@ public static class ImageRoot {
9595
final String internalImageRoot;
9696
final boolean copyInternal;
9797
final boolean copyExternal;
98+
final Map<String, String> fallbackPaths;
9899

99100
private ImageRoot(String cfgRoot, ImageOptions options) {
100101
this.copyExternal = options.copyExternal();
102+
this.fallbackPaths = options.fallbackPaths();
101103

102104
if (cfgRoot == null) {
103105
this.internalImageRoot = "";
@@ -139,6 +141,10 @@ private String endWithSlash(String path) {
139141
}
140142
return path.endsWith("/") ? path : path + "/";
141143
}
144+
145+
public String getFallbackPath(String key) {
146+
return fallbackPaths.getOrDefault(key, key);
147+
}
142148
}
143149

144150
public static ImageRoot internalImageRoot() {
@@ -154,10 +160,6 @@ public static ImageRoot internalImageRoot() {
154160
return root;
155161
}
156162

157-
public static Map<String, String> imageFallbackPaths() {
158-
return activeDSConfig().fallbackImagePaths;
159-
}
160-
161163
public static JsonNode readIndex(String key) {
162164
String file = activeDSConfig().indexes.get(key);
163165
Optional<Path> root = file == null ? Optional.empty() : tui.resolvePath(Path.of(file));

src/main/java/dev/ebullient/convert/io/Tui.java

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
import java.io.IOException;
77
import java.io.InputStream;
88
import java.io.PrintWriter;
9+
import java.io.UnsupportedEncodingException;
10+
import java.net.MalformedURLException;
911
import java.net.URI;
1012
import java.net.URL;
1113
import java.nio.channels.Channels;
@@ -213,7 +215,7 @@ public void init(CommandSpec spec, boolean debug, boolean verbose, boolean log)
213215
this.debug = debug || log;
214216
this.verbose = verbose;
215217
if (log) {
216-
Path p = Path.of("ttrpg-convert.out");
218+
Path p = Path.of("ttrpg-convert.out.txt");
217219
try {
218220
this.log = new PrintWriter(Files.newOutputStream(p));
219221
VersionProvider vp = new VersionProvider();
@@ -436,6 +438,28 @@ private void copyImageResource(ImageRef image, Path targetPath) {
436438
}
437439
}
438440

441+
private final static String allowedCharacters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~/";
442+
443+
String escapeUrlImagePath(String url) throws MalformedURLException, UnsupportedEncodingException {
444+
URL urlObject = new URL(url);
445+
String path = urlObject.getPath();
446+
447+
StringBuilder encodedPath = new StringBuilder();
448+
for (char ch : path.toCharArray()) {
449+
if (allowedCharacters.indexOf(ch) == -1) {
450+
byte[] bytes = String.valueOf(ch).getBytes("UTF-8");
451+
for (byte b : bytes) {
452+
encodedPath.append(String.format("%%%02X", b));
453+
}
454+
} else {
455+
encodedPath.append(ch);
456+
}
457+
}
458+
459+
return url.replace(path, encodedPath.toString())
460+
.replace("/imgur.com", "/i.imgur.com");
461+
}
462+
439463
private void copyRemoteImage(ImageRef image, Path targetPath) {
440464
targetPath.getParent().toFile().mkdirs();
441465

@@ -445,12 +469,14 @@ private void copyRemoteImage(ImageRef image, Path targetPath) {
445469
return;
446470
}
447471
if (!url.startsWith("http") && !url.startsWith("file")) {
448-
errorf("ImageRef %s has invalid URL %s", image.targetFilePath(), url);
472+
errorf("Remote ImageRef %s has invalid URL %s", image.targetFilePath(), url);
449473
return;
450474
}
451475

452-
Tui.instance().debugf("copy image %s %n to %s", url, targetPath);
453476
try {
477+
url = escapeUrlImagePath(url);
478+
Tui.instance().debugf("copy image %s", url);
479+
454480
ReadableByteChannel readableByteChannel = Channels.newChannel(new URL(url).openStream());
455481
try (FileOutputStream fileOutputStream = new FileOutputStream(targetPath.toFile())) {
456482
fileOutputStream.getChannel().transferFrom(readableByteChannel, 0, Long.MAX_VALUE);

src/main/java/dev/ebullient/convert/qute/ImageRef.java

Lines changed: 37 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package dev.ebullient.convert.qute;
22

3+
import java.io.UnsupportedEncodingException;
4+
import java.nio.charset.StandardCharsets;
35
import java.nio.file.Path;
46

57
import dev.ebullient.convert.config.TtrpgConfig;
@@ -43,7 +45,7 @@ public class ImageRef {
4345
final String titleAttr;
4446

4547
private ImageRef(String url, Path sourcePath, Path targetFilePath, String title, String vaultPath, Integer width) {
46-
this.url = url == null ? null : url.replace(" ", "%20"); // catch any remaining spaces
48+
this.url = url;
4749
this.sourcePath = sourcePath;
4850
this.targetFilePath = targetFilePath;
4951
title = title == null
@@ -59,10 +61,6 @@ private ImageRef(String url, Path sourcePath, Path targetFilePath, String title,
5961
}
6062
this.vaultPath = vaultPath;
6163
this.width = width;
62-
63-
if (url == null && vaultPath == null) {
64-
Tui.instance().errorf("ImageRef (target=%s) has no url or vaultPath", targetFilePath);
65-
}
6664
}
6765

6866
String escape(String s) {
@@ -208,61 +206,52 @@ public Builder setUrl(String url) {
208206
}
209207

210208
public ImageRef build() {
211-
final ImageRoot imageRoot = TtrpgConfig.internalImageRoot();
212-
213-
if (url != null && !imageRoot.copyExternalToVault()) {
214-
// leave external images alone (referenced as url)
215-
return new ImageRef(url, null, null, title, null, width);
216-
}
217-
218209
if (url == null && sourcePath == null) {
219210
Tui.instance().errorf("ImageRef build for internal image called without url or sourcePath set");
220211
return null;
221212
}
222-
if (relativeTarget == null || vaultRoot == null || rootFilePath == null) {
223-
Tui.instance().errorf("ImageRef build called without target paths set");
224-
return null;
213+
214+
final ImageRoot imageRoot = TtrpgConfig.internalImageRoot();
215+
String sourceUrl = url == null ? sourcePath.toString() : url;
216+
217+
// Check for any URL replacements (to replace a not-found-image with a local one, e.g.)
218+
// replace backslashes with forward slashes
219+
sourceUrl = imageRoot.getFallbackPath(sourceUrl)
220+
.replace('\\', '/');
221+
222+
try {
223+
// Remove escaped characters here (local file paths won't want it)
224+
sourceUrl = java.net.URLDecoder.decode(sourceUrl, StandardCharsets.UTF_8.name());
225+
} catch (UnsupportedEncodingException e) {
226+
Tui.instance().errorf("Error decoding image URL: %s", e.getMessage());
225227
}
226228

227-
Path targetFilePath = rootFilePath.resolve(relativeTarget);
228-
String vaultPath = String.format("%s%s", vaultRoot,
229-
relativeTarget.toString().replace('\\', '/'));
230-
231-
// Escaping spaces is a mess. Remove here (local file paths won't want it)
232-
// It is changed back (from space to %20) if in URL form (file or http)
233-
String remoteUrl = url == null
234-
? sourcePath.toString().replace("%20", " ")
235-
: url;
236-
if (remoteUrl.startsWith("http")) {
237-
remoteUrl = remoteUrl
238-
.replaceAll("^(https?):/+", "$1://")
239-
.replace("/imgur.com", "/i.imgur.com");
240-
} else if (!remoteUrl.startsWith("file:/")) {
241-
remoteUrl = imageRoot.getRootPath() + remoteUrl;
229+
boolean copyToVault = false;
230+
231+
if (sourceUrl.startsWith("http") || sourceUrl.startsWith("file")) {
232+
sourceUrl = sourceUrl.replaceAll("^(https?):/+", "$1://");
233+
copyToVault = imageRoot.copyExternalToVault();
234+
} else if (!sourceUrl.startsWith("file:/")) {
235+
sourceUrl = imageRoot.getRootPath() + sourceUrl;
236+
copyToVault = imageRoot.copyInternalToVault();
242237
}
243238

244-
if (imageRoot.copyInternalToVault() || imageRoot.copyExternalToVault()) {
239+
boolean localTargetSet = relativeTarget != null && vaultRoot != null && rootFilePath != null;
240+
if (localTargetSet && copyToVault) {
241+
Path targetFilePath = rootFilePath.resolve(relativeTarget);
242+
String vaultPath = String.format("%s%s", vaultRoot,
243+
relativeTarget.toString().replace('\\', '/'));
244+
245245
// remote images to be copied into the vault
246-
if (remoteUrl.startsWith("http") || remoteUrl.startsWith("file")) {
247-
String filename = remoteUrl.substring(remoteUrl.lastIndexOf('/') + 1);
248-
if (!filename.contains("%")) {
249-
try {
250-
String encoded = java.net.URLEncoder.encode(filename, "UTF-8")
251-
.replace("+", "%20");
252-
remoteUrl = remoteUrl.replace(filename, encoded);
253-
} catch (java.io.UnsupportedEncodingException e) {
254-
Tui.instance().errorf("Failed to encode filename: %s", filename);
255-
}
256-
}
257-
// also replace any remaining spaces in the path
258-
return new ImageRef(remoteUrl,
259-
null, targetFilePath, title, vaultPath, width);
246+
if (sourceUrl.startsWith("http") || sourceUrl.startsWith("file")) {
247+
return new ImageRef(sourceUrl, null, targetFilePath, title, vaultPath, width);
260248
}
261-
return new ImageRef(null, Path.of(remoteUrl), targetFilePath, title, vaultPath, width);
249+
// local image to be copied into the vault
250+
return new ImageRef(null, Path.of(sourceUrl), targetFilePath, title, vaultPath, width);
262251
}
263252

264-
// remote images are not copied to the vault --> url image ref
265-
return new ImageRef(remoteUrl,
253+
// remote images that are not copied to the vault --> url image ref, no target
254+
return new ImageRef(sourceUrl,
266255
null, null, title, null, width);
267256
}
268257

src/main/resources/convertData.json

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -332,14 +332,6 @@
332332
"constants": {
333333
"internalImageRoot": "https://raw.githubusercontent.com/5etools-mirror-2/5etools-img/main/"
334334
},
335-
"fallbackImage": {
336-
"img/PSZ/Archon of Redemption.png": "img/PSZ/Archon Of Redemption.png",
337-
"img/bestiary/ERLW/Inspired.png": "img/bestiary/ERLW/Inspired.webp",
338-
"img/bestiary/MTF/Merrenoloth.jpg": "img/bestiary/MTF/Merrenoloth.webp",
339-
"img/bestiary/SDW/Lhammaruntosz.jpg": "img/SDW/Lhammaruntosz.png",
340-
"img/bestiary/VGM/Deep Scion.jpg": "img/bestiary/VGM/Deep Scion.webp",
341-
"img/items/CRCotN/Medal of the Maze.jpg": "img/items/CRCotN/Medal of the Maze.webp"
342-
},
343335
"markerFiles": [
344336
"bestiary/bestiary-mm.json",
345337
"cultsboons.json",
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package dev.ebullient.convert.io;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
5+
import java.io.UnsupportedEncodingException;
6+
import java.net.MalformedURLException;
7+
import java.nio.file.Path;
8+
9+
import org.junit.jupiter.api.BeforeAll;
10+
import org.junit.jupiter.api.Test;
11+
12+
import dev.ebullient.convert.config.TtrpgConfig;
13+
import dev.ebullient.convert.qute.ImageRef;
14+
import dev.ebullient.convert.tools.dnd5e.Tools5eIndex;
15+
import dev.ebullient.convert.tools.dnd5e.Tools5eSources;
16+
import io.quarkus.arc.Arc;
17+
import io.quarkus.test.junit.QuarkusTest;
18+
19+
@QuarkusTest
20+
public class ImgurUrlTest {
21+
protected static Tui tui;
22+
protected static Tools5eIndex index;
23+
24+
@BeforeAll
25+
public static void prepare() {
26+
tui = Arc.container().instance(Tui.class).get();
27+
tui.init(null, true, false);
28+
index = new Tools5eIndex(TtrpgConfig.getConfig());
29+
}
30+
31+
@Test
32+
public void testImgurUrl() throws MalformedURLException, UnsupportedEncodingException {
33+
String input = "https://imgur.com/lQfZ1dF.png";
34+
35+
assertThat(tui.escapeUrlImagePath(input))
36+
.isEqualTo("https://i.imgur.com/lQfZ1dF.png");
37+
}
38+
39+
@Test
40+
public void testAccentedCharacters() throws MalformedURLException, UnsupportedEncodingException {
41+
String input = "https://whatever.com/áé.png?raw=true";
42+
43+
assertThat(tui.escapeUrlImagePath(input))
44+
.isEqualTo("https://whatever.com/%C3%A1%C3%A9.png?raw=true");
45+
46+
Tools5eSources sources = Tools5eSources.findOrTemporary(
47+
Tui.MAPPER.createObjectNode()
48+
.put("name", "Critter")
49+
.put("source", "DMG"));
50+
51+
ImageRef ref = sources.buildTokenImageRef(index,
52+
"https://raw.githubusercontent.com/TheGiddyLimit/homebrew/master/_img/MonsterManualExpanded3/creature/Hill%20Giant%20Warlock%20Of%20Ogrémoch.jpg",
53+
Path.of("something.png"),
54+
false);
55+
56+
assertThat(tui.escapeUrlImagePath(ref.url()))
57+
.isEqualTo(
58+
"https://raw.githubusercontent.com/TheGiddyLimit/homebrew/master/_img/MonsterManualExpanded3/creature/Hill%20Giant%20Warlock%20Of%20Ogr%C3%A9moch.jpg");
59+
60+
ref = sources.buildTokenImageRef(index,
61+
"https://raw.githubusercontent.com/TheGiddyLimit/homebrew/master/_img/MonsterManualExpanded3/creature/token/Stone%20Giant%20Warlock%20Of%20Ogrémoch%20%28Token%29.png",
62+
Path.of("something.png"),
63+
false);
64+
65+
assertThat(tui.escapeUrlImagePath(ref.url()))
66+
.isEqualTo(
67+
"https://raw.githubusercontent.com/TheGiddyLimit/homebrew/master/_img/MonsterManualExpanded3/creature/token/Stone%20Giant%20Warlock%20Of%20Ogr%C3%A9moch%20%28Token%29.png");
68+
}
69+
70+
}

src/test/java/dev/ebullient/convert/tools/ImgurUrlTest.java

Lines changed: 0 additions & 48 deletions
This file was deleted.

0 commit comments

Comments
 (0)