Skip to content

Commit

Permalink
Allow preview and selection of already uploaded icons (#158) (#160)
Browse files Browse the repository at this point in the history
Show already uploaded icons and make them selectable.
An ItemListener was added to automatically delete unused icons when the folder is deleted.
  • Loading branch information
strangelookingnerd committed Mar 29, 2023
1 parent dcb8f49 commit a4bb391
Show file tree
Hide file tree
Showing 16 changed files with 768 additions and 31 deletions.
5 changes: 5 additions & 0 deletions README.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ Release notes are recorded in https://github.com/jenkinsci/custom-folder-icon-pl

This version requires Jenkins 2.357 and above in order to support the transition to https://www.jenkins.io/blog/2022/06/28/require-java-11/[Java 11].

* Version 2.6 enables users to select and re-use a already existing `CustomFolderIcon`. Further an icon file will now be deleted automatically if the folder it used is being deleted - unless of course the file is still used by another folder.
* Version 2.5 introduces a new type of icon.
The `EmojiFolderIcon` provides https://unicode.org/emoji/charts/full-emoji-list.html[unicode emojis] as icon.
* Version 2.3 introduces a new type of icon.
Expand Down Expand Up @@ -61,6 +62,10 @@ You can crop the image to the desired result and upload it using the "Apply" but

The file name will be randomized during upload.

You can also select an image from the list of the already available icons.

The file will be deleted automatically if the folder it used is being deleted - unless of course the file is still used by another folder.

image:images/custom-folder-icon-configuration.png[]

===== Job DSL
Expand Down
Binary file modified images/custom-folder-icon-configuration.png
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
96 changes: 78 additions & 18 deletions src/main/java/jenkins/plugins/foldericon/CustomFolderIcon.java
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
import hudson.Extension;
import hudson.FilePath;
import hudson.model.Item;
import hudson.model.listeners.ItemListener;
import jenkins.model.Jenkins;
import org.apache.commons.fileupload.FileItem;
import org.apache.commons.lang.StringUtils;
Expand Down Expand Up @@ -73,6 +74,32 @@ public CustomFolderIcon(String foldericon) {
this.foldericon = foldericon;
}

/**
* Get all icons that are currently available.
*
* @return all the icons that have been uploaded, sorted descending by {@link FilePath#lastModified()}.
*/
public static List<String> getAvailableIcons() {
try {
FilePath iconDir = Jenkins.get().getRootPath().child(USER_CONTENT_PATH).child(PLUGIN_PATH);

if (iconDir.exists()) {
return iconDir.list().stream().sorted((file1, file2) -> {
try {
return Long.compare(file2.lastModified(), file1.lastModified());
} catch (Exception ex) {
return 0;
}
}).map(FilePath::getName).collect(Collectors.toList());
} else {
return List.of();
}
} catch (IOException | InterruptedException ex) {
LOGGER.log(Level.WARNING, ex, () -> "Unable to list available icons!");
return List.of();
}
}

@Override
protected void setOwner(AbstractFolder<?> folder) {
this.owner = folder;
Expand Down Expand Up @@ -173,36 +200,69 @@ public HttpResponse doUploadIcon(StaplerRequest req, @AncestorInPath Item item)
*
* @param req the request
* @return OK
* @throws InterruptedException if there is a file handling error
* @throws IOException if there is a file handling error
*/
@RequirePOST
public HttpResponse doCleanup(StaplerRequest req) throws InterruptedException, IOException {
Jenkins jenkins = Jenkins.get();
jenkins.checkPermission(Jenkins.ADMINISTER);
public HttpResponse doCleanup(StaplerRequest req) {
Jenkins.get().checkPermission(Jenkins.ADMINISTER);

FilePath iconDir = jenkins.getRootPath().child(USER_CONTENT_PATH).child(PLUGIN_PATH);
FilePath iconDir = Jenkins.get().getRootPath().child(USER_CONTENT_PATH).child(PLUGIN_PATH);

if (iconDir.exists()) {
List<String> existingIcons = iconDir.list().stream().map(FilePath::getName).collect(Collectors.toList());
List<String> existingIcons = getAvailableIcons();

List<String> usedIcons = Jenkins.get().getAllItems(AbstractFolder.class).stream()
.filter(folder -> folder.getIcon() instanceof CustomFolderIcon)
.map(folder -> ((CustomFolderIcon) folder.getIcon()).getFoldericon())
.collect(Collectors.toList());

if (usedIcons.isEmpty() || existingIcons.removeAll(usedIcons)) {
for (String icon : existingIcons) {
try {
if (!iconDir.child(icon).delete()) {
LOGGER.warning(() -> "Unable to delete unused Folder Icon '" + icon + "'!");
}
} catch (IOException | InterruptedException ex) {
LOGGER.log(Level.WARNING, ex, () -> "Unable to delete unused Folder Icon '" + icon + "'!");
}
}
}
return HttpResponses.ok();
}
}

List<String> usedIcons = jenkins.getAllItems(AbstractFolder.class).stream()
.filter(folder -> folder.getIcon() instanceof CustomFolderIcon)
.map(folder -> ((CustomFolderIcon) folder.getIcon()).getFoldericon()).collect(Collectors.toList());
/**
* Item Listener to clean up unused icons when the folder is deleted.
*
* @author strangelookingnerd
*/
@Extension
public static class CustomFolderIconCleanup extends ItemListener {

if (usedIcons.isEmpty() || existingIcons.removeAll(usedIcons)) {
for (String icon : existingIcons) {
@Override
public void onDeleted(Item item) {
if (item instanceof AbstractFolder<?>) {
FolderIcon icon = ((AbstractFolder<?>) item).getIcon();
if (icon instanceof CustomFolderIcon) {
String foldericon = ((CustomFolderIcon) icon).getFoldericon();

// delete the icon only if there is no other usage
boolean orphan = Jenkins.get().getAllItems(AbstractFolder.class).stream()
.filter(folder -> folder.getIcon() instanceof CustomFolderIcon
&& StringUtils.equals(foldericon, ((CustomFolderIcon) folder.getIcon()).getFoldericon()))
.limit(2)
.count() <= 1;

if (orphan) {
FilePath iconDir = Jenkins.get().getRootPath().child(USER_CONTENT_PATH).child(PLUGIN_PATH);
try {
if (!iconDir.child(icon).delete()) {
LOGGER.warning(() -> "Unable to delete unused Folder Icon '" + icon + "'!");
if (!iconDir.child(foldericon).delete()) {
LOGGER.warning(() -> "Unable to delete Folder Icon '" + foldericon + "' for Folder '" + item.getFullName() + "'!");
}
} catch (IOException ex) {
LOGGER.log(Level.WARNING, ex, () -> "Unable to delete unused Folder Icon '" + icon + "'!");
} catch (IOException | InterruptedException ex) {
LOGGER.log(Level.WARNING, ex, () -> "Unable to delete Folder Icon '" + foldericon + "' for Folder '" + item.getFullName() + "'!");
}
}
}
}
return HttpResponses.ok();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,26 @@ SOFTWARE.
<script src="${rootURL}/plugin/custom-folder-icon/js/custom-icon-config.js" type="text/javascript" />
<link rel="stylesheet" href="${rootURL}/plugin/custom-folder-icon/css/croppie.css" type="text/css" />
<script src="${rootURL}/plugin/custom-folder-icon/scripts/croppie.js" type="text/javascript" />
<j:invokeStatic var="icons" method="getAvailableIcons" className="jenkins.plugins.foldericon.CustomFolderIcon" />
<f:entry title="${%IconPreview}" help="${descriptor.getHelpFile('upload')}">
<div>
<div id="file-cropper"></div>
<f:file accept="image/*" onchange="setFile(this.files[0])" />
<f:file id="file-upload" accept="image/*" onchange="setFile(this.files[0])" />
<st:nbsp />
<input type="button" value="${%Apply}" onclick="doUploadIcon('${%UploadSuccess}', '${%UploadFailed}')" />
</div>
</f:entry>
<j:if test="${not empty icons}">
<f:advanced title="${%AvailableIcons}">
<j:forEach var="icon" items="${icons}">
<a tooltip="${icon}">
<img class="custom-icon-selection" src="${rootURL}/userContent/customFolderIcons/${icon}"
onclick="setIcon(this.src)" />
</a>
<st:nbsp />
</j:forEach>
</f:advanced>
</j:if>
<f:entry field="foldericon">
<f:textbox id="file-name" value="${instance.foldericon}" clazz="file-name" />
</f:entry>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,6 @@

IconPreview=Icon preview
Apply=Apply
AvailableIcons=Show all available icons
UploadSuccess=Image uploaded as
UploadFailed=Image uploaded failed:
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@
Upload an image to represent the folder.</br>
Use the "Browse..." button to select an image from disk.</br>
You can crop the image to the desired result and upload it using the "Apply" button.</br>
The file name will be randomized during upload.</br>
You can also select an image from the list of the already available icons.</br>
</br>
The recommended minimum size of the image is 128x128 pixels.
The recommended minimum size of the image is 128x128 pixels.</br>
The image will be deleted automatically if this folder is being deleted - unless of course it is still used by another folder.
</div>
24 changes: 24 additions & 0 deletions src/main/webapp/css/croppie.css
Original file line number Diff line number Diff line change
@@ -1,3 +1,27 @@
/*
* The MIT License
*
* Copyright (c) 2015 Foliotek Inc
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/

.croppie-container {
width: 100%;
height: 100%;
Expand Down
30 changes: 30 additions & 0 deletions src/main/webapp/css/custom-icon.css
Original file line number Diff line number Diff line change
@@ -1,7 +1,37 @@
/*
* The MIT License
*
* Copyright (c) 2023 strangelookingnerd
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/

.jenkins-file-upload {
width: 90%;
}

.file-name {
display: none;
}

.custom-icon-selection {
cursor: pointer;
height: 24px;
width: 24px;
}
24 changes: 24 additions & 0 deletions src/main/webapp/css/emoji.css
Original file line number Diff line number Diff line change
@@ -1,3 +1,27 @@
/*
* The MIT License
*
* Copyright (c) 2023 strangelookingnerd
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/

.emoji-preview {
font-size: 48px;
width: 86px;
Expand Down
24 changes: 24 additions & 0 deletions src/main/webapp/css/ionicon.css
Original file line number Diff line number Diff line change
@@ -1,3 +1,27 @@
/*
* The MIT License
*
* Copyright (c) 2023 strangelookingnerd
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/

.icon-selection {
color: unset;
cursor: pointer;
Expand Down
59 changes: 57 additions & 2 deletions src/main/webapp/js/custom-icon-config.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,27 @@
/*
* The MIT License
*
* Copyright (c) 2023 strangelookingnerd
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/

let croppie

/**
Expand All @@ -20,10 +44,40 @@ function init() {
enforceBoundary: false,
url: url
});

// fix to scale the image correctly
try {
croppie.bind({
zoom: 1
});
} catch (e) {
// NOP
}
}

/**
* Set an icon for cropping / preview.
*
* @param {string} url The icon url.
*/
function setIcon(url) {
// load icon image
croppie.bind({
url: url,
zoom: 1
});

// reset the name in the upload input element
document.getElementById("file-upload").value = "";
// set the file name - in case you don't crop / upload the image again it will simply be re-used that way
let paths = url.split("/");
let icon = paths[paths.length - 1];
document.getElementById("file-name").setAttribute("value", icon);
}


/**
* Set the file for cropping.
* Set a file for cropping / preview.
*
* @param {Blob} file The file input.
*/
Expand All @@ -32,7 +86,8 @@ function setFile(file) {
let reader = new FileReader();
reader.onload = function (ev) {
croppie.bind({
url: ev.target.result
url: ev.target.result,
zoom: 1
});
}
reader.readAsDataURL(file);
Expand Down

0 comments on commit a4bb391

Please sign in to comment.