Skip to content
Merged
10 changes: 9 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,16 @@ subprojects {
reportLevel = 'medium'
excludeFilter = rootProject.file('config/spotbugs-exclude.xml')
}
tasks.withType(com.github.spotbugs.snom.SpotBugsTask).configureEach {
tasks.withType(com.github.spotbugs.snom.SpotBugsTask).configureEach { spotbugsTask ->
notCompatibleWithConfigurationCache("SpotBugs does not support configuration cache yet")
String suffix = spotbugsTask.name.startsWith('spotbugs') ?
spotbugsTask.name.substring('spotbugs'.length()) : ''
String sourceSetName = suffix.isEmpty() ? 'main' :
suffix.substring(0, 1).toLowerCase(Locale.ROOT) + suffix.substring(1)
def sourceSet = project.sourceSets.findByName(sourceSetName)
if (sourceSet != null) {
auxClassPaths.from(sourceSet.compileClasspath, sourceSet.runtimeClasspath)
}
reports {
html.required = true
xml.required = false
Expand Down
4 changes: 3 additions & 1 deletion check.sh
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
#!/usr/bin/env bash
gradlew spotlessApply check --no-configuration-cache --console=plain
set -euo pipefail

./gradlew spotlessApply check --no-configuration-cache --console=plain
14 changes: 11 additions & 3 deletions docs/API-Guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,8 @@ io.view([
// Display an image
io.display("/path/to/chart.png", "Chart")

// Display a file (opens with system default for unknown types)
// gi-console opens unknown files with the system default application;
// gi-swing and gi-fx expect a displayable image resource instead.
io.display(new File("document.pdf"))
```

Expand Down Expand Up @@ -239,6 +240,8 @@ if (io.urlExists("https://example.com/api/health", 5000)) {
}
```

`urlExists` accepts HTTP and HTTPS URLs, follows redirects within the supplied overall timeout budget, and returns `false` unless the final response is 2xx. A negative timeout throws `IllegalArgumentException`; `0` disables the timeout entirely, so the call may block indefinitely.

### Content Type Detection

```groovy
Expand All @@ -257,8 +260,13 @@ if (url != null) {
def content = url.text
}

// Missing paths return null; use File directly for a file that will be created later.
def output = new File("build/report.html")

// Extract filename from path
def filename = FileUtils.baseName("/path/to/data.csv") // "data.csv"
// A # in a local filename is preserved; URL fragments are stripped.
assert FileUtils.baseName("/tmp/report#2.pdf") == "report#2.pdf"
```

## Gade Compatibility
Expand Down Expand Up @@ -299,7 +307,7 @@ if (file == null) {
### gi-fx (JavaFX)

- Requires a JVM with JavaFX support
- Full SVG rendering support via WebView
- SVG rendering via matrix-charts JavaFX integration (a lighter subset than a browser renderer)
- Rich date pickers with calendar UI

### gi-swing
Expand All @@ -312,5 +320,5 @@ if (file == null) {

- Best for headless/CI environments
- `display()` and `display(Chart)` print messages instead of showing UI
- Password input requires `System.console()` (returns null in IDEs)
- Password input is masked when `System.console()` is available; otherwise stdin input is visible and a warning is logged
- Tables displayed as text using Matrix.content()
113 changes: 96 additions & 17 deletions gi-common/src/main/groovy/se/alipsa/gi/AbstractInOut.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,13 @@ import java.awt.datatransfer.StringSelection
import java.awt.datatransfer.Transferable
import java.nio.file.Paths
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit

@CompileStatic
abstract class AbstractInOut implements GuiInteraction {

private static final int MAX_REDIRECTS = 5

private Parser markdownParser
private HtmlRenderer htmlRenderer
protected def clipboard
Expand All @@ -30,24 +33,92 @@ abstract class AbstractInOut implements GuiInteraction {

@Override
boolean urlExists(String urlString, int timeout) {
HttpURLConnection con = null
if (timeout < 0) {
throw new IllegalArgumentException("timeout cannot be negative")
}
try {
URL url = new URL(urlString)
con = (HttpURLConnection) url.openConnection()
con.setInstanceFollowRedirects(false)
con.setRequestMethod("HEAD")
con.setConnectTimeout(timeout)
con.setReadTimeout(timeout)
int responseCode = con.getResponseCode()
// Accept 2xx (success) and 3xx (redirect) status codes
return responseCode >= 200 && responseCode < 400
long deadline = timeout > 0 ?
System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeout) : Long.MAX_VALUE
for (int redirect = 0; redirect <= MAX_REDIRECTS; redirect++) {
if (!isHttpUrl(url) || !hasTimeRemaining(timeout, deadline)) {
return false
}
HttpURLConnection con = null
try {
con = open(url, "HEAD", remainingTimeout(timeout, deadline), false)
int responseCode = con.getResponseCode()
if (shouldFallbackToGet(responseCode)) {
if (!hasTimeRemaining(timeout, deadline)) {
return false
}
con.disconnect()
con = open(url, "GET", remainingTimeout(timeout, deadline), true)
responseCode = con.getResponseCode()
if (responseCode == 416) {
if (!hasTimeRemaining(timeout, deadline)) {
return false
}
con.disconnect()
con = open(url, "GET", remainingTimeout(timeout, deadline), false)
responseCode = con.getResponseCode()
}
}
if (responseCode >= 300 && responseCode < 400) {
String location = con.getHeaderField("Location")
if (location == null || redirect == MAX_REDIRECTS) {
return false
}
url = new URL(url, location)
continue
}
return responseCode >= 200 && responseCode < 300
} finally {
if (con != null) {
con.disconnect()
}
}
}
} catch (RuntimeException | IOException ignored) {
return false
} finally {
if (con != null) {
con.disconnect()
}
}
return false
}

private static HttpURLConnection open(URL url, String method, int timeout, boolean range) {
HttpURLConnection con = (HttpURLConnection) url.openConnection()
con.setInstanceFollowRedirects(false)
con.setRequestMethod(method)
if (range) {
con.setRequestProperty("Range", "bytes=0-0")
}
con.setConnectTimeout(timeout)
con.setReadTimeout(timeout)
return con
}

private static boolean isHttpUrl(URL url) {
return "http".equalsIgnoreCase(url.protocol) || "https".equalsIgnoreCase(url.protocol)
}

private static boolean shouldFallbackToGet(int responseCode) {
return responseCode == HttpURLConnection.HTTP_BAD_REQUEST ||
responseCode == HttpURLConnection.HTTP_UNAUTHORIZED ||
responseCode == HttpURLConnection.HTTP_FORBIDDEN ||
responseCode == HttpURLConnection.HTTP_BAD_METHOD ||
responseCode == HttpURLConnection.HTTP_NOT_IMPLEMENTED
}

private static int remainingTimeout(int configuredTimeout, long deadline) {
if (configuredTimeout <= 0) {
return configuredTimeout
}
long remainingMillis = TimeUnit.NANOSECONDS.toMillis(deadline - System.nanoTime())
return (int) Math.max(1, Math.min(Integer.MAX_VALUE, remainingMillis))
}

private static boolean hasTimeRemaining(int configuredTimeout, long deadline) {
return configuredTimeout <= 0 || System.nanoTime() < deadline
}

@Override
Expand Down Expand Up @@ -91,10 +162,18 @@ abstract class AbstractInOut implements GuiInteraction {

@Override
String prompt(Map<String, Object> namedParams) {
return prompt(String.valueOf(namedParams.getOrDefault("title", "")),
String.valueOf(namedParams.getOrDefault("headerText", "")),
String.valueOf(namedParams.getOrDefault("message", "")),
String.valueOf(namedParams.getOrDefault("defaultValue", "")))
if (namedParams == null) {
throw new IllegalArgumentException("namedParams cannot be null")
}
return prompt(asPromptValue(namedParams, "title"),
asPromptValue(namedParams, "headerText"),
asPromptValue(namedParams, "message"),
asPromptValue(namedParams, "defaultValue"))
}

private static String asPromptValue(Map<String, Object> namedParams, String key) {
Object value = namedParams.get(key)
return value == null ? "" : String.valueOf(value)
}

@Override
Expand Down
93 changes: 80 additions & 13 deletions gi-common/src/main/groovy/se/alipsa/gi/FileUtils.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ package se.alipsa.gi

import groovy.transform.CompileStatic

import java.nio.charset.Charset
import java.nio.charset.StandardCharsets
import java.util.regex.Pattern

/**
* Utility class for file and resource operations.
* <p>
Expand All @@ -11,37 +15,96 @@ import groovy.transform.CompileStatic
@CompileStatic
class FileUtils {

private static final Pattern XML_ENCODING = Pattern.compile(
"(?i)<\\?xml[^>]*encoding\\s*=\\s*['\"]([^'\"]+)['\"]")

/**
* Extracts the base filename from a path or URL string.
* <p>
* Handles both Unix and Windows path separators, and strips query strings from URLs.
* Handles both Unix and Windows path separators, and strips query strings from paths or URLs.
* URL fragments are stripped only from URL-shaped input; {@code #} remains valid in local filenames.
* <p>
* Examples:
* <ul>
* <li>{@code baseName("/path/to/file.txt")} returns {@code "file.txt"}</li>
* <li>{@code baseName("C:\\path\\to\\file.txt")} returns {@code "file.txt"}</li>
* <li>{@code baseName("http://example.com/file.txt?param=1")} returns {@code "file.txt"}</li>
* <li>{@code baseName("/tmp/report#2.pdf")} returns {@code "report#2.pdf"}</li>
* <li>{@code baseName("filename")} returns {@code "filename"}</li>
* <li>{@code baseName("/path/to/dir/")} returns {@code "/path/to/dir/"} (empty basename)</li>
* </ul>
*
* @param url the path or URL string to extract the filename from
* @return the base filename, or the original string if no path separator is found,
* @return the base filename, or the original path if it ends with a separator,
* or {@code null} if the input is {@code null}
*/
static String baseName(String url) {
if (url == null) return null;
String basename = "";
url = url.replace('\\', '/');
if (url == null) return null
url = url.replace('\\', '/')
int queryIndex = url.indexOf('?')
boolean urlLike = url ==~ /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/.*$/ ||
url.startsWith('file:') || url.startsWith('jar:')
int fragmentIndex = urlLike ? url.indexOf('#') : -1
int suffixIndex = queryIndex >= 0 && fragmentIndex >= 0 ?
Math.min(queryIndex, fragmentIndex) : Math.max(queryIndex, fragmentIndex)
if (suffixIndex >= 0) {
url = url.substring(0, suffixIndex)
}
String basename = ""
if (url.contains("/")) {
String filePart = url.substring(url.lastIndexOf('/')+1);
if (filePart.contains("?")) {
basename = filePart.substring(0, filePart.indexOf('?'));
} else {
basename = filePart;
String filePart = url.substring(url.lastIndexOf('/')+1)
basename = filePart
}
return basename.length() > 0 ? basename : url
}

/**
* Returns whether a URL identifies an SVG resource by its final path segment.
* Query strings and URL fragments are ignored, while names such as {@code report.svg.png}
* and {@code /svgs.svg/logo.png} are not treated as SVG resources.
*/
static boolean isSvgResource(URL url) {
return url != null && baseName(url.toExternalForm()).toLowerCase(Locale.ROOT).endsWith('.svg')
}

/**
* Decodes XML bytes using the encoding declared in the XML prolog when present.
* UTF-8 is used when no declaration or byte-order mark is available.
*/
static String decodeXml(byte[] content) {
if (content == null || content.length == 0) {
return ''
}
Charset charset = StandardCharsets.UTF_8
if (content.length >= 2 && content[0] == (byte) 0xFF && content[1] == (byte) 0xFE) {
charset = StandardCharsets.UTF_16LE
} else if (content.length >= 2 && content[0] == (byte) 0xFE && content[1] == (byte) 0xFF) {
charset = StandardCharsets.UTF_16BE
}
String prefix = new String(content, 0, Math.min(content.length, 512), charset)
def matcher = XML_ENCODING.matcher(prefix)
if (matcher.find()) {
try {
charset = Charset.forName(matcher.group(1))
} catch (IllegalArgumentException ignored) {
// Keep the UTF-8/BOM-derived fallback for an unknown declaration.
}
}
return basename.length() > 0 ? basename : url;
String decoded = new String(content, charset)
return decoded.startsWith('\uFEFF') ? decoded.substring(1) : decoded
}

/**
* Reads and decodes XML content from a URL, honoring its XML declaration and byte-order mark.
*
* @param url the URL containing XML content
* @return the decoded XML content
* @throws IOException if the URL cannot be opened or read
*/
static String readXml(URL url) throws IOException {
try (InputStream input = url.openStream()) {
return decodeXml(input.readAllBytes())
}
}

/**
Expand All @@ -59,9 +122,12 @@ class FileUtils {
* This method allows loading resources from both the classpath and the file system.
*
* @param resource the resource path to locate (classpath resource or file path)
* @return the URL of the resource, or {@code null} if not found and path is invalid
* @return the URL of the resource, or {@code null} if it cannot be found
*/
static URL getResourceUrl(String resource) {
if (resource == null || resource.isEmpty()) {
return null
}
final List<ClassLoader> classLoaders = new ArrayList<>()
classLoaders.add(Thread.currentThread().getContextClassLoader())
classLoaders.add(FileUtils.class.getClassLoader())
Expand All @@ -82,7 +148,8 @@ class FileUtils {
return systemResource
} else {
try {
return new File(resource).toURI().toURL()
File file = new File(resource)
return file.exists() ? file.toURI().toURL() : null
} catch (MalformedURLException e) {
return null
}
Expand Down
Loading
Loading