diff --git a/appinventor/appengine/src/com/google/appinventor/YaClient.gwt.xml b/appinventor/appengine/src/com/google/appinventor/YaClient.gwt.xml index 5434d67b47a..c5fa3279b78 100644 --- a/appinventor/appengine/src/com/google/appinventor/YaClient.gwt.xml +++ b/appinventor/appengine/src/com/google/appinventor/YaClient.gwt.xml @@ -34,6 +34,7 @@ + @@ -45,6 +46,7 @@ + @@ -93,6 +95,8 @@ + + @@ -110,6 +114,12 @@ + + diff --git a/appinventor/appengine/src/com/google/appinventor/client/Images.java b/appinventor/appengine/src/com/google/appinventor/client/Images.java index b8e043337ed..1b4c7391ec1 100644 --- a/appinventor/appengine/src/com/google/appinventor/client/Images.java +++ b/appinventor/appengine/src/com/google/appinventor/client/Images.java @@ -834,4 +834,16 @@ public interface Images extends Resources { */ @Source("com/google/appinventor/images/trendline.png") ImageResource trendline(); + + /** + * Generic file icon for assets that are not images, audio, or video. + */ + @Source("com/google/appinventor/images/file.png") + ImageResource fileIcon(); + + /** + * Sync icon for updated global assets. + */ + @Source("com/google/appinventor/images/syncIcon.png") + ImageResource syncIcon(); } diff --git a/appinventor/appengine/src/com/google/appinventor/client/Ode.java b/appinventor/appengine/src/com/google/appinventor/client/Ode.java index 2e146eece73..3a6e56119d1 100644 --- a/appinventor/appengine/src/com/google/appinventor/client/Ode.java +++ b/appinventor/appengine/src/com/google/appinventor/client/Ode.java @@ -29,6 +29,7 @@ import com.google.appinventor.client.editor.youngandroid.TutorialPanel; import com.google.appinventor.client.editor.youngandroid.YaFormEditor; import com.google.appinventor.client.editor.youngandroid.YaProjectEditor; +import com.google.appinventor.client.editor.youngandroid.AssetManagerPanel; import com.google.appinventor.client.editor.youngandroid.YaVisibleComponentsPanel; import com.google.appinventor.client.explorer.commands.ChainableCommand; import com.google.appinventor.client.explorer.commands.CommandRegistry; @@ -96,6 +97,7 @@ import com.google.gwt.resources.client.CssResource; import com.google.gwt.resources.client.ImageResource; import com.google.gwt.uibinder.client.UiField; +import com.google.gwt.uibinder.client.UiFactory; import com.google.gwt.user.client.Command; import com.google.gwt.user.client.Event; import com.google.gwt.user.client.Event.NativePreviewEvent; @@ -227,6 +229,7 @@ public class Ode implements EntryPoint { private int projectsTabIndex; private int designTabIndex; private int debuggingTabIndex; + private int assetLibraryTabIndex; private int userAdminTabIndex; @UiField protected TopPanel topPanel; @UiField protected StatusPanel statusPanel; @@ -238,6 +241,21 @@ public class Ode implements EntryPoint { @UiField (provided = true) protected PaletteBox paletteBox = PaletteBox.getPaletteBox(); @UiField (provided = true) protected ViewerBox viewerBox = ViewerBox.getViewerBox(); @UiField (provided = true) protected AssetListBox assetListBox = AssetListBox.getAssetListBox(); + // @UiField protected AssetManagerPanel assetManagerPanel; + + // public AssetManagerPanel getAssetManagerPanel() { + // return assetManagerPanel; + // } + + @UiFactory + public com.google.appinventor.client.assetlibrary.AssetLibraryWidget createAssetLibraryWidget() { + return new com.google.appinventor.client.assetlibrary.AssetLibraryWidget(this); + } + + @UiFactory + public com.google.appinventor.client.assetlibrary.AssetLibraryWidgetClassic createAssetLibraryWidgetClassic() { + return new com.google.appinventor.client.assetlibrary.AssetLibraryWidgetClassic(this); + } @UiField (provided = true) protected SourceStructureBox sourceStructureBox; @UiField (provided = true) protected PropertiesBox propertiesBox = PropertiesBox.getPropertiesBox(); @@ -387,6 +405,15 @@ public AssetManager getAssetManager() { return assetManager; } + /** + * Returns the asset list box. + * + * @return asset list box + */ + public AssetListBox getAssetListBox() { + return assetListBox; + } + /** * Returns true if we have received the window closing event. */ @@ -529,6 +556,15 @@ public void switchToDebuggingView() { resizeWorkArea((WorkAreaPanel) deckPanel.getWidget(debuggingTabIndex)); } + /** + * Switch to the Asset Library tab + */ + public void switchToAssetLibraryView() { + hideChaff(); + hideTutorials(); + deckPanel.showWidget(assetLibraryTabIndex); + } + /** * Processes the template and galleryId flags. * @@ -1049,6 +1085,9 @@ public final void onBrowserEvent(Event event) { // Debugging Panel debuggingTabIndex = 3; + // Asset Library Panel + assetLibraryTabIndex = 4; + RootPanel.get().add(mainPanel); // Add a handler to the RootPanel to keep track of Google Chrome Pinch Zooming and diff --git a/appinventor/appengine/src/com/google/appinventor/client/Ode.ui.xml b/appinventor/appengine/src/com/google/appinventor/client/Ode.ui.xml index ddaa7315416..104c760d519 100644 --- a/appinventor/appengine/src/com/google/appinventor/client/Ode.ui.xml +++ b/appinventor/appengine/src/com/google/appinventor/client/Ode.ui.xml @@ -9,6 +9,7 @@ xmlns:ex="urn:import:com.google.appinventor.client.explorer.youngandroid" xmlns:ode="urn:import:com.google.appinventor.client" xmlns:box="urn:import:com.google.appinventor.client.boxes" + xmlns:assetlib="urn:import:com.google.appinventor.client.assetlibrary" ui:generatedFormat="com.google.gwt.i18n.server.PropertyCatalogFactory" ui:generatedKeys="com.google.gwt.i18n.server.keygen.MethodNameKeyGenerator" ui:generateLocales="default"> @@ -39,6 +40,10 @@ + + + + diff --git a/appinventor/appengine/src/com/google/appinventor/client/OdeMessages.java b/appinventor/appengine/src/com/google/appinventor/client/OdeMessages.java index 8ac2c3f1481..06be19d1214 100755 --- a/appinventor/appengine/src/com/google/appinventor/client/OdeMessages.java +++ b/appinventor/appengine/src/com/google/appinventor/client/OdeMessages.java @@ -119,9 +119,44 @@ public interface OdeMessages extends Messages, ComponentTranslations { @Description("Text on Toggle Warning Button") String hideWarnings(); - @DefaultMessage("Upload File ...") - @Description("Text on \"Add...\" button") - String addButton(); + @DefaultMessage("Upload File ...") @Description("Text on \"Add...\" button") String addButton(); @DefaultMessage("Preview") @Description("Text on \"Preview\" button") String previewButton(); @DefaultMessage("Download") @Description("Text on \"Download\" button") String downloadButton(); @DefaultMessage("Link to Project") + @Description("Text for button to link a global asset to the current project") + String linkToProjectButton(); + + @DefaultMessage("Error linking global asset to project.") + @Description("Error message when linking global asset to project fails") + String linkGlobalAssetError(); + + @DefaultMessage("Global asset ''{0}'' linked successfully to project.") + @Description("Success message when a global asset is linked to a project") + String globalAssetLinked(String assetName); + + @DefaultMessage("No project is currently open. Please open a project to link global assets.") + @Description("Warning message when trying to link a global asset without an open project") + String noProjectOpenForLinking(); + + @DefaultMessage("Global Asset Update") + @Description("Title for the global asset update dialog") + String globalAssetUpdateDialogTitle(); + + @DefaultMessage("The global asset \"{0}\" has been updated.\n\n" + + "Current version timestamp: {1}\n" + + "New version timestamp: {2}\n\n" + + "Do you want to update this asset in your project?") + @Description("Message for the global asset update dialog") + String globalAssetUpdateMessage(String fileName, String oldTimestamp, String newTimestamp); + + @DefaultMessage("Error updating global asset in project.") + @Description("Error message when updating global asset in project fails") + String globalAssetUpdateError(); + + @DefaultMessage("Global asset \"{0}\" updated successfully.") + @Description("Success message when a global asset is updated in the project") + String globalAssetUpdatedSuccessfully(String fileName); + + @DefaultMessage("Error checking for global asset updates.") + @Description("Error message when checking for global asset updates fails") + String errorCheckingGlobalAssetUpdate(); @DefaultMessage("Name") @Description("Header for name column of project table") @@ -539,6 +574,10 @@ public interface OdeMessages extends Messages, ComponentTranslations { @Description("Name of Import Template menuitem") String importTemplateButton(); + @DefaultMessage("Asset Library") + @Description("Name of Asset Library menuitem") + String assetLibraryMenuItem(); + @DefaultMessage("Export selected project (.aia) to my computer") @Description("Name of Export Project menuitem") String exportProjectMenuItem(); @@ -5823,4 +5862,120 @@ String newerVersionComponentException(String componentType, int srcCompVersion, @DefaultMessage("Welcome to App Inventor Neo! If you are looking for the classic App Inventor look, you can switch in the User Interface Settings, or click here.") @Description("Message shown in the info popup when the user first opens the Neo UI.") String neoWelcomeMessage(); + + @DefaultMessage("Asset Manager") + @Description("Title for the Asset Manager panel") + String assetManagerTitle(); + + @DefaultMessage("Upload Asset") + @Description("Text for the upload asset button") + String uploadAssetButton(); + + @DefaultMessage("Search Assets") + @Description("Placeholder text for asset search box") + String searchAssetsPlaceholder(); + + @DefaultMessage("Folders") + @Description("Title for the folders section") + String foldersSectionTitle(); + + @DefaultMessage("Tags") + @Description("Title for the tags section") + String tagsSectionTitle(); + + @DefaultMessage("Recent") + @Description("Title for the recent assets section") + String recentAssetsTitle(); + + @DefaultMessage("Drag and drop files here") + @Description("Text shown in the upload drop zone") + String dragDropText(); + + @DefaultMessage("or click to browse") + @Description("Text shown next to drag and drop message") + String clickToBrowseText(); + + @DefaultMessage("Preview") + @Description("Title for the asset preview panel") + String previewTitle(); + + @DefaultMessage("Properties") + @Description("Title for the asset properties panel") + String propertiesTitle(); + + @DefaultMessage("Name") + @Description("Label for asset name property") + String assetNameLabel(); + + @DefaultMessage("Type") + @Description("Label for asset type property") + String assetTypeLabel(); + + @DefaultMessage("Size") + @Description("Label for asset size property") + String assetSizeLabel(); + + @DefaultMessage("Date Added") + @Description("Label for asset date added property") + String assetDateAddedLabel(); + + @DefaultMessage("Tags") + @Description("Label for asset tags property") + String assetTagsLabel(); + + @DefaultMessage("Add to Project") + @Description("Text for the add to project button") + String addToProjectButton(); + + @DefaultMessage("Delete") + @Description("Text for the delete asset button") + String deleteAssetButton(); + + @DefaultMessage("Edit") + @Description("Text for the edit asset button") + String editAssetButton(); + + @DefaultMessage("Create Folder") + @Description("Text for the create folder button") + String createFolderButton(); + + @DefaultMessage("Add Tag") + @Description("Text for the add tag button") + String addTagButton(); + + @DefaultMessage("Filter by Type") + @Description("Label for the asset type filter") + String filterByTypeLabel(); + + @DefaultMessage("All Types") + @Description("Text for the all types filter option") + String allTypesFilter(); + + @DefaultMessage("Images") + @Description("Text for the images filter option") + String imagesFilter(); + + @DefaultMessage("Audio") + @Description("Text for the audio filter option") + String audioFilter(); + + @DefaultMessage("Video") + @Description("Text for the video filter option") + String videoFilter(); + + @DefaultMessage("Other") + @Description("Text for the other files filter option") + String otherFilter(); + + @DefaultMessage("Upload as Global Asset") + @Description("Label for the checkbox to upload as a global asset") + String uploadAsGlobalAssetCheckbox(); + + @DefaultMessage("Global Folder (optional)") + @Description("Placeholder for the global folder text box in asset upload") + String globalFolderPlaceholder(); + + @DefaultMessage("Error fetching global assets.") + @Description("Error message when fetching global assets fails") + String errorFetchingGlobalAssets(); } diff --git a/appinventor/appengine/src/com/google/appinventor/client/OdeMessages.properties b/appinventor/appengine/src/com/google/appinventor/client/OdeMessages.properties new file mode 100644 index 00000000000..7b059a51f6e --- /dev/null +++ b/appinventor/appengine/src/com/google/appinventor/client/OdeMessages.properties @@ -0,0 +1,5 @@ +uploadAsGlobalAssetCheckbox=Upload as Global Asset +globalFolderPlaceholder=Global Folder (optional) +errorFetchingGlobalAssets=Error fetching global assets. +previewButton=Preview +downloadButton=Download \ No newline at end of file diff --git a/appinventor/appengine/src/com/google/appinventor/client/TopToolbar.java b/appinventor/appengine/src/com/google/appinventor/client/TopToolbar.java index 63c50271444..686e137a836 100644 --- a/appinventor/appengine/src/com/google/appinventor/client/TopToolbar.java +++ b/appinventor/appengine/src/com/google/appinventor/client/TopToolbar.java @@ -15,6 +15,7 @@ import com.google.appinventor.client.editor.youngandroid.DesignToolbar.Screen; import com.google.appinventor.client.editor.youngandroid.YaBlocksEditor; import com.google.appinventor.client.widgets.DropDownButton; +import com.google.appinventor.client.widgets.DropDownItem; import com.google.appinventor.common.version.AppInventorFeatures; import com.google.appinventor.shared.storage.StorageUtil; import com.google.gwt.core.client.GWT; @@ -68,6 +69,7 @@ public class TopToolbar extends Composite { private static final String WIDGET_NAME_ABOUT = "About"; private static final String WIDGET_NAME_IMPORTPROJECT = "ImportProject"; private static final String WIDGET_NAME_IMPORTTEMPLATE = "ImportTemplate"; + private static final String WIDGET_NAME_ASSETLIBRARY = "AssetLibrary"; private static final String WIDGET_NAME_EXPORTPROJECT = "ExportProject"; private static final String WIDGET_NAME_PROJECTPROPERTIES = "ProjectProperties"; @@ -87,6 +89,7 @@ public class TopToolbar extends Composite { @UiField protected DropDownButton buildDropDown; @UiField protected DropDownButton settingsDropDown; @UiField protected DropDownButton adminDropDown; + @UiField protected DropDownItem assetLibraryDropDown; @UiField (provided = true) Boolean hasWriteAccess; protected boolean readOnly; @@ -316,10 +319,7 @@ public void updateFileMenuButtons(int view) { // TODO: This code will work only so long as these menu items stay located in the file/build // menus as expected. It should be refactored. int projectCount = ProjectListBox.getProjectListBox().getProjectList().getMyProjectsCount(); - if (view == 0) { // We are in the Projects view - if ("ProjectDesignOnly".equals(fileDropDown.getName())) { - fileDropDown.setVisible(false); - } + if (view == Ode.PROJECTS) { // We are in the Projects view fileDropDown.setItemEnabled(MESSAGES.deleteProjectButton(), false); fileDropDown.setItemEnabled(MESSAGES.projectPropertiesMenuItem(), false); fileDropDown.setItemVisible(MESSAGES.deleteFromTrashButton(), false); @@ -330,6 +330,7 @@ public void updateFileMenuButtons(int view) { fileDropDown.setItemEnabled(MESSAGES.saveAsMenuItem(), false); fileDropDown.setItemEnabled(MESSAGES.checkpointMenuItem(), false); fileDropDown.setItemEnabled(MESSAGES.projectPropertiesMenuItem(), false); + fileDropDown.setItemEnabledById(WIDGET_NAME_ASSETLIBRARY, true); buildDropDown.setItemEnabled(MESSAGES.showExportAndroidApk(), false); buildDropDown.setItemEnabled(MESSAGES.showExportAndroidAab(), false); if (Ode.getInstance().hasSecondBuildserver()) { @@ -345,9 +346,6 @@ public void updateFileMenuButtons(int view) { } } } else { // We have to be in the Designer/Blocks view - if ("ProjectDesignOnly".equals(fileDropDown.getName())) { - fileDropDown.setVisible(true); - } fileDropDown.setItemEnabled(MESSAGES.deleteProjectButton(), true); fileDropDown.setItemEnabled(MESSAGES.projectPropertiesMenuItem(), true); fileDropDown.setItemEnabled(MESSAGES.trashProjectMenuItem(), true); @@ -357,6 +355,7 @@ public void updateFileMenuButtons(int view) { fileDropDown.setItemEnabled(MESSAGES.saveAsMenuItem(), true); fileDropDown.setItemEnabled(MESSAGES.checkpointMenuItem(), true); fileDropDown.setItemEnabled(MESSAGES.projectPropertiesMenuItem(), true); + fileDropDown.setItemEnabledById(WIDGET_NAME_ASSETLIBRARY, false); buildDropDown.setItemEnabled(MESSAGES.showExportAndroidApk(), true); buildDropDown.setItemEnabled(MESSAGES.showExportAndroidAab(), true); if (Ode.getInstance().hasSecondBuildserver()) { diff --git a/appinventor/appengine/src/com/google/appinventor/client/TopToolbar.ui.xml b/appinventor/appengine/src/com/google/appinventor/client/TopToolbar.ui.xml index bebf11d91d4..5e5da5de07f 100644 --- a/appinventor/appengine/src/com/google/appinventor/client/TopToolbar.ui.xml +++ b/appinventor/appengine/src/com/google/appinventor/client/TopToolbar.ui.xml @@ -29,6 +29,9 @@ + + + diff --git a/appinventor/appengine/src/com/google/appinventor/client/actions/SwitchToAssetLibraryAction.java b/appinventor/appengine/src/com/google/appinventor/client/actions/SwitchToAssetLibraryAction.java new file mode 100644 index 00000000000..12fdc185e96 --- /dev/null +++ b/appinventor/appengine/src/com/google/appinventor/client/actions/SwitchToAssetLibraryAction.java @@ -0,0 +1,11 @@ +package com.google.appinventor.client.actions; + +import com.google.appinventor.client.Ode; +import com.google.gwt.user.client.Command; + +public class SwitchToAssetLibraryAction implements Command { + @Override + public void execute() { + Ode.getInstance().switchToAssetLibraryView(); + } +} diff --git a/appinventor/appengine/src/com/google/appinventor/client/assetlibrary/AssetLibrary.gwt.xml b/appinventor/appengine/src/com/google/appinventor/client/assetlibrary/AssetLibrary.gwt.xml new file mode 100644 index 00000000000..764373d4c38 --- /dev/null +++ b/appinventor/appengine/src/com/google/appinventor/client/assetlibrary/AssetLibrary.gwt.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/appinventor/appengine/src/com/google/appinventor/client/assetlibrary/AssetLibraryWidget.java b/appinventor/appengine/src/com/google/appinventor/client/assetlibrary/AssetLibraryWidget.java new file mode 100644 index 00000000000..20bc401a170 --- /dev/null +++ b/appinventor/appengine/src/com/google/appinventor/client/assetlibrary/AssetLibraryWidget.java @@ -0,0 +1,2264 @@ +package com.google.appinventor.client.assetlibrary; + +import com.google.appinventor.client.Ode; +import com.google.appinventor.client.explorer.commands.PreviewFileCommand; +import static com.google.appinventor.client.Ode.MESSAGES; +import com.google.appinventor.shared.storage.StorageUtil; +import com.google.gwt.user.client.ui.*; +import com.google.gwt.event.dom.client.ClickEvent; +import com.google.gwt.event.dom.client.ClickHandler; +import com.google.gwt.event.dom.client.KeyUpEvent; +import com.google.gwt.event.dom.client.KeyUpHandler; +import com.google.gwt.event.dom.client.ChangeEvent; +import com.google.gwt.event.dom.client.ChangeHandler; +import com.google.gwt.event.dom.client.DragStartEvent; +import com.google.gwt.event.dom.client.DragStartHandler; +import com.google.gwt.event.dom.client.DragOverEvent; +import com.google.gwt.event.dom.client.DragOverHandler; +import com.google.gwt.event.dom.client.DropEvent; +import com.google.gwt.event.dom.client.DropHandler; +import com.google.gwt.user.client.Window; +import com.google.gwt.user.client.Event; +import com.google.gwt.user.client.ui.FormPanel.SubmitCompleteEvent; +import com.google.gwt.user.client.ui.FormPanel.SubmitEvent; +import com.google.gwt.user.client.ui.FormPanel.SubmitHandler; +import com.google.gwt.user.client.ui.FormPanel.SubmitCompleteHandler; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import com.google.gwt.resources.client.ImageResource; +import com.google.gwt.user.client.ui.Image; +import com.google.appinventor.client.style.neo.ImagesNeo; +import com.google.appinventor.shared.rpc.globalasset.GlobalAssetService; +import com.google.appinventor.shared.rpc.globalasset.GlobalAssetServiceAsync; +import com.google.appinventor.shared.rpc.ServerLayout; +import com.google.appinventor.shared.rpc.project.GlobalAsset; +import com.google.appinventor.shared.rpc.project.GlobalAssetProjectNode; +import com.google.appinventor.shared.rpc.project.youngandroid.YoungAndroidAssetNode; +import com.google.appinventor.shared.rpc.project.youngandroid.YoungAndroidAssetsFolder; +import com.google.appinventor.shared.rpc.project.youngandroid.YoungAndroidProjectNode; +import com.google.appinventor.client.explorer.project.Project; +import com.google.appinventor.client.boxes.AssetListBox; +import com.google.gwt.core.client.GWT; +import com.google.gwt.user.client.rpc.AsyncCallback; + +public class AssetLibraryWidget extends Composite { + private VerticalPanel rootPanel; + private HorizontalPanel headerContainer; + private TextBox searchBox; + private ListBox typeFilter; + private Button uploadButton; + private Button closeButton; + private HorizontalPanel mainContentPanel; + private VerticalPanel sidebarPanel; + private ScrollPanel assetScrollPanel; + private FlowPanel assetGridPanel; + private HorizontalPanel footerPanel; + private Label statusLabel; + + // Asset management + private List globalAssets = new ArrayList<>(); + private final GlobalAssetServiceAsync globalAssetService = GWT.create(GlobalAssetService.class); + private static String draggedAssetName; + + // Sidebar state + private List folders = new ArrayList<>(); + private int selectedFolderIndex = 0; + private VerticalPanel folderListPanel; + + // Selection management + private List assetCheckBoxes = new ArrayList<>(); + private Button addSelectedButton; + private Button deleteSelectedButton; + + public AssetLibraryWidget(Ode ode) { + initializeLayout(); + setupEventHandlers(); + loadInitialData(); + injectFolderHoverCSS(); + initWidget(rootPanel); + } + + private void initializeLayout() { + // Main root panel - takes full DeckPanel space + rootPanel = new VerticalPanel(); + rootPanel.setSize("100%", "100%"); + rootPanel.getElement().getStyle().setProperty("margin", "0"); + rootPanel.getElement().getStyle().setProperty("padding", "0"); + rootPanel.getElement().getStyle().setProperty("display", "flex"); + rootPanel.getElement().getStyle().setProperty("flexDirection", "column"); + rootPanel.getElement().getStyle().setProperty("height", "100vh"); + rootPanel.getElement().getStyle().setProperty("maxHeight", "100vh"); + rootPanel.getElement().getStyle().setProperty("overflow", "hidden"); + + createHeader(); + createMainContent(); + createFooter(); + } + + private void createHeader() { + // Header matching neo design with fixed positioning + headerContainer = new HorizontalPanel(); + headerContainer.setWidth("100%"); + headerContainer.setStyleName("ode-TopPanel"); + headerContainer.setVerticalAlignment(HasVerticalAlignment.ALIGN_MIDDLE); + headerContainer.getElement().getStyle().setProperty("padding", "12px 24px"); + headerContainer.getElement().getStyle().setProperty("borderBottom", "1px solid #e0e0e0"); + headerContainer.getElement().getStyle().setProperty("zIndex", "1000"); + headerContainer.getElement().getStyle().setProperty("backgroundColor", "white"); + headerContainer.getElement().getStyle().setProperty("flexShrink", "0"); + headerContainer.getElement().getStyle().setProperty("minHeight", "60px"); + headerContainer.getElement().getStyle().setProperty("boxSizing", "border-box"); + + // Left section: Title and search + HorizontalPanel leftSection = new HorizontalPanel(); + leftSection.setVerticalAlignment(HasVerticalAlignment.ALIGN_MIDDLE); + + // Asset Library Title + Label titleLabel = new Label("Asset Library"); + titleLabel.setStyleName("ode-ProjectNameLabel"); + titleLabel.getElement().getStyle().setProperty("fontSize", "18px"); + titleLabel.getElement().getStyle().setProperty("fontWeight", "600"); + titleLabel.getElement().getStyle().setProperty("marginRight", "20px"); + leftSection.add(titleLabel); + + // Search box matching App Inventor style + searchBox = new TextBox(); + searchBox.getElement().setPropertyString("placeholder", "Search assets..."); + searchBox.setStyleName("ode-TextBox"); + searchBox.getElement().getStyle().setProperty("width", "280px"); + searchBox.getElement().getStyle().setProperty("height", "36px"); + searchBox.getElement().getStyle().setProperty("fontSize", "14px"); + searchBox.getElement().getStyle().setProperty("marginRight", "16px"); + leftSection.add(searchBox); + + // Type filter matching App Inventor dropdown style + typeFilter = new ListBox(); + typeFilter.addItem("All Types"); + typeFilter.addItem("Images"); + typeFilter.addItem("Sounds"); + typeFilter.setStyleName("ode-ListBox"); + typeFilter.getElement().getStyle().setProperty("height", "36px"); + typeFilter.getElement().getStyle().setProperty("fontSize", "14px"); + typeFilter.getElement().getStyle().setProperty("minWidth", "120px"); + typeFilter.getElement().getStyle().setProperty("marginRight", "20px"); + leftSection.add(typeFilter); + + headerContainer.add(leftSection); + + // Spacer + Label spacer = new Label(""); + spacer.getElement().getStyle().setProperty("flex", "1"); + headerContainer.add(spacer); + + // Right section: Action buttons using App Inventor button style + HorizontalPanel rightSection = new HorizontalPanel(); + rightSection.setVerticalAlignment(HasVerticalAlignment.ALIGN_MIDDLE); + rightSection.getElement().getStyle().setProperty("gap", "12px"); + rightSection.getElement().getStyle().setProperty("display", "flex"); + rightSection.getElement().getStyle().setProperty("alignItems", "center"); + + // Bulk action buttons with improved sizing + addSelectedButton = new Button("Add Selected"); + addSelectedButton.setStyleName("ode-ProjectListButton"); + addSelectedButton.setEnabled(false); + addSelectedButton.getElement().getStyle().setProperty("height", "36px"); + addSelectedButton.getElement().getStyle().setProperty("fontSize", "14px"); + addSelectedButton.getElement().getStyle().setProperty("marginRight", "8px"); + rightSection.add(addSelectedButton); + + deleteSelectedButton = new Button("Delete Selected"); + deleteSelectedButton.setStyleName("ode-ProjectListButton"); + deleteSelectedButton.getElement().getStyle().setProperty("height", "36px"); + deleteSelectedButton.getElement().getStyle().setProperty("fontSize", "14px"); + deleteSelectedButton.getElement().getStyle().setProperty("marginRight", "8px"); + deleteSelectedButton.setEnabled(false); + rightSection.add(deleteSelectedButton); + + // Upload button + uploadButton = new Button("⬆ Upload Asset"); + uploadButton.setStyleName("ode-ProjectListButton"); + uploadButton.getElement().getStyle().setProperty("height", "36px"); + uploadButton.getElement().getStyle().setProperty("fontSize", "14px"); + uploadButton.getElement().getStyle().setProperty("marginRight", "8px"); + rightSection.add(uploadButton); + + // Close button + closeButton = new Button("✕"); + closeButton.setTitle("Close Asset Library"); + closeButton.setStyleName("ode-ProjectListButton"); + closeButton.getElement().getStyle().setProperty("height", "36px"); + closeButton.getElement().getStyle().setProperty("fontSize", "16px"); + closeButton.getElement().getStyle().setProperty("minWidth", "36px"); + rightSection.add(closeButton); + + headerContainer.add(rightSection); + rootPanel.add(headerContainer); + } + + private void createMainContent() { + // Main content area - takes remaining space after header/footer + mainContentPanel = new HorizontalPanel(); + mainContentPanel.setSize("100%", "100%"); + mainContentPanel.setStyleName("ode-WorkColumns"); + mainContentPanel.getElement().getStyle().setProperty("flex", "1 1 auto"); + mainContentPanel.getElement().getStyle().setProperty("minHeight", "0"); + mainContentPanel.getElement().getStyle().setProperty("display", "flex"); + mainContentPanel.getElement().getStyle().setProperty("overflow", "hidden"); + mainContentPanel.getElement().getStyle().setProperty("height", "100%"); + mainContentPanel.getElement().getStyle().setProperty("boxSizing", "border-box"); + mainContentPanel.getElement().getStyle().setProperty("backgroundColor", "#f9f9f9"); + + createSidebar(); + createAssetGrid(); + + rootPanel.add(mainContentPanel); + } + + private void createSidebar() { + // Sidebar matching App Inventor design with improved spacing + sidebarPanel = new VerticalPanel(); + sidebarPanel.setWidth("280px"); + sidebarPanel.setHeight("100%"); + sidebarPanel.setStyleName("ode-Designer-LeftColumn"); + sidebarPanel.getElement().getStyle().setProperty("padding", "20px 16px"); + sidebarPanel.getElement().getStyle().setProperty("borderRight", "1px solid #e0e0e0"); + sidebarPanel.getElement().getStyle().setProperty("flexShrink", "0"); + sidebarPanel.getElement().getStyle().setProperty("backgroundColor", "white"); + sidebarPanel.getElement().getStyle().setProperty("height", "100vh"); + sidebarPanel.getElement().getStyle().setProperty("minHeight", "100vh"); + sidebarPanel.getElement().getStyle().setProperty("maxHeight", "100vh"); + sidebarPanel.getElement().getStyle().setProperty("boxSizing", "border-box"); + sidebarPanel.getElement().getStyle().setProperty("display", "flex"); + sidebarPanel.getElement().getStyle().setProperty("flexDirection", "column"); + sidebarPanel.getElement().getStyle().setProperty("alignItems", "stretch"); + sidebarPanel.getElement().getStyle().setProperty("justifyContent", "flex-start"); + sidebarPanel.getElement().getStyle().setProperty("overflowY", "auto"); + + // Folder section header + HorizontalPanel folderHeader = new HorizontalPanel(); + folderHeader.setWidth("100%"); + folderHeader.setVerticalAlignment(HasVerticalAlignment.ALIGN_MIDDLE); + folderHeader.getElement().getStyle().setProperty("flexShrink", "0"); + folderHeader.getElement().getStyle().setProperty("marginBottom", "8px"); + folderHeader.getElement().getStyle().setProperty("display", "flex"); + folderHeader.getElement().getStyle().setProperty("justifyContent", "space-between"); + folderHeader.getElement().getStyle().setProperty("alignItems", "center"); + folderHeader.getElement().getStyle().setProperty("width", "100%"); + + Label folderTitle = new Label("📁 Folders"); + folderTitle.setStyleName("ode-ComponentRowLabel"); + folderTitle.getElement().getStyle().setProperty("fontSize", "16px"); + folderTitle.getElement().getStyle().setProperty("fontWeight", "600"); + folderHeader.add(folderTitle); + + // Small action buttons with proper spacing + HorizontalPanel folderActions = new HorizontalPanel(); + folderActions.setSpacing(6); + folderActions.getElement().getStyle().setProperty("marginLeft", "50px"); + + Button newFolderBtn = createSmallButton("+", "New Folder"); + Button renameFolderBtn = createSmallButton("✎", "Rename Folder"); + Button deleteFolderBtn = createSmallButton("🗑", "Delete Folder"); + + // Add event handlers for folder management + setupFolderManagementHandlers(newFolderBtn, renameFolderBtn, deleteFolderBtn); + + folderActions.add(newFolderBtn); + folderActions.add(renameFolderBtn); + folderActions.add(deleteFolderBtn); + folderHeader.add(folderActions); + + sidebarPanel.add(folderHeader); + + // Folder list + folderListPanel = new VerticalPanel(); + folderListPanel.setWidth("100%"); + folderListPanel.getElement().getStyle().setProperty("flex", "1 1 auto"); + folderListPanel.getElement().getStyle().setProperty("overflowY", "auto"); + folderListPanel.getElement().getStyle().setProperty("minHeight", "0"); + folderListPanel.getElement().getStyle().setProperty("width", "100%"); + folderListPanel.getElement().getStyle().setProperty("paddingTop", "4px"); + sidebarPanel.add(folderListPanel); + + mainContentPanel.add(sidebarPanel); + } + + private Button createSmallButton(String text, String title) { + Button button = new Button(text); + button.setTitle(title); + button.setStyleName("ode-ProjectListButton"); + button.getElement().getStyle().setProperty("padding", "4px 8px"); + button.getElement().getStyle().setProperty("fontSize", "12px"); + button.getElement().getStyle().setProperty("minWidth", "28px"); + button.getElement().getStyle().setProperty("height", "28px"); + return button; + } + + private void createAssetGrid() { + // Asset grid container with improved spacing for full screen utilization + VerticalPanel assetContainer = new VerticalPanel(); + assetContainer.setWidth("100%"); + assetContainer.setHeight("100%"); + assetContainer.setStyleName("ode-Box-body-padding"); + assetContainer.getElement().getStyle().setProperty("flex", "1 1 auto"); + assetContainer.getElement().getStyle().setProperty("padding", "0"); + assetContainer.getElement().getStyle().setProperty("display", "flex"); + assetContainer.getElement().getStyle().setProperty("flexDirection", "column"); + assetContainer.getElement().getStyle().setProperty("boxSizing", "border-box"); + assetContainer.getElement().getStyle().setProperty("overflow", "hidden"); + assetContainer.getElement().getStyle().setProperty("minWidth", "0"); + assetContainer.getElement().getStyle().setProperty("flexGrow", "1"); + assetContainer.getElement().getStyle().setProperty("backgroundColor", "#f9f9f9"); + + // Scrollable grid with optimized full height utilization + assetScrollPanel = new ScrollPanel(); + assetScrollPanel.setWidth("100%"); + assetScrollPanel.getElement().getStyle().setProperty("border", "none"); + assetScrollPanel.getElement().getStyle().setProperty("flex", "1 1 auto"); + assetScrollPanel.getElement().getStyle().setProperty("overflowY", "auto"); + assetScrollPanel.getElement().getStyle().setProperty("overflowX", "hidden"); + assetScrollPanel.getElement().getStyle().setProperty("backgroundColor", "#f9f9f9"); + assetScrollPanel.getElement().getStyle().setProperty("height", "auto"); + assetScrollPanel.getElement().getStyle().setProperty("maxHeight", "calc(100vh - 140px)"); + + // Optimized CSS Grid for maximum screen utilization with responsive columns + assetGridPanel = new FlowPanel(); + assetGridPanel.setWidth("100%"); + assetGridPanel.getElement().getStyle().setProperty("display", "grid"); + assetGridPanel.getElement().getStyle().setProperty("gridTemplateColumns", "repeat(auto-fill, minmax(280px, 1fr))"); + assetGridPanel.getElement().getStyle().setProperty("gap", "20px"); + assetGridPanel.getElement().getStyle().setProperty("padding", "20px"); + assetGridPanel.getElement().getStyle().setProperty("alignContent", "start"); + assetGridPanel.getElement().getStyle().setProperty("justifyItems", "stretch"); + assetGridPanel.getElement().getStyle().setProperty("alignItems", "start"); + assetGridPanel.getElement().getStyle().setProperty("backgroundColor", "#f9f9f9"); + assetGridPanel.getElement().getStyle().setProperty("boxSizing", "border-box"); + assetGridPanel.getElement().getStyle().setProperty("gridAutoRows", "max-content"); + assetGridPanel.getElement().getStyle().setProperty("width", "100%"); + assetGridPanel.getElement().getStyle().setProperty("minHeight", "auto"); + + // Add responsive behavior for smaller screens + addResponsiveStyles(); + + assetScrollPanel.add(assetGridPanel); + assetContainer.add(assetScrollPanel); + mainContentPanel.add(assetContainer); + } + + private void addResponsiveStyles() { + // Add responsive adjustments for different screen sizes + int screenWidth = com.google.gwt.user.client.Window.getClientWidth(); + + if (screenWidth < 768) { + // Smaller screens: 2 columns + assetGridPanel.getElement().getStyle().setProperty("gridTemplateColumns", "repeat(2, 1fr)"); + sidebarPanel.setWidth("200px"); + assetGridPanel.getElement().getStyle().setProperty("gap", "16px"); + assetGridPanel.getElement().getStyle().setProperty("padding", "16px"); + } else if (screenWidth < 1200) { + // Medium screens: 3-4 columns + assetGridPanel.getElement().getStyle().setProperty("gridTemplateColumns", "repeat(3, 1fr)"); + assetGridPanel.getElement().getStyle().setProperty("gap", "20px"); + } else if (screenWidth < 1600) { + // Large screens: 4-5 columns + assetGridPanel.getElement().getStyle().setProperty("gridTemplateColumns", "repeat(4, 1fr)"); + assetGridPanel.getElement().getStyle().setProperty("gap", "24px"); + } else { + // Extra large screens: 5+ columns for maximum utilization + assetGridPanel.getElement().getStyle().setProperty("gridTemplateColumns", "repeat(5, 1fr)"); + assetGridPanel.getElement().getStyle().setProperty("gap", "28px"); + } + } + + private void createFooter() { + // Footer matching App Inventor style with improved spacing + footerPanel = new HorizontalPanel(); + footerPanel.setWidth("100%"); + footerPanel.setStyleName("ode-StatusPanel"); + footerPanel.setVerticalAlignment(HasVerticalAlignment.ALIGN_MIDDLE); + footerPanel.getElement().getStyle().setProperty("padding", "8px 24px"); + footerPanel.getElement().getStyle().setProperty("minHeight", "36px"); + footerPanel.getElement().getStyle().setProperty("borderTop", "1px solid #e0e0e0"); + footerPanel.getElement().getStyle().setProperty("flexShrink", "0"); + footerPanel.getElement().getStyle().setProperty("backgroundColor", "white"); + footerPanel.getElement().getStyle().setProperty("boxSizing", "border-box"); + + statusLabel = new Label("Loading assets..."); + statusLabel.setStyleName("ode-StatusPanelLabel"); + statusLabel.getElement().getStyle().setProperty("fontSize", "14px"); + statusLabel.getElement().getStyle().setProperty("fontWeight", "500"); + footerPanel.add(statusLabel); + + rootPanel.add(footerPanel); + } + + private void setupEventHandlers() { + searchBox.addKeyUpHandler(new KeyUpHandler() { + @Override + public void onKeyUp(KeyUpEvent event) { + refreshAssetList(); + } + }); + + typeFilter.addChangeHandler(new ChangeHandler() { + @Override + public void onChange(ChangeEvent event) { + refreshAssetList(); + } + }); + + uploadButton.addClickHandler(new ClickHandler() { + @Override + public void onClick(ClickEvent event) { + showUploadDialog(); + } + }); + + closeButton.addClickHandler(new ClickHandler() { + @Override + public void onClick(ClickEvent event) { + // Get the current project and switch back to the editor + long currentProjectId = Ode.getInstance().getCurrentYoungAndroidProjectId(); + if (currentProjectId != 0) { + Project currentProject = Ode.getInstance().getProjectManager().getProject(currentProjectId); + if (currentProject != null) { + Ode.getInstance().openYoungAndroidProjectInDesigner(currentProject); + } else { + // Fallback to projects view if project not found + Ode.getInstance().switchToProjectsView(); + } + } else { + // Fallback to projects view if no current project + Ode.getInstance().switchToProjectsView(); + } + } + }); + + setupBulkActionHandlers(); + } + + private void setupBulkActionHandlers() { + addSelectedButton.addClickHandler(new ClickHandler() { + @Override + public void onClick(ClickEvent event) { + List selectedAssets = getSelectedAssets(); + if (!selectedAssets.isEmpty()) { + showAddToProjectDialog(selectedAssets); + } + } + }); + + deleteSelectedButton.addClickHandler(new ClickHandler() { + @Override + public void onClick(ClickEvent event) { + List selectedAssets = getSelectedAssets(); + if (!selectedAssets.isEmpty() && + Window.confirm("Are you sure you want to delete " + selectedAssets.size() + " selected asset(s)?")) { + deleteSelectedAssets(selectedAssets); + } + } + }); + } + + private void setupFolderManagementHandlers(Button newFolderBtn, Button renameFolderBtn, Button deleteFolderBtn) { + newFolderBtn.addClickHandler(new ClickHandler() { + @Override + public void onClick(ClickEvent event) { + showNewFolderDialog(); + } + }); + + renameFolderBtn.addClickHandler(new ClickHandler() { + @Override + public void onClick(ClickEvent event) { + String selectedFolder = selectedFolderIndex >= 0 && selectedFolderIndex < folders.size() + ? folders.get(selectedFolderIndex) : null; + if (selectedFolder != null && !isSpecialFolder(selectedFolder)) { + showRenameFolderDialog(selectedFolder); + } else { + Window.alert("Please select a regular folder to rename. Special folders cannot be renamed."); + } + } + }); + + deleteFolderBtn.addClickHandler(new ClickHandler() { + @Override + public void onClick(ClickEvent event) { + String selectedFolder = selectedFolderIndex >= 0 && selectedFolderIndex < folders.size() + ? folders.get(selectedFolderIndex) : null; + if (selectedFolder != null && !isSpecialFolder(selectedFolder)) { + showDeleteFolderDialog(selectedFolder); + } else { + Window.alert("Please select a regular folder to delete. Special folders cannot be deleted."); + } + } + }); + } + + private boolean isSpecialFolder(String folderName) { + return "All Assets".equals(folderName) || "Recent".equals(folderName); + } + + private void injectFolderHoverCSS() { + // Inject CSS to prevent flickering hover effects + String css = ".folder-hoverable:hover { " + + " background-color: #f5f5f5 !important; " + + " border-radius: 4px !important; " + + " transform: translateX(2px) !important; " + + " transition: all 0.15s ease !important; " + + "} "; + + com.google.gwt.dom.client.StyleElement style = com.google.gwt.dom.client.Document.get().createStyleElement(); + style.setInnerText(css); + com.google.gwt.dom.client.Document.get().getHead().appendChild(style); + } + + private void loadInitialData() { + // Initialize with default folders + folders.clear(); + folders.add("All Assets"); + selectedFolderIndex = 0; + + updateFolderList(); + refreshGlobalAssets(); + } + + private void updateFolderList() { + folderListPanel.clear(); + + for (int i = 0; i < folders.size(); i++) { + final int index = i; + HorizontalPanel folderRow = createFolderRow(folders.get(i), index); + folderListPanel.add(folderRow); + } + } + + private HorizontalPanel createFolderRow(String folderName, int index) { + HorizontalPanel folderRow = new HorizontalPanel(); + folderRow.setWidth("100%"); + folderRow.setVerticalAlignment(HasVerticalAlignment.ALIGN_MIDDLE); + folderRow.getElement().getStyle().setProperty("padding", "10px 0px"); + folderRow.getElement().getStyle().setProperty("borderRadius", "4px"); + folderRow.getElement().getStyle().setProperty("cursor", "pointer"); + folderRow.getElement().getStyle().setProperty("marginBottom", "6px"); + folderRow.getElement().getStyle().setProperty("minHeight", "36px"); + folderRow.getElement().getStyle().setProperty("boxSizing", "border-box"); + + // Folder icon + Label icon = new Label("📁"); + icon.getElement().getStyle().setProperty("marginLeft", "12px"); + icon.getElement().getStyle().setProperty("marginRight", "10px"); + icon.getElement().getStyle().setProperty("fontSize", "16px"); + folderRow.add(icon); + + // Folder name + Label nameLabel = new Label(folderName); + nameLabel.getElement().getStyle().setProperty("fontSize", "14px"); + nameLabel.getElement().getStyle().setProperty("fontWeight", "500"); + folderRow.add(nameLabel); + + // Apply selection styling matching App Inventor + if (index == selectedFolderIndex) { + folderRow.setStyleName("ode-ComponentRowHighlighted"); + nameLabel.setStyleName("ode-ComponentRowLabel"); + } else { + folderRow.setStyleName("ode-ComponentRowUnHighlighted"); + nameLabel.setStyleName("ode-ComponentRowLabel"); + + // Add hover class for CSS-based hover effects (no flickering) + folderRow.addStyleName("folder-hoverable"); + } + + // Click handler + folderRow.addDomHandler(new com.google.gwt.event.dom.client.ClickHandler() { + @Override + public void onClick(com.google.gwt.event.dom.client.ClickEvent event) { + selectedFolderIndex = index; + updateFolderList(); + refreshAssetList(); + } + }, com.google.gwt.event.dom.client.ClickEvent.getType()); + + // Drag and drop support + setupFolderDragDrop(folderRow, index); + + return folderRow; + } + + private void setupFolderDragDrop(HorizontalPanel folderRow, int folderIndex) { + folderRow.addDomHandler(new DragOverHandler() { + @Override + public void onDragOver(DragOverEvent event) { + event.preventDefault(); + folderRow.addStyleName("ode-ComponentRowHighlighted"); + } + }, DragOverEvent.getType()); + + folderRow.addDomHandler(new DropHandler() { + @Override + public void onDrop(DropEvent event) { + event.preventDefault(); + if (folderIndex != selectedFolderIndex) { + folderRow.removeStyleName("ode-ComponentRowHighlighted"); + } + + if (draggedAssetName != null) { + String folderName = folders.get(folderIndex); + moveAssetToFolder(draggedAssetName, folderName); + draggedAssetName = null; + } + } + }, DropEvent.getType()); + } + + private void refreshGlobalAssets() { + statusLabel.setText("Loading assets..."); + globalAssetService.getGlobalAssets(new AsyncCallback>() { + @Override + public void onSuccess(List assets) { + globalAssets.clear(); + if (assets != null) { + globalAssets.addAll(assets); + } + updateFoldersFromAssets(); + refreshAssetList(); + } + + @Override + public void onFailure(Throwable caught) { + statusLabel.setText("Failed to load assets: " + caught.getMessage()); + globalAssets.clear(); + refreshAssetList(); + } + }); + } + + private void updateFoldersFromAssets() { + // Preserve current selection + String currentFolder = selectedFolderIndex >= 0 && selectedFolderIndex < folders.size() + ? folders.get(selectedFolderIndex) : "All Assets"; + + // Clear and rebuild folders + folders.clear(); + folders.add("All Assets"); + + // Add unique folders from assets + Set uniqueFolders = new HashSet<>(); + for (GlobalAsset asset : globalAssets) { + String folder = asset.getFolder(); + if (folder != null && !folder.isEmpty() && !folder.equals("")) { + uniqueFolders.add(folder); + } + } + + // Add folders in alphabetical order + List sortedFolders = new ArrayList<>(uniqueFolders); + Collections.sort(sortedFolders); + folders.addAll(sortedFolders); + + // Add virtual folders (only Recent, since Images/Sounds are in type dropdown) + folders.add("Recent"); + + // Restore selection or default to "All Assets" + selectedFolderIndex = 0; + for (int i = 0; i < folders.size(); i++) { + if (folders.get(i).equals(currentFolder)) { + selectedFolderIndex = i; + break; + } + } + + updateFolderList(); + } + + private void refreshAssetList() { + assetGridPanel.clear(); + assetCheckBoxes.clear(); + + String searchText = searchBox.getText().toLowerCase(); + String typeFilter = this.typeFilter.getSelectedValue(); + String folderFilter = selectedFolderIndex >= 0 && selectedFolderIndex < folders.size() + ? folders.get(selectedFolderIndex) : "All Assets"; + List filteredAssets = filterAssets(searchText, typeFilter, folderFilter); + + if (filteredAssets.isEmpty()) { + showEmptyState(); + } else { + displayAssets(filteredAssets); + } + + updateBulkActionButtons(); + updateStatusLabel(filteredAssets.size()); + } + + private List filterAssets(String searchText, String typeFilter, String folderFilter) { + List filtered = new ArrayList<>(); + + for (GlobalAsset asset : globalAssets) { + boolean nameMatch = asset.getFileName().toLowerCase().contains(searchText); + boolean typeMatch = matchesTypeFilter(asset, typeFilter); + boolean folderMatch = matchesFolderFilter(asset, folderFilter); + + if (nameMatch && typeMatch && folderMatch) { + filtered.add(asset); + } + } + + return filtered; + } + + private boolean matchesFolderFilter(GlobalAsset asset, String folderFilter) { + if ("All Assets".equals(folderFilter)) { + return true; + } else if ("Recent".equals(folderFilter)) { + // Show assets modified in the last 7 days + long weekAgo = System.currentTimeMillis() - (7 * 24 * 60 * 60 * 1000); + return asset.getTimestamp() > weekAgo; + } else { + // Regular folder match + String assetFolder = asset.getFolder(); + if (assetFolder == null) assetFolder = ""; + return folderFilter.equals(assetFolder); + } + } + + private boolean matchesTypeFilter(GlobalAsset asset, String typeFilter) { + if ("All Types".equals(typeFilter)) return true; + + String fileName = asset.getFileName().toLowerCase(); + if ("Images".equals(typeFilter)) { + return fileName.endsWith(".png") || fileName.endsWith(".jpg") || fileName.endsWith(".jpeg") || fileName.endsWith(".gif"); + } else if ("Sounds".equals(typeFilter)) { + return fileName.endsWith(".mp3") || fileName.endsWith(".wav") || fileName.endsWith(".ogg"); + } + + return false; + } + + private void showEmptyState() { + VerticalPanel emptyState = new VerticalPanel(); + emptyState.setWidth("100%"); + emptyState.setHorizontalAlignment(HasHorizontalAlignment.ALIGN_CENTER); + emptyState.setVerticalAlignment(HasVerticalAlignment.ALIGN_MIDDLE); + emptyState.getElement().getStyle().setProperty("padding", "60px 20px"); + emptyState.getElement().getStyle().setProperty("textAlign", "center"); + emptyState.getElement().getStyle().setProperty("gridColumn", "1 / -1"); + emptyState.getElement().getStyle().setProperty("display", "flex"); + emptyState.getElement().getStyle().setProperty("flexDirection", "column"); + emptyState.getElement().getStyle().setProperty("justifyContent", "center"); + emptyState.getElement().getStyle().setProperty("minHeight", "400px"); + emptyState.getElement().getStyle().setProperty("alignItems", "center"); + + // Empty icon + Label emptyIcon = new Label("📁"); + emptyIcon.getElement().getStyle().setProperty("fontSize", "64px"); + emptyIcon.getElement().getStyle().setProperty("opacity", "0.4"); + emptyIcon.getElement().getStyle().setProperty("marginBottom", "16px"); + emptyState.add(emptyIcon); + + // Message + Label emptyMessage = new Label("No assets found"); + emptyMessage.setStyleName("ode-ComponentRowLabel"); + emptyMessage.getElement().getStyle().setProperty("fontSize", "18px"); + emptyMessage.getElement().getStyle().setProperty("fontWeight", "500"); + emptyMessage.getElement().getStyle().setProperty("marginBottom", "8px"); + emptyState.add(emptyMessage); + + // Sub message + Label emptySubMessage = new Label("Try adjusting your search or upload new assets"); + emptySubMessage.setStyleName("ode-ComponentRowLabel"); + emptySubMessage.getElement().getStyle().setProperty("fontSize", "14px"); + emptySubMessage.getElement().getStyle().setProperty("opacity", "0.7"); + emptyState.add(emptySubMessage); + + assetGridPanel.add(emptyState); + } + + private void displayAssets(List assets) { + for (final GlobalAsset asset : assets) { + VerticalPanel assetCard = createAssetCard(asset); + assetGridPanel.add(assetCard); + } + } + + private VerticalPanel createAssetCard(final GlobalAsset asset) { + VerticalPanel card = new VerticalPanel(); + card.setStyleName("ode-Box"); + card.setHorizontalAlignment(HasHorizontalAlignment.ALIGN_CENTER); + card.getElement().getStyle().setProperty("border", "1px solid #e0e0e0"); + card.getElement().getStyle().setProperty("borderRadius", "8px"); + card.getElement().getStyle().setProperty("padding", "16px"); + card.getElement().getStyle().setProperty("backgroundColor", "white"); + card.getElement().getStyle().setProperty("cursor", "pointer"); + card.getElement().getStyle().setProperty("transition", "all 0.2s ease"); + card.getElement().getStyle().setProperty("textAlign", "center"); + card.getElement().getStyle().setProperty("minHeight", "280px"); + card.getElement().getStyle().setProperty("maxHeight", "320px"); + card.getElement().getStyle().setProperty("width", "100%"); + card.getElement().getStyle().setProperty("boxSizing", "border-box"); + card.getElement().getStyle().setProperty("display", "flex"); + card.getElement().getStyle().setProperty("flexDirection", "column"); + card.getElement().getStyle().setProperty("justifyContent", "space-between"); + + // Hover effect + card.addDomHandler(new com.google.gwt.event.dom.client.MouseOverHandler() { + @Override + public void onMouseOver(com.google.gwt.event.dom.client.MouseOverEvent event) { + card.getElement().getStyle().setProperty("boxShadow", "0 2px 4px rgba(0,0,0,0.1)"); + card.getElement().getStyle().setProperty("borderColor", "#ccc"); + } + }, com.google.gwt.event.dom.client.MouseOverEvent.getType()); + + card.addDomHandler(new com.google.gwt.event.dom.client.MouseOutHandler() { + @Override + public void onMouseOut(com.google.gwt.event.dom.client.MouseOutEvent event) { + card.getElement().getStyle().setProperty("boxShadow", "none"); + card.getElement().getStyle().setProperty("borderColor", "#e0e0e0"); + } + }, com.google.gwt.event.dom.client.MouseOutEvent.getType()); + + // Checkbox for selection + final CheckBox checkBox = new CheckBox(); + checkBox.getElement().getStyle().setProperty("marginBottom", "8px"); + assetCheckBoxes.add(checkBox); + card.add(checkBox); + + // Asset preview/icon + String filePath = asset.getFolder() + "/" + asset.getFileName(); + String fileName = asset.getFileName().toLowerCase(); + + // Create preview widget + Widget previewWidget = createPreviewWidget(asset); + previewWidget.getElement().getStyle().setProperty("marginBottom", "8px"); + card.add(previewWidget); + + // Asset name with truncation + String displayName = asset.getFileName(); + if (displayName.length() > 20) { + displayName = displayName.substring(0, 17) + "..."; + } + + Label nameLabel = new Label(displayName); + nameLabel.setTitle(asset.getFileName()); // Full name on hover + nameLabel.setStyleName("ode-ComponentRowLabel"); + nameLabel.getElement().getStyle().setProperty("fontSize", "14px"); + nameLabel.getElement().getStyle().setProperty("fontWeight", "600"); + nameLabel.getElement().getStyle().setProperty("marginTop", "8px"); + nameLabel.getElement().getStyle().setProperty("lineHeight", "1.2"); + nameLabel.getElement().getStyle().setProperty("textAlign", "center"); + nameLabel.getElement().getStyle().setProperty("maxWidth", "180px"); + nameLabel.getElement().getStyle().setProperty("wordBreak", "break-word"); + nameLabel.getElement().getStyle().setProperty("marginBottom", "4px"); + nameLabel.getElement().getStyle().setProperty("textAlign", "center"); + nameLabel.getElement().getStyle().setProperty("wordBreak", "break-all"); + card.add(nameLabel); + + // Asset date + Label dateLabel = new Label(formatDate(asset.getTimestamp())); + dateLabel.setStyleName("ode-ComponentRowLabel"); + dateLabel.getElement().getStyle().setProperty("fontSize", "10px"); + dateLabel.getElement().getStyle().setProperty("opacity", "0.7"); + dateLabel.getElement().getStyle().setProperty("marginBottom", "8px"); + card.add(dateLabel); + + // Version indicator and project usage + HorizontalPanel versionPanel = new HorizontalPanel(); + versionPanel.setHorizontalAlignment(HasHorizontalAlignment.ALIGN_CENTER); + versionPanel.getElement().getStyle().setProperty("marginTop", "4px"); + versionPanel.getElement().getStyle().setProperty("marginBottom", "8px"); + + Label lastUpdated = new Label("Updated: " + formatDate(asset.getTimestamp())); + lastUpdated.setStyleName("ode-ComponentRowLabel"); + lastUpdated.getElement().getStyle().setProperty("fontSize", "9px"); + lastUpdated.getElement().getStyle().setProperty("backgroundColor", "#f5f5f5"); + lastUpdated.getElement().getStyle().setProperty("color", "#666"); + lastUpdated.getElement().getStyle().setProperty("padding", "2px 6px"); + lastUpdated.getElement().getStyle().setProperty("borderRadius", "4px"); + lastUpdated.getElement().getStyle().setProperty("fontWeight", "400"); + versionPanel.add(lastUpdated); + + card.add(versionPanel); + + // Project usage indicator with async loading + final Label usageIndicator = new Label("Checking usage..."); + usageIndicator.setStyleName("ode-ComponentRowLabel"); + usageIndicator.getElement().getStyle().setProperty("fontSize", "10px"); + usageIndicator.getElement().getStyle().setProperty("color", "#6c757d"); + usageIndicator.getElement().getStyle().setProperty("textAlign", "center"); + usageIndicator.getElement().getStyle().setProperty("marginBottom", "8px"); + usageIndicator.getElement().getStyle().setProperty("fontWeight", "400"); + card.add(usageIndicator); + + // Load project usage asynchronously + globalAssetService.getProjectsUsingAsset(asset.getFileName(), new AsyncCallback>() { + @Override + public void onSuccess(List projectIds) { + if (projectIds != null && !projectIds.isEmpty()) { + usageIndicator.setText("Used by " + projectIds.size() + " project" + (projectIds.size() == 1 ? "" : "s")); + usageIndicator.getElement().getStyle().setProperty("color", "#007bff"); + usageIndicator.setTitle("This asset is linked to " + projectIds.size() + " project(s)"); + } else { + usageIndicator.setText("Not in use"); + usageIndicator.getElement().getStyle().setProperty("color", "#6c757d"); + usageIndicator.setTitle("This asset is not currently used by any projects"); + } + } + + @Override + public void onFailure(Throwable caught) { + usageIndicator.setText("Unknown usage"); + usageIndicator.getElement().getStyle().setProperty("color", "#dc3545"); + } + }); + + // Action buttons with improved spacing + HorizontalPanel actionPanel = new HorizontalPanel(); + actionPanel.setHorizontalAlignment(HasHorizontalAlignment.ALIGN_CENTER); + actionPanel.getElement().getStyle().setProperty("gap", "4px"); + actionPanel.getElement().getStyle().setProperty("marginTop", "4px"); + + // Preview button + Button previewBtn = new Button("👁"); + previewBtn.setTitle("Preview Asset"); + previewBtn.setStyleName("ode-ProjectListButton"); + previewBtn.getElement().getStyle().setProperty("padding", "3px 6px"); + previewBtn.getElement().getStyle().setProperty("fontSize", "10px"); + previewBtn.addClickHandler(new ClickHandler() { + @Override + public void onClick(ClickEvent event) { + event.stopPropagation(); + previewAsset(asset); + } + }); + actionPanel.add(previewBtn); + + + // Update button + Button updateBtn = new Button("⬆"); + updateBtn.setTitle("Upload New Version"); + updateBtn.setStyleName("ode-ProjectListButton"); + updateBtn.getElement().getStyle().setProperty("padding", "3px 6px"); + updateBtn.getElement().getStyle().setProperty("fontSize", "10px"); + updateBtn.addClickHandler(new ClickHandler() { + @Override + public void onClick(ClickEvent event) { + event.stopPropagation(); + showUpdateAssetDialog(asset); + } + }); + actionPanel.add(updateBtn); + + // Add button + Button addBtn = new Button("Add"); + addBtn.setStyleName("ode-ProjectListButton"); + addBtn.getElement().getStyle().setProperty("padding", "3px 6px"); + addBtn.getElement().getStyle().setProperty("fontSize", "10px"); + addBtn.addClickHandler(new ClickHandler() { + @Override + public void onClick(ClickEvent event) { + event.stopPropagation(); + showAddToProjectDialog(java.util.Arrays.asList(asset)); + } + }); + actionPanel.add(addBtn); + + // Delete button + Button deleteBtn = new Button("🗑"); + deleteBtn.setTitle("Delete Asset"); + deleteBtn.setStyleName("ode-ProjectListButton"); + deleteBtn.getElement().getStyle().setProperty("padding", "3px 6px"); + deleteBtn.getElement().getStyle().setProperty("fontSize", "10px"); + deleteBtn.addClickHandler(new ClickHandler() { + @Override + public void onClick(ClickEvent event) { + event.stopPropagation(); + if (Window.confirm("Are you sure you want to delete '" + asset.getFileName() + "'?")) { + deleteAsset(asset); + } + } + }); + actionPanel.add(deleteBtn); + + card.add(actionPanel); + + // Drag and drop support + setupAssetDragDrop(card, asset); + + // Checkbox change handler + checkBox.addClickHandler(new ClickHandler() { + @Override + public void onClick(ClickEvent event) { + event.stopPropagation(); + updateBulkActionButtons(); + } + }); + + return card; + } + + private Widget createPreviewWidget(GlobalAsset asset) { + String fileName = asset.getFileName().toLowerCase(); + String filePath = asset.getFolder() + "/" + asset.getFileName(); + + if (StorageUtil.isImageFile(filePath)) { + // Create larger image preview for better visibility + // Add cache-busting timestamp parameter to force refresh when asset is updated + String imageUrl = "/ode/download/globalasset/" + asset.getFileName() + "?t=" + asset.getTimestamp(); + Image img = new Image(imageUrl); + img.setWidth("100px"); + img.setHeight("100px"); + img.getElement().getStyle().setProperty("objectFit", "cover"); + img.getElement().getStyle().setProperty("border", "1px solid #ddd"); + img.getElement().getStyle().setProperty("borderRadius", "4px"); + img.getElement().getStyle().setProperty("marginBottom", "12px"); + return img; + } else { + // Use icon for non-image files + ImageResource iconRes; + if (StorageUtil.isAudioFile(filePath)) { + iconRes = ImagesNeo.INSTANCE.player(); + } else if (StorageUtil.isVideoFile(filePath)) { + iconRes = ImagesNeo.INSTANCE.image(); // Use image icon for video for now + } else { + iconRes = ImagesNeo.INSTANCE.image(); + } + + Image icon = new Image(iconRes); + icon.setWidth("64px"); + icon.setHeight("64px"); + icon.getElement().getStyle().setProperty("marginBottom", "12px"); + return icon; + } + } + + private void previewAsset(GlobalAsset asset) { + String fileId = asset.getFileName(); + GlobalAssetProjectNode projectNode = new GlobalAssetProjectNode( + asset.getFileName(), + fileId + ); + + // Use the existing PreviewFileCommand + PreviewFileCommand previewCommand = new PreviewFileCommand(); + if (previewCommand.isSupported(projectNode)) { + previewCommand.execute(projectNode); + } else { + Window.alert("Preview not supported for this file type: " + asset.getFileName()); + } + } + + private void setupAssetDragDrop(VerticalPanel card, GlobalAsset asset) { + card.getElement().setAttribute("draggable", "true"); + + card.addDomHandler(new DragStartHandler() { + @Override + public void onDragStart(DragStartEvent event) { + event.setData("text/plain", asset.getFileName()); + draggedAssetName = asset.getFileName(); + card.getElement().getStyle().setProperty("opacity", "0.5"); + } + }, DragStartEvent.getType()); + } + + private String formatDate(long timestamp) { + java.util.Date date = new java.util.Date(timestamp); + String dateStr = date.toString(); + // Return simplified date format + return dateStr.substring(4, 10); // "MMM dd" + } + + private List getSelectedAssets() { + List selected = new ArrayList<>(); + String searchText = searchBox.getText().toLowerCase(); + String typeFilter = this.typeFilter.getSelectedValue(); + String folderFilter = selectedFolderIndex >= 0 && selectedFolderIndex < folders.size() + ? folders.get(selectedFolderIndex) : "All Assets"; + List filteredAssets = filterAssets(searchText, typeFilter, folderFilter); + + for (int i = 0; i < assetCheckBoxes.size() && i < filteredAssets.size(); i++) { + if (assetCheckBoxes.get(i).getValue()) { + selected.add(filteredAssets.get(i)); + } + } + return selected; + } + + private void updateBulkActionButtons() { + boolean hasSelection = false; + for (CheckBox checkBox : assetCheckBoxes) { + if (checkBox.getValue()) { + hasSelection = true; + break; + } + } + + addSelectedButton.setEnabled(hasSelection); + deleteSelectedButton.setEnabled(hasSelection); + } + + private void updateStatusLabel(int assetCount) { + statusLabel.setText(assetCount + " asset" + (assetCount != 1 ? "s" : "") + " shown"); + } + + // Asset operations + private void deleteAsset(GlobalAsset asset) { + globalAssetService.deleteGlobalAsset(asset.getFileName(), new AsyncCallback() { + @Override + public void onSuccess(Void result) { + refreshGlobalAssets(); + statusLabel.setText("Asset '" + asset.getFileName() + "' deleted successfully"); + } + + @Override + public void onFailure(Throwable caught) { + showDeleteError("Cannot Delete Asset", caught.getMessage()); + } + }); + } + + private void deleteSelectedAssets(List assets) { + for (GlobalAsset asset : assets) { + deleteAsset(asset); + } + } + + private void moveAssetToFolder(String assetName, String folderName) { + globalAssetService.updateGlobalAssetFolder(assetName, folderName, new AsyncCallback() { + @Override + public void onSuccess(Void result) { + refreshGlobalAssets(); + } + + @Override + public void onFailure(Throwable caught) { + Window.alert("Failed to move asset: " + caught.getMessage()); + } + }); + } + + // Dialog methods matching App Inventor style + private void showUploadDialog() { + final DialogBox dialog = new DialogBox(); + dialog.setText("Upload Asset to Library"); + dialog.setStyleName("ode-DialogBox"); + dialog.setModal(true); + dialog.setGlassEnabled(true); + dialog.setAutoHideEnabled(false); + + VerticalPanel dialogPanel = new VerticalPanel(); + dialogPanel.setSpacing(12); + dialogPanel.setWidth("360px"); + dialogPanel.getElement().getStyle().setProperty("padding", "16px"); + + // Upload form + final FormPanel form = new FormPanel(); + form.setEncoding(FormPanel.ENCODING_MULTIPART); + form.setMethod(FormPanel.METHOD_POST); + + VerticalPanel formPanel = new VerticalPanel(); + formPanel.setSpacing(8); + + // File input label + Label fileLabel = new Label("Select file to upload:"); + fileLabel.setStyleName("ode-ComponentRowLabel"); + formPanel.add(fileLabel); + + // File input + final FileUpload fileUpload = new FileUpload(); + fileUpload.setName(ServerLayout.UPLOAD_GLOBAL_ASSET_FORM_ELEMENT); + fileUpload.setStyleName("ode-TextBox"); + fileUpload.getElement().getStyle().setProperty("width", "100%"); + formPanel.add(fileUpload); + + // Error label + final Label errorLabel = new Label(); + errorLabel.getElement().getStyle().setProperty("color", "#d93025"); + errorLabel.getElement().getStyle().setProperty("fontSize", "12px"); + formPanel.add(errorLabel); + + form.setWidget(formPanel); + dialogPanel.add(form); + + // Button panel + HorizontalPanel buttonPanel = new HorizontalPanel(); + buttonPanel.setSpacing(8); + buttonPanel.setHorizontalAlignment(HasHorizontalAlignment.ALIGN_RIGHT); + buttonPanel.setWidth("100%"); + + Button cancelBtn = new Button("Cancel"); + cancelBtn.setStyleName("ode-ProjectListButton"); + + Button uploadBtn = new Button("Upload"); + uploadBtn.setStyleName("ode-ProjectListButton"); + + buttonPanel.add(cancelBtn); + buttonPanel.add(uploadBtn); + dialogPanel.add(buttonPanel); + + dialog.setWidget(dialogPanel); + + // Event handlers + uploadBtn.addClickHandler(new ClickHandler() { + @Override + public void onClick(ClickEvent event) { + String filename = fileUpload.getFilename(); + if (filename == null || filename.isEmpty()) { + errorLabel.setText("Please select a file."); + return; + } + + String actualFilename = filename; + if (filename.contains("\\")) { + actualFilename = filename.substring(filename.lastIndexOf("\\") + 1); + } else if (filename.contains("/")) { + actualFilename = filename.substring(filename.lastIndexOf("/") + 1); + } + + String lower = actualFilename.toLowerCase(); + if (!(lower.endsWith(".png") || lower.endsWith(".jpg") || lower.endsWith(".jpeg") || + lower.endsWith(".gif") || lower.endsWith(".mp3") || lower.endsWith(".wav") || + lower.endsWith(".ogg"))) { + errorLabel.setText("Invalid file type. Supported: PNG, JPG, GIF, MP3, WAV, OGG"); + return; + } + + // Get the currently selected folder + String targetFolder = ""; + if (selectedFolderIndex >= 0 && selectedFolderIndex < folders.size()) { + String selectedFolder = folders.get(selectedFolderIndex); + // Don't use special folders as target folders - use empty string for root + if (!isSpecialFolder(selectedFolder)) { + targetFolder = selectedFolder; + } + } + + // Construct proper upload path: _global_/folder/filename or _global_/filename + String uploadPath = "_global_/"; + if (!targetFolder.isEmpty()) { + uploadPath += targetFolder + "/"; + } + uploadPath += actualFilename; + + form.setAction(GWT.getModuleBaseURL() + "upload/" + ServerLayout.UPLOAD_GLOBAL_ASSET + "/" + uploadPath); + form.submit(); + } + }); + + form.addSubmitCompleteHandler(new SubmitCompleteHandler() { + @Override + public void onSubmitComplete(SubmitCompleteEvent event) { + dialog.hide(); + String results = event.getResults(); + if (results != null && results.contains("SUCCESS")) { + statusLabel.setText("Asset uploaded successfully"); + refreshGlobalAssets(); + } else { + showUploadError(MESSAGES.fileUploadError(), "Please check your file and try again."); + } + } + }); + + cancelBtn.addClickHandler(new ClickHandler() { + @Override + public void onClick(ClickEvent event) { + dialog.hide(); + } + }); + + dialog.center(); + dialog.show(); + } + + private void showAddToProjectDialog(final List assets) { + final DialogBox dialog = new DialogBox(); + boolean isSingle = assets.size() == 1; + dialog.setText(isSingle ? "Add Asset to Project" : "Add " + assets.size() + " Assets to Project"); + dialog.setStyleName("ode-DialogBox"); + dialog.setModal(true); + dialog.setGlassEnabled(true); + dialog.setAutoHideEnabled(false); + + VerticalPanel dialogPanel = new VerticalPanel(); + dialogPanel.setSpacing(12); + dialogPanel.setWidth(isSingle ? "400px" : "440px"); + dialogPanel.getElement().getStyle().setProperty("padding", "16px"); + + // Asset info - different for single vs multiple + if (isSingle) { + // Single asset info + GlobalAsset asset = assets.get(0); + HorizontalPanel assetInfo = new HorizontalPanel(); + assetInfo.setVerticalAlignment(HasVerticalAlignment.ALIGN_MIDDLE); + assetInfo.getElement().getStyle().setProperty("marginBottom", "12px"); + assetInfo.getElement().getStyle().setProperty("padding", "8px"); + assetInfo.getElement().getStyle().setProperty("border", "1px solid #e0e0e0"); + assetInfo.getElement().getStyle().setProperty("borderRadius", "2px"); + + String fileName = asset.getFileName().toLowerCase(); + ImageResource iconRes = fileName.endsWith(".mp3") || fileName.endsWith(".wav") || fileName.endsWith(".ogg") + ? ImagesNeo.INSTANCE.player() + : ImagesNeo.INSTANCE.image(); + + Image assetIcon = new Image(iconRes); + assetIcon.setWidth("24px"); + assetIcon.setHeight("24px"); + assetIcon.getElement().getStyle().setProperty("marginRight", "8px"); + assetInfo.add(assetIcon); + + Label assetName = new Label(asset.getFileName()); + assetName.setStyleName("ode-ComponentRowLabel"); + assetName.getElement().getStyle().setProperty("fontWeight", "500"); + assetInfo.add(assetName); + + dialogPanel.add(assetInfo); + } else { + // Multiple assets list preview + Label assetsLabel = new Label("Selected assets (" + assets.size() + "):"); + assetsLabel.setStyleName("ode-ComponentRowLabel"); + assetsLabel.getElement().getStyle().setProperty("fontWeight", "600"); + assetsLabel.getElement().getStyle().setProperty("marginBottom", "8px"); + dialogPanel.add(assetsLabel); + + // Scrollable list of asset names + ScrollPanel assetListScroll = new ScrollPanel(); + assetListScroll.setHeight("100px"); + assetListScroll.getElement().getStyle().setProperty("border", "1px solid #e0e0e0"); + assetListScroll.getElement().getStyle().setProperty("borderRadius", "2px"); + assetListScroll.getElement().getStyle().setProperty("padding", "8px"); + assetListScroll.getElement().getStyle().setProperty("marginBottom", "12px"); + + VerticalPanel assetList = new VerticalPanel(); + assetList.setWidth("100%"); + for (GlobalAsset asset : assets) { + Label assetName = new Label("• " + asset.getFileName()); + assetName.setStyleName("ode-ComponentRowLabel"); + assetName.getElement().getStyle().setProperty("fontSize", "12px"); + assetName.getElement().getStyle().setProperty("marginBottom", "2px"); + assetList.add(assetName); + } + assetListScroll.add(assetList); + dialogPanel.add(assetListScroll); + } + + // Options + final Label optionsLabel = new Label(isSingle ? "How would you like to add this asset?" : "How would you like to add these assets?"); + optionsLabel.setStyleName("ode-ComponentRowLabel"); + optionsLabel.getElement().getStyle().setProperty("fontWeight", "500"); + optionsLabel.getElement().getStyle().setProperty("marginBottom", "8px"); + dialogPanel.add(optionsLabel); + + // Track option + final VerticalPanel trackOption = new VerticalPanel(); + trackOption.getElement().getStyle().setProperty("padding", "8px"); + trackOption.getElement().getStyle().setProperty("border", "1px solid #e0e0e0"); + trackOption.getElement().getStyle().setProperty("borderRadius", "2px"); + trackOption.getElement().getStyle().setProperty("marginBottom", "6px"); + + final RadioButton trackRadio = new RadioButton("addOption", isSingle ? "Track Asset (Recommended)" : "Track Usage (Recommended)"); + trackRadio.setValue(true); + trackRadio.setStyleName("ode-ComponentRowLabel"); + trackRadio.getElement().getStyle().setProperty("fontWeight", "500"); + trackOption.add(trackRadio); + + Label trackDesc = new Label(isSingle ? "The asset will be updated in your project if the library version changes." + : "Assets will be updated in your project if the library versions change."); + trackDesc.setStyleName("ode-ComponentRowLabel"); + trackDesc.getElement().getStyle().setProperty("fontSize", "12px"); + trackDesc.getElement().getStyle().setProperty("opacity", "0.8"); + trackDesc.getElement().getStyle().setProperty("marginTop", "4px"); + trackOption.add(trackDesc); + + dialogPanel.add(trackOption); + + // Copy option + final VerticalPanel copyOption = new VerticalPanel(); + copyOption.getElement().getStyle().setProperty("padding", "8px"); + copyOption.getElement().getStyle().setProperty("border", "1px solid #e0e0e0"); + copyOption.getElement().getStyle().setProperty("borderRadius", "2px"); + + final RadioButton copyRadio = new RadioButton("addOption", isSingle ? "Copy Asset" : "Copy Assets"); + copyRadio.setStyleName("ode-ComponentRowLabel"); + copyRadio.getElement().getStyle().setProperty("fontWeight", "500"); + copyOption.add(copyRadio); + + Label copyDesc = new Label(isSingle ? "A copy will be added to your project and will not be updated." + : "Copies will be added to your project and will not be updated."); + copyDesc.setStyleName("ode-ComponentRowLabel"); + copyDesc.getElement().getStyle().setProperty("fontSize", "12px"); + copyDesc.getElement().getStyle().setProperty("opacity", "0.8"); + copyDesc.getElement().getStyle().setProperty("marginTop", "4px"); + copyOption.add(copyDesc); + + dialogPanel.add(copyOption); + + // Progress/Status area (for both single and multiple assets) + final VerticalPanel progressPanel = new VerticalPanel(); + progressPanel.setWidth("100%"); + progressPanel.setVisible(false); + progressPanel.getElement().getStyle().setProperty("marginTop", "12px"); + progressPanel.getElement().getStyle().setProperty("padding", "12px"); + progressPanel.getElement().getStyle().setProperty("border", "1px solid #e0e0e0"); + progressPanel.getElement().getStyle().setProperty("borderRadius", "2px"); + progressPanel.getElement().getStyle().setProperty("backgroundColor", "#f9f9f9"); + + final Label progressLabel = new Label(isSingle ? "Processing asset..." : "Processing assets..."); + progressLabel.setStyleName("ode-ComponentRowLabel"); + progressLabel.getElement().getStyle().setProperty("fontSize", "12px"); + progressPanel.add(progressLabel); + dialogPanel.add(progressPanel); + + // Button panel + HorizontalPanel buttonPanel = new HorizontalPanel(); + buttonPanel.setSpacing(8); + buttonPanel.setHorizontalAlignment(HasHorizontalAlignment.ALIGN_RIGHT); + buttonPanel.setWidth("100%"); + buttonPanel.getElement().getStyle().setProperty("marginTop", "12px"); + + Button cancelBtn = new Button("Cancel"); + cancelBtn.setStyleName("ode-ProjectListButton"); + + final Button addBtn = new Button(isSingle ? "Add to Project" : "Add " + assets.size() + " Assets"); + addBtn.setStyleName("ode-ProjectListButton"); + + buttonPanel.add(cancelBtn); + buttonPanel.add(addBtn); + dialogPanel.add(buttonPanel); + + dialog.setWidget(dialogPanel); + + // Event handlers + addBtn.addClickHandler(new ClickHandler() { + @Override + public void onClick(ClickEvent event) { + // If button text is "Close", just close the dialog + if ("Close".equals(addBtn.getText())) { + dialog.hide(); + return; + } + + boolean track = trackRadio.getValue(); + long projectId = Ode.getInstance().getCurrentYoungAndroidProjectId(); + + // Show progress for both single and multiple + progressPanel.setVisible(true); + addBtn.setEnabled(false); + + if (isSingle) { + // Single asset + GlobalAsset asset = assets.get(0); + globalAssetService.importAssetIntoProject(asset.getFileName(), String.valueOf(projectId), track, + new AsyncCallback() { + @Override + public void onSuccess(Void result) { + // Hide options and show success message in main dialog + trackOption.setVisible(false); + copyOption.setVisible(false); + optionsLabel.setVisible(false); + progressPanel.setVisible(false); + + // Add success message directly to main dialog + Label successMsg = new Label("✓ Asset '" + asset.getFileName() + "' added successfully!"); + successMsg.setStyleName("ode-ComponentRowLabel"); + successMsg.getElement().getStyle().setProperty("color", "#2e7d32"); + successMsg.getElement().getStyle().setProperty("fontWeight", "bold"); + successMsg.getElement().getStyle().setProperty("fontSize", "14px"); + successMsg.getElement().getStyle().setProperty("textAlign", "center"); + successMsg.getElement().getStyle().setProperty("padding", "20px"); + dialogPanel.insert(successMsg, dialogPanel.getWidgetIndex(buttonPanel)); + + // Manually create and add the project node for the imported asset + Project project = Ode.getInstance().getProjectManager().getProject(projectId); + YoungAndroidProjectNode projectNode = (YoungAndroidProjectNode) project.getRootNode(); + YoungAndroidAssetsFolder assetsFolder = projectNode.getAssetsFolder(); + + // Create the asset node with the full imported path + String assetName = asset.getFileName(); + String fullAssetPath = "assets/_global_/"; + if (asset.getFolder() != null && !asset.getFolder().isEmpty()) { + fullAssetPath += asset.getFolder() + "/"; + } + fullAssetPath += assetName; + + YoungAndroidAssetNode assetNode = new YoungAndroidAssetNode(assetName, fullAssetPath); + project.addNode(assetsFolder, assetNode); + + // Refresh the project asset list and asset manager to make the asset visible + Ode.getInstance().getAssetListBox().getAssetList().refreshAssetList(projectId); + Ode.getInstance().getAssetManager().loadAssets(projectId); + + statusLabel.setText("Asset '" + asset.getFileName() + "' added successfully!"); + addBtn.setText("Close"); + addBtn.setEnabled(true); + cancelBtn.setVisible(false); + } + + @Override + public void onFailure(Throwable caught) { + progressLabel.setText("⚠ Failed to add asset: " + caught.getMessage()); + progressLabel.getElement().getStyle().setProperty("color", "#d32f2f"); + addBtn.setText("Retry"); + addBtn.setEnabled(true); + } + }); + } else { + // Multiple assets + cancelBtn.setText("Close"); + + // Collect asset filenames + List assetFileNames = new ArrayList<>(); + for (GlobalAsset asset : assets) { + assetFileNames.add(asset.getFileName()); + } + + // Use bulk add method + globalAssetService.bulkAddAssetsToProject(assetFileNames, projectId, track, + new AsyncCallback() { + @Override + public void onSuccess(Void result) { + // Hide options and show success message in main dialog + trackOption.setVisible(false); + copyOption.setVisible(false); + optionsLabel.setVisible(false); + progressPanel.setVisible(false); + + // Add success message directly to main dialog + Label successMsg = new Label("✓ All " + assets.size() + " assets added successfully!"); + successMsg.setStyleName("ode-ComponentRowLabel"); + successMsg.getElement().getStyle().setProperty("color", "#2e7d32"); + successMsg.getElement().getStyle().setProperty("fontWeight", "bold"); + successMsg.getElement().getStyle().setProperty("fontSize", "14px"); + successMsg.getElement().getStyle().setProperty("textAlign", "center"); + successMsg.getElement().getStyle().setProperty("padding", "20px"); + dialogPanel.insert(successMsg, dialogPanel.getWidgetIndex(buttonPanel)); + + // Manually create and add project nodes for all imported assets + Project project = Ode.getInstance().getProjectManager().getProject(projectId); + YoungAndroidProjectNode projectNode = (YoungAndroidProjectNode) project.getRootNode(); + YoungAndroidAssetsFolder assetsFolder = projectNode.getAssetsFolder(); + + for (GlobalAsset asset : assets) { + // Create the asset node with the full imported path + String assetName = asset.getFileName(); + String fullAssetPath = "assets/_global_/"; + if (asset.getFolder() != null && !asset.getFolder().isEmpty()) { + fullAssetPath += asset.getFolder() + "/"; + } + fullAssetPath += assetName; + + YoungAndroidAssetNode assetNode = new YoungAndroidAssetNode(assetName, fullAssetPath); + project.addNode(assetsFolder, assetNode); + } + + // Refresh the project asset list and asset manager to make the assets visible + Ode.getInstance().getAssetListBox().getAssetList().refreshAssetList(projectId); + Ode.getInstance().getAssetManager().loadAssets(projectId); + + statusLabel.setText(assets.size() + " assets added successfully!"); + addBtn.setText("Close"); + addBtn.setEnabled(true); + cancelBtn.setVisible(false); + } + + @Override + public void onFailure(Throwable caught) { + progressLabel.setText("⚠ " + caught.getMessage()); + progressLabel.getElement().getStyle().setProperty("color", "#d32f2f"); + addBtn.setText("Retry"); + addBtn.setEnabled(true); + cancelBtn.setText("Cancel"); + } + }); + } + } + }); + + cancelBtn.addClickHandler(new ClickHandler() { + @Override + public void onClick(ClickEvent event) { + dialog.hide(); + } + }); + + dialog.center(); + dialog.show(); + } + + + // Folder management dialogs + private void showNewFolderDialog() { + final DialogBox dialog = new DialogBox(); + dialog.setText("Create New Folder"); + dialog.setStyleName("ode-DialogBox"); + dialog.setModal(true); + dialog.setGlassEnabled(true); + dialog.setAutoHideEnabled(false); + + VerticalPanel dialogPanel = new VerticalPanel(); + dialogPanel.setSpacing(12); + dialogPanel.setWidth("300px"); + dialogPanel.getElement().getStyle().setProperty("padding", "16px"); + + Label nameLabel = new Label("Folder name:"); + nameLabel.setStyleName("ode-ComponentRowLabel"); + dialogPanel.add(nameLabel); + + final TextBox nameBox = new TextBox(); + nameBox.setStyleName("ode-TextBox"); + nameBox.getElement().getStyle().setProperty("width", "100%"); + dialogPanel.add(nameBox); + + HorizontalPanel buttonPanel = new HorizontalPanel(); + buttonPanel.setSpacing(8); + buttonPanel.setHorizontalAlignment(HasHorizontalAlignment.ALIGN_RIGHT); + buttonPanel.setWidth("100%"); + buttonPanel.getElement().getStyle().setProperty("marginTop", "12px"); + + Button cancelBtn = new Button("Cancel"); + cancelBtn.setStyleName("ode-ProjectListButton"); + + Button createBtn = new Button("Create"); + createBtn.setStyleName("ode-ProjectListButton"); + + buttonPanel.add(cancelBtn); + buttonPanel.add(createBtn); + dialogPanel.add(buttonPanel); + + dialog.setWidget(dialogPanel); + + createBtn.addClickHandler(new ClickHandler() { + @Override + public void onClick(ClickEvent event) { + String folderName = nameBox.getText().trim(); + if (folderName.isEmpty()) { + Window.alert("Please enter a folder name."); + return; + } + if (folders.contains(folderName)) { + Window.alert("A folder with this name already exists."); + return; + } + + // Add to folders list (will be persisted when assets are moved to it) + folders.add(folders.size() - 1, folderName); // Insert before "Recent" + updateFolderList(); + statusLabel.setText("Folder '" + folderName + "' created"); + dialog.hide(); + } + }); + + cancelBtn.addClickHandler(new ClickHandler() { + @Override + public void onClick(ClickEvent event) { + dialog.hide(); + } + }); + + dialog.center(); + dialog.show(); + nameBox.setFocus(true); + } + + private void showRenameFolderDialog(final String oldFolderName) { + final DialogBox dialog = new DialogBox(); + dialog.setText("Rename Folder"); + dialog.setStyleName("ode-DialogBox"); + dialog.setModal(true); + dialog.setGlassEnabled(true); + dialog.setAutoHideEnabled(false); + + VerticalPanel dialogPanel = new VerticalPanel(); + dialogPanel.setSpacing(12); + dialogPanel.setWidth("300px"); + dialogPanel.getElement().getStyle().setProperty("padding", "16px"); + + Label nameLabel = new Label("New folder name:"); + nameLabel.setStyleName("ode-ComponentRowLabel"); + dialogPanel.add(nameLabel); + + final TextBox nameBox = new TextBox(); + nameBox.setText(oldFolderName); + nameBox.setStyleName("ode-TextBox"); + nameBox.getElement().getStyle().setProperty("width", "100%"); + dialogPanel.add(nameBox); + + HorizontalPanel buttonPanel = new HorizontalPanel(); + buttonPanel.setSpacing(8); + buttonPanel.setHorizontalAlignment(HasHorizontalAlignment.ALIGN_RIGHT); + buttonPanel.setWidth("100%"); + buttonPanel.getElement().getStyle().setProperty("marginTop", "12px"); + + Button cancelBtn = new Button("Cancel"); + cancelBtn.setStyleName("ode-ProjectListButton"); + + Button renameBtn = new Button("Rename"); + renameBtn.setStyleName("ode-ProjectListButton"); + + buttonPanel.add(cancelBtn); + buttonPanel.add(renameBtn); + dialogPanel.add(buttonPanel); + + dialog.setWidget(dialogPanel); + + renameBtn.addClickHandler(new ClickHandler() { + @Override + public void onClick(ClickEvent event) { + String newFolderName = nameBox.getText().trim(); + if (newFolderName.isEmpty()) { + Window.alert("Please enter a folder name."); + return; + } + if (newFolderName.equals(oldFolderName)) { + dialog.hide(); + return; + } + if (folders.contains(newFolderName)) { + Window.alert("A folder with this name already exists."); + return; + } + + // Update all assets in this folder + renameFolderForAssets(oldFolderName, newFolderName); + dialog.hide(); + } + }); + + cancelBtn.addClickHandler(new ClickHandler() { + @Override + public void onClick(ClickEvent event) { + dialog.hide(); + } + }); + + dialog.center(); + dialog.show(); + nameBox.selectAll(); + nameBox.setFocus(true); + } + + private void showDeleteFolderDialog(final String folderName) { + // Count assets in this folder + int assetCount = 0; + for (GlobalAsset asset : globalAssets) { + if (folderName.equals(asset.getFolder())) { + assetCount++; + } + } + + String message = assetCount > 0 + ? "Delete folder '" + folderName + "' and move " + assetCount + " asset(s) to root folder?" + : "Delete empty folder '" + folderName + "'?"; + + if (Window.confirm(message)) { + deleteFolderAndMoveAssets(folderName); + } + } + + private void renameFolderForAssets(final String oldFolderName, final String newFolderName) { + statusLabel.setText("Renaming folder..."); + final List assetsToUpdate = new ArrayList(); + + // Find assets to update + for (GlobalAsset asset : globalAssets) { + if (oldFolderName.equals(asset.getFolder())) { + assetsToUpdate.add(asset); + } + } + + if (assetsToUpdate.isEmpty()) { + // Just update the folder list + int index = folders.indexOf(oldFolderName); + if (index >= 0) { + folders.set(index, newFolderName); + updateFolderList(); + } + statusLabel.setText("Folder renamed to '" + newFolderName + "'"); + return; + } + + // Update each asset's folder using the existing API + final int totalAssets = assetsToUpdate.size(); + final int[] completedCount = {0}; + + for (final GlobalAsset asset : assetsToUpdate) { + globalAssetService.updateGlobalAssetFolder(asset.getFileName(), newFolderName, new AsyncCallback() { + @Override + public void onSuccess(Void result) { + completedCount[0]++; + if (completedCount[0] == totalAssets) { + // All assets updated successfully + int index = folders.indexOf(oldFolderName); + if (index >= 0) { + folders.set(index, newFolderName); + updateFolderList(); + } + refreshGlobalAssets(); + statusLabel.setText("Folder renamed to '" + newFolderName + "' (" + totalAssets + " assets updated)"); + } + } + + @Override + public void onFailure(Throwable caught) { + statusLabel.setText("Error renaming folder"); + Window.alert("Failed to rename folder: " + caught.getMessage()); + } + }); + } + } + + private void deleteFolderAndMoveAssets(final String folderName) { + statusLabel.setText("Deleting folder..."); + + final List assetsToMove = new ArrayList(); + for (GlobalAsset asset : globalAssets) { + if (folderName.equals(asset.getFolder())) { + assetsToMove.add(asset); + } + } + + if (assetsToMove.isEmpty()) { + // Just remove from folder list + folders.remove(folderName); + if (selectedFolderIndex > 0) { + selectedFolderIndex = 0; // Reset to "All Assets" + } + updateFolderList(); + refreshAssetList(); + statusLabel.setText("Empty folder '" + folderName + "' deleted"); + } else { + // Move all assets to root folder (empty string) + final int totalAssets = assetsToMove.size(); + final int[] completedCount = {0}; + + for (final GlobalAsset asset : assetsToMove) { + globalAssetService.updateGlobalAssetFolder(asset.getFileName(), "", new AsyncCallback() { + @Override + public void onSuccess(Void result) { + completedCount[0]++; + if (completedCount[0] == totalAssets) { + // All assets moved successfully, now remove folder + folders.remove(folderName); + if (selectedFolderIndex > 0) { + selectedFolderIndex = 0; // Reset to "All Assets" + } + updateFolderList(); + refreshGlobalAssets(); + statusLabel.setText("Folder '" + folderName + "' deleted (" + totalAssets + " assets moved to root)"); + } + } + + @Override + public void onFailure(Throwable caught) { + statusLabel.setText("Error deleting folder"); + Window.alert("Failed to delete folder: " + caught.getMessage()); + } + }); + } + } + } + + // Version management methods + + private void showUpdateAssetDialog(final GlobalAsset asset) { + final DialogBox dialog = new DialogBox(); + dialog.setText("Upload New Version - " + asset.getFileName()); + dialog.setStyleName("ode-DialogBox"); + dialog.setModal(true); + dialog.setGlassEnabled(true); + dialog.setAutoHideEnabled(false); + + VerticalPanel dialogPanel = new VerticalPanel(); + dialogPanel.setSpacing(12); + dialogPanel.setWidth("400px"); + dialogPanel.getElement().getStyle().setProperty("padding", "16px"); + + // Current asset info + HorizontalPanel currentAssetInfo = new HorizontalPanel(); + currentAssetInfo.setVerticalAlignment(HasVerticalAlignment.ALIGN_MIDDLE); + currentAssetInfo.getElement().getStyle().setProperty("marginBottom", "16px"); + currentAssetInfo.getElement().getStyle().setProperty("padding", "8px"); + currentAssetInfo.getElement().getStyle().setProperty("border", "1px solid #e0e0e0"); + currentAssetInfo.getElement().getStyle().setProperty("borderRadius", "4px"); + + String fileName = asset.getFileName().toLowerCase(); + ImageResource iconRes = fileName.endsWith(".mp3") || fileName.endsWith(".wav") || fileName.endsWith(".ogg") + ? ImagesNeo.INSTANCE.player() + : ImagesNeo.INSTANCE.image(); + + Image assetIcon = new Image(iconRes); + assetIcon.setWidth("24px"); + assetIcon.setHeight("24px"); + assetIcon.getElement().getStyle().setProperty("marginRight", "8px"); + currentAssetInfo.add(assetIcon); + + VerticalPanel currentInfo = new VerticalPanel(); + Label currentName = new Label("Current: " + asset.getFileName()); + currentName.setStyleName("ode-ComponentRowLabel"); + currentName.getElement().getStyle().setProperty("fontWeight", "500"); + currentInfo.add(currentName); + + Label currentVersion = new Label("Last updated: " + formatDate(asset.getTimestamp())); + currentVersion.setStyleName("ode-ComponentRowLabel"); + currentVersion.getElement().getStyle().setProperty("fontSize", "11px"); + currentVersion.getElement().getStyle().setProperty("opacity", "0.7"); + currentInfo.add(currentVersion); + currentAssetInfo.add(currentInfo); + + dialogPanel.add(currentAssetInfo); + + // Upload form + final FormPanel form = new FormPanel(); + form.setEncoding(FormPanel.ENCODING_MULTIPART); + form.setMethod(FormPanel.METHOD_POST); + + VerticalPanel formPanel = new VerticalPanel(); + formPanel.setSpacing(8); + + // File input + Label fileLabel = new Label("Select new version file:"); + fileLabel.setStyleName("ode-ComponentRowLabel"); + formPanel.add(fileLabel); + + final FileUpload fileUpload = new FileUpload(); + fileUpload.setName(ServerLayout.UPLOAD_GLOBAL_ASSET_FORM_ELEMENT); + fileUpload.setStyleName("ode-TextBox"); + fileUpload.getElement().getStyle().setProperty("width", "100%"); + formPanel.add(fileUpload); + + // Version notes + Label notesLabel = new Label("Version notes (optional):"); + notesLabel.setStyleName("ode-ComponentRowLabel"); + formPanel.add(notesLabel); + + final TextBox versionNotes = new TextBox(); + versionNotes.setStyleName("ode-TextBox"); + versionNotes.getElement().getStyle().setProperty("width", "100%"); + versionNotes.getElement().setPropertyString("placeholder", "Describe changes in this version..."); + formPanel.add(versionNotes); + + // Auto-update projects checkbox + final CheckBox autoUpdate = new CheckBox("Automatically update projects using this asset"); + autoUpdate.setValue(true); + autoUpdate.setStyleName("ode-ComponentRowLabel"); + autoUpdate.getElement().getStyle().setProperty("fontSize", "12px"); + autoUpdate.getElement().getStyle().setProperty("marginTop", "8px"); + formPanel.add(autoUpdate); + + Label autoUpdateDesc = new Label("Projects with tracking enabled will receive this update automatically."); + autoUpdateDesc.setStyleName("ode-ComponentRowLabel"); + autoUpdateDesc.getElement().getStyle().setProperty("fontSize", "10px"); + autoUpdateDesc.getElement().getStyle().setProperty("opacity", "0.7"); + autoUpdateDesc.getElement().getStyle().setProperty("marginLeft", "20px"); + formPanel.add(autoUpdateDesc); + + // Error label + final Label errorLabel = new Label(); + errorLabel.getElement().getStyle().setProperty("color", "#d93025"); + errorLabel.getElement().getStyle().setProperty("fontSize", "12px"); + formPanel.add(errorLabel); + + form.setWidget(formPanel); + dialogPanel.add(form); + + // Button panel + HorizontalPanel buttonPanel = new HorizontalPanel(); + buttonPanel.setSpacing(8); + buttonPanel.setHorizontalAlignment(HasHorizontalAlignment.ALIGN_RIGHT); + buttonPanel.setWidth("100%"); + + Button cancelBtn = new Button("Cancel"); + cancelBtn.setStyleName("ode-ProjectListButton"); + + Button uploadBtn = new Button("Upload New Version"); + uploadBtn.setStyleName("ode-ProjectListButton"); + + buttonPanel.add(cancelBtn); + buttonPanel.add(uploadBtn); + dialogPanel.add(buttonPanel); + + dialog.setWidget(dialogPanel); + + // Event handlers + uploadBtn.addClickHandler(new ClickHandler() { + @Override + public void onClick(ClickEvent event) { + String filename = fileUpload.getFilename(); + if (filename == null || filename.isEmpty()) { + errorLabel.setText("Please select a file."); + return; + } + + // Extract actual filename + String actualFilename = filename; + if (filename.contains("\\")) { + actualFilename = filename.substring(filename.lastIndexOf("\\") + 1); + } else if (filename.contains("/")) { + actualFilename = filename.substring(filename.lastIndexOf("/") + 1); + } + + // Validate file type matches existing asset + String newExt = getFileExtension(actualFilename).toLowerCase(); + String currentExt = getFileExtension(asset.getFileName()).toLowerCase(); + if (!newExt.equals(currentExt)) { + errorLabel.setText("New version must have the same file type as the current asset (" + currentExt + ")."); + return; + } + + // Use the original asset name for the upload (maintaining the same filename) + String uploadPath = "_global_/"; + if (!asset.getFolder().isEmpty()) { + uploadPath += asset.getFolder() + "/"; + } + uploadPath += asset.getFileName(); // Keep the same filename + + form.setAction(GWT.getModuleBaseURL() + "upload/" + ServerLayout.UPLOAD_GLOBAL_ASSET + "/" + uploadPath); + form.submit(); + } + }); + + form.addSubmitCompleteHandler(new SubmitCompleteHandler() { + @Override + public void onSubmitComplete(SubmitCompleteEvent event) { + dialog.hide(); + String results = event.getResults(); + if (results != null && results.contains("SUCCESS")) { + statusLabel.setText("New version uploaded successfully"); + refreshGlobalAssets(); + + // If auto-update is enabled, sync all projects using this asset + if (autoUpdate.getValue()) { + syncAssetWithProjects(asset.getFileName()); + } + + // Show success notification + Window.alert("Asset updated successfully!" + + (autoUpdate.getValue() ? " Projects using this asset will be updated." : "")); + } else { + showUploadError(MESSAGES.fileUploadError(), "Failed to upload new version. Please try again."); + } + } + }); + + cancelBtn.addClickHandler(new ClickHandler() { + @Override + public void onClick(ClickEvent event) { + dialog.hide(); + } + }); + + dialog.center(); + dialog.show(); + } + + private String getFileExtension(String filename) { + int lastDot = filename.lastIndexOf('.'); + return lastDot > 0 ? filename.substring(lastDot) : ""; + } + + private void syncAssetWithProjects(String assetFileName) { + statusLabel.setText("Syncing asset with projects..."); + + // Get current project ID if we're in a project + long currentProjectId = Ode.getInstance().getCurrentYoungAndroidProjectId(); + + if (currentProjectId != 0) { + // Sync with current project + globalAssetService.syncProjectGlobalAsset(assetFileName, currentProjectId, new AsyncCallback() { + @Override + public void onSuccess(Boolean result) { + if (result) { + statusLabel.setText("Asset synced with current project"); + // Force refresh of current project's asset list and editors + refreshCurrentProjectAssets(); + } else { + statusLabel.setText("Asset not used in current project"); + } + } + + @Override + public void onFailure(Throwable caught) { + statusLabel.setText("Failed to sync asset: " + caught.getMessage()); + } + }); + } + + // Also get list of all projects using this asset and sync them + globalAssetService.getProjectsUsingAsset(assetFileName, new AsyncCallback>() { + @Override + public void onSuccess(List projectIds) { + statusLabel.setText("Asset synced with " + projectIds.size() + " projects"); + // The server-side should handle the actual synchronization + // Client-side we just need to refresh if current project is affected + if (projectIds.contains(Ode.getInstance().getCurrentYoungAndroidProjectId())) { + refreshCurrentProjectAssets(); + } + } + + @Override + public void onFailure(Throwable caught) { + statusLabel.setText("Failed to get project usage info: " + caught.getMessage()); + } + }); + } + + private void refreshCurrentProjectAssets() { + // Force refresh of project assets and any open editors + long projectId = Ode.getInstance().getCurrentYoungAndroidProjectId(); + if (projectId != 0) { + // Refresh the project manager - this will update asset lists + Ode.getInstance().getProjectManager().getProject(projectId); + + // The server-side sync should handle updating the project assets + // Client-side we just notify that a refresh might be needed + statusLabel.setText("Projects updated - please refresh your designer if needed"); + } + } + + + /** + * Displays upload error dialog. + */ + private void showUploadError(String title, String message) { + final DialogBox errorDialog = new DialogBox(); + errorDialog.setText(title); + errorDialog.setStyleName("ode-DialogBox"); + errorDialog.setModal(true); + errorDialog.setGlassEnabled(true); + + VerticalPanel dialogPanel = new VerticalPanel(); + dialogPanel.setSpacing(10); + dialogPanel.setHorizontalAlignment(HasHorizontalAlignment.ALIGN_CENTER); + + Label messageLabel = new Label(message); + messageLabel.setStyleName("ode-DialogBodyText"); + dialogPanel.add(messageLabel); + + Button okButton = new Button("OK"); + okButton.setStyleName("ode-DialogButton"); + okButton.addClickHandler(new ClickHandler() { + @Override + public void onClick(ClickEvent event) { + errorDialog.hide(); + } + }); + dialogPanel.add(okButton); + + errorDialog.setWidget(dialogPanel); + errorDialog.center(); + } + + /** + * Displays deletion error dialog with better formatting for asset usage information. + */ + private void showDeleteError(String title, String message) { + final DialogBox errorDialog = new DialogBox(); + errorDialog.setText(title); + errorDialog.setStyleName("ode-DialogBox"); + errorDialog.setModal(true); + errorDialog.setGlassEnabled(true); + errorDialog.setAutoHideEnabled(false); + + VerticalPanel dialogPanel = new VerticalPanel(); + dialogPanel.setSpacing(16); + dialogPanel.setWidth("500px"); + dialogPanel.getElement().getStyle().setProperty("padding", "16px"); + + // Error header with icon + HorizontalPanel errorHeader = new HorizontalPanel(); + errorHeader.setVerticalAlignment(HasVerticalAlignment.ALIGN_TOP); + errorHeader.setSpacing(12); + errorHeader.getElement().getStyle().setProperty("backgroundColor", "#fef7f7"); + errorHeader.getElement().getStyle().setProperty("padding", "12px"); + errorHeader.getElement().getStyle().setProperty("borderRadius", "6px"); + errorHeader.getElement().getStyle().setProperty("border", "1px solid #f5c6cb"); + errorHeader.getElement().getStyle().setProperty("marginBottom", "12px"); + + Label errorIcon = new Label("⚠"); + errorIcon.getElement().getStyle().setProperty("fontSize", "24px"); + errorIcon.getElement().getStyle().setProperty("color", "#721c24"); + errorIcon.getElement().getStyle().setProperty("marginRight", "8px"); + errorHeader.add(errorIcon); + + VerticalPanel errorText = new VerticalPanel(); + Label errorTitle = new Label("Asset Cannot Be Deleted"); + errorTitle.setStyleName("ode-ComponentRowLabel"); + errorTitle.getElement().getStyle().setProperty("fontWeight", "600"); + errorTitle.getElement().getStyle().setProperty("fontSize", "16px"); + errorTitle.getElement().getStyle().setProperty("color", "#721c24"); + errorText.add(errorTitle); + + // Parse the error message to make it more readable + String displayMessage = message; + if (message.contains("is currently used by") && message.contains("project(s):")) { + // Enhanced error message - display it nicely + displayMessage = message.replace("Cannot delete asset", "This asset"); + } + + Label errorMsg = new Label(displayMessage); + errorMsg.setStyleName("ode-ComponentRowLabel"); + errorMsg.getElement().getStyle().setProperty("fontSize", "14px"); + errorMsg.getElement().getStyle().setProperty("color", "#721c24"); + errorMsg.getElement().getStyle().setProperty("lineHeight", "1.4"); + errorMsg.getElement().getStyle().setProperty("marginTop", "4px"); + errorMsg.getElement().getStyle().setProperty("wordWrap", "break-word"); + errorText.add(errorMsg); + + errorHeader.add(errorText); + dialogPanel.add(errorHeader); + + // Help text + if (message.contains("Please remove the asset from these projects first")) { + Label helpText = new Label("To delete this asset:"); + helpText.setStyleName("ode-ComponentRowLabel"); + helpText.getElement().getStyle().setProperty("fontWeight", "600"); + helpText.getElement().getStyle().setProperty("marginBottom", "8px"); + dialogPanel.add(helpText); + + VerticalPanel stepsList = new VerticalPanel(); + stepsList.getElement().getStyle().setProperty("marginLeft", "16px"); + + Label step1 = new Label("1. Open each project listed above"); + step1.setStyleName("ode-ComponentRowLabel"); + step1.getElement().getStyle().setProperty("fontSize", "14px"); + step1.getElement().getStyle().setProperty("marginBottom", "4px"); + stepsList.add(step1); + + Label step2 = new Label("2. Remove the asset from the project's assets"); + step2.setStyleName("ode-ComponentRowLabel"); + step2.getElement().getStyle().setProperty("fontSize", "14px"); + step2.getElement().getStyle().setProperty("marginBottom", "4px"); + stepsList.add(step2); + + Label step3 = new Label("3. Return here to delete the asset"); + step3.setStyleName("ode-ComponentRowLabel"); + step3.getElement().getStyle().setProperty("fontSize", "14px"); + stepsList.add(step3); + + dialogPanel.add(stepsList); + } + + // Button panel + HorizontalPanel buttonPanel = new HorizontalPanel(); + buttonPanel.setSpacing(8); + buttonPanel.setHorizontalAlignment(HasHorizontalAlignment.ALIGN_RIGHT); + buttonPanel.setWidth("100%"); + buttonPanel.getElement().getStyle().setProperty("marginTop", "16px"); + + Button okButton = new Button("OK"); + okButton.setStyleName("ode-ProjectListButton"); + okButton.addClickHandler(new ClickHandler() { + @Override + public void onClick(ClickEvent event) { + errorDialog.hide(); + } + }); + buttonPanel.add(okButton); + dialogPanel.add(buttonPanel); + + errorDialog.setWidget(dialogPanel); + errorDialog.center(); + } + +} \ No newline at end of file diff --git a/appinventor/appengine/src/com/google/appinventor/client/assetlibrary/AssetLibraryWidgetClassic.java b/appinventor/appengine/src/com/google/appinventor/client/assetlibrary/AssetLibraryWidgetClassic.java new file mode 100644 index 00000000000..9ac711164ea --- /dev/null +++ b/appinventor/appengine/src/com/google/appinventor/client/assetlibrary/AssetLibraryWidgetClassic.java @@ -0,0 +1,2753 @@ +package com.google.appinventor.client.assetlibrary; + +import com.google.appinventor.client.Ode; +import com.google.appinventor.client.explorer.commands.PreviewFileCommand; +import com.google.appinventor.client.youngandroid.TextValidators; +import static com.google.appinventor.client.Ode.MESSAGES; +import com.google.appinventor.shared.storage.StorageUtil; +import com.google.gwt.user.client.ui.*; +import com.google.gwt.event.dom.client.ClickEvent; +import com.google.gwt.event.dom.client.ClickHandler; +import com.google.gwt.event.dom.client.KeyUpEvent; +import com.google.gwt.event.dom.client.KeyUpHandler; +import com.google.gwt.event.dom.client.ChangeEvent; +import com.google.gwt.event.dom.client.ChangeHandler; +import com.google.gwt.event.dom.client.DragStartEvent; +import com.google.gwt.event.dom.client.DragStartHandler; +import com.google.gwt.event.dom.client.DragOverEvent; +import com.google.gwt.event.dom.client.DragOverHandler; +import com.google.gwt.event.dom.client.DropEvent; +import com.google.gwt.event.dom.client.DropHandler; +import com.google.gwt.user.client.Window; +import com.google.gwt.user.client.Event; +import com.google.gwt.user.client.ui.FormPanel.SubmitCompleteEvent; +import com.google.gwt.user.client.ui.FormPanel.SubmitEvent; +import com.google.gwt.user.client.ui.FormPanel.SubmitHandler; +import com.google.gwt.user.client.ui.FormPanel.SubmitCompleteHandler; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import com.google.gwt.resources.client.ImageResource; +import com.google.gwt.user.client.ui.Image; +import com.google.appinventor.client.Images; +import com.google.appinventor.shared.rpc.globalasset.GlobalAssetService; +import com.google.appinventor.shared.rpc.globalasset.GlobalAssetServiceAsync; +import com.google.appinventor.shared.rpc.globalasset.AssetConflictInfo; +import com.google.appinventor.client.assetlibrary.AssetUploadConflictDialog.ConflictResolution; +import com.google.appinventor.client.assetlibrary.AssetUploadConflictDialog.ConflictResolutionCallback; +import com.google.appinventor.shared.rpc.ServerLayout; +import com.google.appinventor.shared.rpc.project.GlobalAsset; +import com.google.appinventor.shared.rpc.project.GlobalAssetProjectNode; +import com.google.appinventor.shared.rpc.project.youngandroid.YoungAndroidAssetNode; +import com.google.appinventor.shared.rpc.project.youngandroid.YoungAndroidAssetsFolder; +import com.google.appinventor.shared.rpc.project.youngandroid.YoungAndroidProjectNode; +import com.google.appinventor.client.explorer.project.Project; +import com.google.appinventor.client.boxes.AssetListBox; +import com.google.gwt.core.client.GWT; +import com.google.gwt.user.client.rpc.AsyncCallback; + +public class AssetLibraryWidgetClassic extends Composite { + private VerticalPanel rootPanel; + private HorizontalPanel headerContainer; + private TextBox searchBox; + private ListBox typeFilter; + private Button uploadButton; + private Button closeButton; + private HorizontalPanel mainContentPanel; + private VerticalPanel sidebarPanel; + private ScrollPanel assetScrollPanel; + private VerticalPanel assetListPanel; + private HorizontalPanel footerPanel; + + // Asset management + private List globalAssets = new ArrayList<>(); + private final GlobalAssetServiceAsync globalAssetService = GWT.create(GlobalAssetService.class); + private static String draggedAssetName; + private final Images images = GWT.create(Images.class); + + // Sidebar state + private List folders = new ArrayList<>(); + private int selectedFolderIndex = 0; + private VerticalPanel folderListPanel; + + // Selection management + private List assetCheckBoxes = new ArrayList<>(); + private Button addSelectedButton; + private Button deleteSelectedButton; + + public AssetLibraryWidgetClassic(Ode ode) { + initializeLayout(); + setupEventHandlers(); + loadInitialData(); + initWidget(rootPanel); + } + + + private void initializeLayout() { + // Main root panel - classic table layout with background + rootPanel = new VerticalPanel(); + rootPanel.setSize("100%", "100%"); + rootPanel.setStyleName("ode-Box"); + rootPanel.getElement().getStyle().setProperty("backgroundColor", "#f8f9fa"); + rootPanel.getElement().getStyle().setProperty("display", "flex"); + rootPanel.getElement().getStyle().setProperty("flexDirection", "column"); + rootPanel.getElement().getStyle().setProperty("height", "100vh"); + rootPanel.getElement().getStyle().setProperty("overflow", "hidden"); + + createHeader(); + createMainContent(); + createFooter(); + } + + private void createHeader() { + // Premium header with modern classic App Inventor styling + headerContainer = new HorizontalPanel(); + headerContainer.setWidth("100%"); + headerContainer.setStyleName("ode-TopPanel"); + headerContainer.setVerticalAlignment(HasVerticalAlignment.ALIGN_MIDDLE); + headerContainer.getElement().getStyle().setProperty("padding", "16px 24px"); + headerContainer.getElement().getStyle().setProperty("borderBottom", "1px solid #e0e0e0"); + + // Left section: Title and search + HorizontalPanel leftSection = new HorizontalPanel(); + leftSection.setVerticalAlignment(HasVerticalAlignment.ALIGN_MIDDLE); + + // Asset Library Title + Label titleLabel = new Label("Asset Library"); + titleLabel.setStyleName("ode-ProjectNameLabel"); + titleLabel.getElement().getStyle().setProperty("fontSize", "18px"); + titleLabel.getElement().getStyle().setProperty("fontWeight", "600"); + titleLabel.getElement().getStyle().setProperty("marginRight", "20px"); + leftSection.add(titleLabel); + + // Search box matching App Inventor style + searchBox = new TextBox(); + searchBox.getElement().setPropertyString("placeholder", "Search assets..."); + searchBox.setStyleName("ode-TextBox"); + searchBox.getElement().getStyle().setProperty("width", "280px"); + searchBox.getElement().getStyle().setProperty("height", "36px"); + searchBox.getElement().getStyle().setProperty("fontSize", "14px"); + searchBox.getElement().getStyle().setProperty("marginRight", "16px"); + leftSection.add(searchBox); + + // Type filter matching App Inventor dropdown style + typeFilter = new ListBox(); + typeFilter.addItem("All Types"); + typeFilter.addItem("Images"); + typeFilter.addItem("Sounds"); + typeFilter.setStyleName("ode-ListBox"); + typeFilter.getElement().getStyle().setProperty("height", "36px"); + typeFilter.getElement().getStyle().setProperty("fontSize", "14px"); + typeFilter.getElement().getStyle().setProperty("minWidth", "120px"); + typeFilter.getElement().getStyle().setProperty("marginRight", "20px"); + leftSection.add(typeFilter); + + headerContainer.add(leftSection); + + // Spacer + Label spacer = new Label(""); + spacer.getElement().getStyle().setProperty("flex", "1"); + headerContainer.add(spacer); + + // Right section: Action buttons using App Inventor button style + HorizontalPanel rightSection = new HorizontalPanel(); + rightSection.setVerticalAlignment(HasVerticalAlignment.ALIGN_MIDDLE); + rightSection.getElement().getStyle().setProperty("gap", "12px"); + rightSection.getElement().getStyle().setProperty("display", "flex"); + rightSection.getElement().getStyle().setProperty("alignItems", "center"); + + // Bulk action buttons with improved sizing + addSelectedButton = new Button("Add Selected"); + addSelectedButton.setStyleName("ode-ProjectListButton"); + addSelectedButton.setEnabled(false); + addSelectedButton.getElement().getStyle().setProperty("height", "36px"); + addSelectedButton.getElement().getStyle().setProperty("fontSize", "14px"); + addSelectedButton.getElement().getStyle().setProperty("marginRight", "8px"); + rightSection.add(addSelectedButton); + + deleteSelectedButton = new Button("Delete Selected"); + deleteSelectedButton.setStyleName("ode-ProjectListButton"); + deleteSelectedButton.getElement().getStyle().setProperty("height", "36px"); + deleteSelectedButton.getElement().getStyle().setProperty("fontSize", "14px"); + deleteSelectedButton.getElement().getStyle().setProperty("marginRight", "8px"); + deleteSelectedButton.setEnabled(false); + rightSection.add(deleteSelectedButton); + + // Upload button + uploadButton = new Button("⬆ Upload Asset"); + uploadButton.setStyleName("ode-ProjectListButton"); + uploadButton.getElement().getStyle().setProperty("height", "36px"); + uploadButton.getElement().getStyle().setProperty("fontSize", "14px"); + uploadButton.getElement().getStyle().setProperty("marginRight", "8px"); + rightSection.add(uploadButton); + + // Close button + closeButton = new Button("✕"); + closeButton.setTitle("Close Asset Library"); + closeButton.setStyleName("ode-ProjectListButton"); + closeButton.getElement().getStyle().setProperty("height", "36px"); + closeButton.getElement().getStyle().setProperty("fontSize", "16px"); + closeButton.getElement().getStyle().setProperty("minWidth", "36px"); + rightSection.add(closeButton); + + headerContainer.add(rightSection); + rootPanel.add(headerContainer); + } + + private void createMainContent() { + // Main content area using classic horizontal split + mainContentPanel = new HorizontalPanel(); + mainContentPanel.setSize("100%", "100%"); + mainContentPanel.setStyleName("ode-WorkColumns"); + mainContentPanel.getElement().getStyle().setProperty("flex", "1 1 auto"); + mainContentPanel.getElement().getStyle().setProperty("display", "flex"); + mainContentPanel.getElement().getStyle().setProperty("height", "100%"); + mainContentPanel.getElement().getStyle().setProperty("overflow", "hidden"); + + createSidebar(); + createAssetList(); + + rootPanel.add(mainContentPanel); + } + + private void createSidebar() { + // Sidebar matching App Inventor design with improved spacing and background + sidebarPanel = new VerticalPanel(); + sidebarPanel.setWidth("280px"); + sidebarPanel.setHeight("100%"); + sidebarPanel.setStyleName("ode-Designer-LeftColumn"); + sidebarPanel.getElement().getStyle().setProperty("padding", "20px 16px"); + sidebarPanel.getElement().getStyle().setProperty("borderRight", "1px solid #e0e0e0"); + sidebarPanel.getElement().getStyle().setProperty("flexShrink", "0"); + sidebarPanel.getElement().getStyle().setProperty("backgroundColor", "#ffffff"); + sidebarPanel.getElement().getStyle().setProperty("height", "100vh"); + sidebarPanel.getElement().getStyle().setProperty("minHeight", "100vh"); + sidebarPanel.getElement().getStyle().setProperty("maxHeight", "100vh"); + sidebarPanel.getElement().getStyle().setProperty("boxSizing", "border-box"); + sidebarPanel.getElement().getStyle().setProperty("display", "flex"); + sidebarPanel.getElement().getStyle().setProperty("flexDirection", "column"); + sidebarPanel.getElement().getStyle().setProperty("alignItems", "stretch"); + sidebarPanel.getElement().getStyle().setProperty("justifyContent", "flex-start"); + sidebarPanel.getElement().getStyle().setProperty("overflowY", "auto"); + + // Folder section header + HorizontalPanel folderHeader = new HorizontalPanel(); + folderHeader.setWidth("100%"); + folderHeader.setVerticalAlignment(HasVerticalAlignment.ALIGN_MIDDLE); + folderHeader.getElement().getStyle().setProperty("flexShrink", "0"); + folderHeader.getElement().getStyle().setProperty("marginBottom", "8px"); + folderHeader.getElement().getStyle().setProperty("display", "flex"); + folderHeader.getElement().getStyle().setProperty("justifyContent", "space-between"); + folderHeader.getElement().getStyle().setProperty("alignItems", "center"); + folderHeader.getElement().getStyle().setProperty("width", "100%"); + + Label folderTitle = new Label("📁 Folders"); + folderTitle.setStyleName("ode-ComponentRowLabel"); + folderTitle.getElement().getStyle().setProperty("fontSize", "16px"); + folderTitle.getElement().getStyle().setProperty("fontWeight", "600"); + folderHeader.add(folderTitle); + + // Small action buttons with proper spacing + HorizontalPanel folderActions = new HorizontalPanel(); + folderActions.setSpacing(6); + folderActions.getElement().getStyle().setProperty("marginLeft", "50px"); + + Button newFolderBtn = createSmallButton("+", "New Folder"); + Button renameFolderBtn = createSmallButton("✎", "Rename Folder"); + Button deleteFolderBtn = createSmallButton("🗑", "Delete Folder"); + + // Add event handlers for folder management + setupFolderManagementHandlers(newFolderBtn, renameFolderBtn, deleteFolderBtn); + + folderActions.add(newFolderBtn); + folderActions.add(renameFolderBtn); + folderActions.add(deleteFolderBtn); + folderHeader.add(folderActions); + + sidebarPanel.add(folderHeader); + + // Folder list + folderListPanel = new VerticalPanel(); + folderListPanel.setWidth("100%"); + folderListPanel.getElement().getStyle().setProperty("flex", "1 1 auto"); + folderListPanel.getElement().getStyle().setProperty("overflowY", "auto"); + folderListPanel.getElement().getStyle().setProperty("minHeight", "0"); + folderListPanel.getElement().getStyle().setProperty("width", "100%"); + folderListPanel.getElement().getStyle().setProperty("paddingTop", "4px"); + sidebarPanel.add(folderListPanel); + + mainContentPanel.add(sidebarPanel); + } + + private Button createSmallButton(String text, String title) { + Button button = new Button(text); + button.setTitle(title); + button.setStyleName("ode-ProjectListButton"); + button.getElement().getStyle().setProperty("padding", "4px 8px"); + button.getElement().getStyle().setProperty("fontSize", "12px"); + button.getElement().getStyle().setProperty("minWidth", "28px"); + button.getElement().getStyle().setProperty("height", "28px"); + return button; + } + + + + private void createAssetList() { + // Asset list container + VerticalPanel assetContainer = new VerticalPanel(); + assetContainer.setWidth("100vw"); + assetContainer.setHeight("100%"); + assetContainer.setStyleName("ode-Box-body-padding"); + assetContainer.getElement().getStyle().setProperty("flex", "1 1 auto"); + assetContainer.getElement().getStyle().setProperty("display", "flex"); + assetContainer.getElement().getStyle().setProperty("flexDirection", "column"); + assetContainer.getElement().getStyle().setProperty("minHeight", "0"); + assetContainer.getElement().getStyle().setProperty("overflow", "hidden"); + + + // Scrollable list + assetScrollPanel = new ScrollPanel(); + assetScrollPanel.setWidth("100%"); + assetScrollPanel.setStyleName("ode-Explorer"); + assetScrollPanel.getElement().getStyle().setProperty("flex", "1 1 auto"); + assetScrollPanel.getElement().getStyle().setProperty("height", "calc(100vh - 180px)"); + assetScrollPanel.getElement().getStyle().setProperty("maxHeight", "calc(100vh - 180px)"); + assetScrollPanel.getElement().getStyle().setProperty("overflowY", "auto"); + assetScrollPanel.getElement().getStyle().setProperty("paddingBottom", "60px"); + + // Asset list panel + assetListPanel = new VerticalPanel(); + assetListPanel.setWidth("100%"); + assetListPanel.setSpacing(0); + + assetScrollPanel.add(assetListPanel); + assetContainer.add(assetScrollPanel); + mainContentPanel.add(assetContainer); + mainContentPanel.setCellWidth(assetContainer, "100%"); + mainContentPanel.setCellHeight(assetContainer, "100%"); + } + + private void createFooter() { + // Footer matching App Inventor style + footerPanel = new HorizontalPanel(); + footerPanel.setWidth("100%"); + footerPanel.setStyleName("ode-StatusPanel"); + footerPanel.setVerticalAlignment(HasVerticalAlignment.ALIGN_MIDDLE); + footerPanel.getElement().getStyle().setProperty("padding", "8px 24px"); + footerPanel.getElement().getStyle().setProperty("minHeight", "36px"); + footerPanel.getElement().getStyle().setProperty("borderTop", "1px solid #e0e0e0"); + footerPanel.getElement().getStyle().setProperty("flexShrink", "0"); + footerPanel.getElement().getStyle().setProperty("backgroundColor", "white"); + footerPanel.getElement().getStyle().setProperty("position", "fixed"); + footerPanel.getElement().getStyle().setProperty("bottom", "0"); + footerPanel.getElement().getStyle().setProperty("left", "0"); + footerPanel.getElement().getStyle().setProperty("right", "0"); + footerPanel.getElement().getStyle().setProperty("zIndex", "998"); + footerPanel.getElement().getStyle().setProperty("boxSizing", "border-box"); + + Label footerText = new Label("Tip: Drag assets to folders to organize them"); + footerText.setStyleName("ode-StatusPanelLabel"); + footerText.getElement().getStyle().setProperty("fontSize", "12px"); + footerText.getElement().getStyle().setProperty("fontStyle", "italic"); + footerPanel.add(footerText); + + rootPanel.add(footerPanel); + } + + private void setupEventHandlers() { + searchBox.addKeyUpHandler(new KeyUpHandler() { + @Override + public void onKeyUp(KeyUpEvent event) { + refreshAssetList(); + } + }); + + typeFilter.addChangeHandler(new ChangeHandler() { + @Override + public void onChange(ChangeEvent event) { + refreshAssetList(); + } + }); + + uploadButton.addClickHandler(new ClickHandler() { + @Override + public void onClick(ClickEvent event) { + showUploadDialog(); + } + }); + + closeButton.addClickHandler(new ClickHandler() { + @Override + public void onClick(ClickEvent event) { + // Get the current project and switch back to the editor + long currentProjectId = Ode.getInstance().getCurrentYoungAndroidProjectId(); + if (currentProjectId != 0) { + Project project = Ode.getInstance().getProjectManager().getProject(currentProjectId); + if (project != null) { + Ode.getInstance().openYoungAndroidProjectInDesigner(project); + } else { + // Fallback to projects view if project not found + Ode.getInstance().switchToProjectsView(); + } + } else { + // Fallback to projects view if no current project + Ode.getInstance().switchToProjectsView(); + } + } + }); + + setupBulkActionHandlers(); + } + + private void setupBulkActionHandlers() { + addSelectedButton.addClickHandler(new ClickHandler() { + @Override + public void onClick(ClickEvent event) { + List selectedAssets = getSelectedAssets(); + if (!selectedAssets.isEmpty()) { + showBulkAddToProjectDialog(selectedAssets); + } + } + }); + + deleteSelectedButton.addClickHandler(new ClickHandler() { + @Override + public void onClick(ClickEvent event) { + List selectedAssets = getSelectedAssets(); + if (!selectedAssets.isEmpty() && + Window.confirm("Are you sure you want to delete " + selectedAssets.size() + " selected asset(s)?")) { + deleteSelectedAssets(selectedAssets); + } + } + }); + } + + private void setupFolderManagementHandlers(Button newFolderBtn, Button renameFolderBtn, Button deleteFolderBtn) { + newFolderBtn.addClickHandler(new ClickHandler() { + @Override + public void onClick(ClickEvent event) { + showNewFolderDialog(); + } + }); + + renameFolderBtn.addClickHandler(new ClickHandler() { + @Override + public void onClick(ClickEvent event) { + String selectedFolder = selectedFolderIndex >= 0 && selectedFolderIndex < folders.size() + ? folders.get(selectedFolderIndex) : null; + if (selectedFolder != null && !isSpecialFolder(selectedFolder)) { + showRenameFolderDialog(selectedFolder); + } else { + Window.alert("Please select a regular folder to rename."); + } + } + }); + + deleteFolderBtn.addClickHandler(new ClickHandler() { + @Override + public void onClick(ClickEvent event) { + String selectedFolder = selectedFolderIndex >= 0 && selectedFolderIndex < folders.size() + ? folders.get(selectedFolderIndex) : null; + if (selectedFolder != null && !isSpecialFolder(selectedFolder)) { + showDeleteFolderDialog(selectedFolder); + } else { + Window.alert("Please select a regular folder to delete."); + } + } + }); + } + + private boolean isSpecialFolder(String folderName) { + return "All Assets".equals(folderName) || "Recent".equals(folderName); + } + + private void loadInitialData() { + // Initialize with default folders + folders.clear(); + folders.add("All Assets"); + selectedFolderIndex = 0; + + updateFolderList(); + refreshGlobalAssets(); + } + + private void updateFolderList() { + folderListPanel.clear(); + + for (int i = 0; i < folders.size(); i++) { + final int index = i; + HorizontalPanel folderRow = createFolderRow(folders.get(i), index); + folderListPanel.add(folderRow); + } + } + + private HorizontalPanel createFolderRow(String folderName, int index) { + HorizontalPanel folderRow = new HorizontalPanel(); + folderRow.setWidth("100%"); + folderRow.setVerticalAlignment(HasVerticalAlignment.ALIGN_MIDDLE); + folderRow.getElement().getStyle().setProperty("padding", "10px 0px"); + folderRow.getElement().getStyle().setProperty("borderRadius", "4px"); + folderRow.getElement().getStyle().setProperty("cursor", "pointer"); + folderRow.getElement().getStyle().setProperty("marginBottom", "6px"); + folderRow.getElement().getStyle().setProperty("minHeight", "36px"); + folderRow.getElement().getStyle().setProperty("boxSizing", "border-box"); + + // Folder icon + Label icon = new Label("📁"); + icon.getElement().getStyle().setProperty("marginLeft", "12px"); + icon.getElement().getStyle().setProperty("marginRight", "10px"); + icon.getElement().getStyle().setProperty("fontSize", "16px"); + folderRow.add(icon); + + // Folder name + Label nameLabel = new Label(folderName); + nameLabel.setStyleName("ode-ComponentRowLabel"); + nameLabel.getElement().getStyle().setProperty("fontSize", "14px"); + nameLabel.getElement().getStyle().setProperty("fontWeight", "400"); + nameLabel.getElement().getStyle().setProperty("overflow", "hidden"); + nameLabel.getElement().getStyle().setProperty("textOverflow", "ellipsis"); + nameLabel.getElement().getStyle().setProperty("whiteSpace", "nowrap"); + folderRow.add(nameLabel); + folderRow.setCellWidth(nameLabel, "100%"); + + // Apply selection styling matching App Inventor + if (index == selectedFolderIndex) { + folderRow.setStyleName("ode-ComponentRowHighlighted"); + nameLabel.setStyleName("ode-ComponentRowLabel"); + } else { + folderRow.setStyleName("ode-ComponentRowUnHighlighted"); + nameLabel.setStyleName("ode-ComponentRowLabel"); + } + + // Click handler + folderRow.addDomHandler(new com.google.gwt.event.dom.client.ClickHandler() { + @Override + public void onClick(com.google.gwt.event.dom.client.ClickEvent event) { + selectedFolderIndex = index; + updateFolderList(); + refreshAssetList(); + } + }, com.google.gwt.event.dom.client.ClickEvent.getType()); + + // Drag and drop support + setupFolderDragDrop(folderRow, index); + + return folderRow; + } + + private void setupFolderDragDrop(HorizontalPanel folderRow, int folderIndex) { + folderRow.addDomHandler(new DragOverHandler() { + @Override + public void onDragOver(DragOverEvent event) { + event.preventDefault(); + folderRow.addStyleName("ode-ComponentRowHighlighted"); + } + }, DragOverEvent.getType()); + + folderRow.addDomHandler(new DropHandler() { + @Override + public void onDrop(DropEvent event) { + event.preventDefault(); + if (folderIndex != selectedFolderIndex) { + folderRow.removeStyleName("ode-ComponentRowHighlighted"); + } + + if (draggedAssetName != null) { + String folderName = folders.get(folderIndex); + moveAssetToFolder(draggedAssetName, folderName); + draggedAssetName = null; + } + } + }, DropEvent.getType()); + } + + private void refreshGlobalAssets() { + globalAssetService.getGlobalAssets(new AsyncCallback>() { + @Override + public void onSuccess(List assets) { + globalAssets.clear(); + if (assets != null) { + globalAssets.addAll(assets); + } + updateFoldersFromAssets(); + refreshAssetList(); + } + + @Override + public void onFailure(Throwable caught) { + globalAssets.clear(); + refreshAssetList(); + } + }); + } + + private void updateFoldersFromAssets() { + // Preserve current selection + String currentFolder = selectedFolderIndex >= 0 && selectedFolderIndex < folders.size() + ? folders.get(selectedFolderIndex) : "All Assets"; + + // Clear and rebuild folders + folders.clear(); + folders.add("All Assets"); + + // Add unique folders from assets + Set uniqueFolders = new HashSet<>(); + for (GlobalAsset asset : globalAssets) { + String folder = asset.getFolder(); + if (folder != null && !folder.isEmpty() && !folder.equals("")) { + uniqueFolders.add(folder); + } + } + + // Add folders in alphabetical order + List sortedFolders = new ArrayList<>(uniqueFolders); + Collections.sort(sortedFolders); + folders.addAll(sortedFolders); + + // Add virtual folders + folders.add("Recent"); + + // Restore selection or default to "All Assets" + selectedFolderIndex = 0; + for (int i = 0; i < folders.size(); i++) { + if (folders.get(i).equals(currentFolder)) { + selectedFolderIndex = i; + break; + } + } + + updateFolderList(); + } + + private void refreshAssetList() { + assetListPanel.clear(); + assetCheckBoxes.clear(); + + String searchText = searchBox.getText().toLowerCase(); + String typeFilter = this.typeFilter.getSelectedValue(); + String folderFilter = selectedFolderIndex >= 0 && selectedFolderIndex < folders.size() + ? folders.get(selectedFolderIndex) : "All Assets"; + List filteredAssets = filterAssets(searchText, typeFilter, folderFilter); + + if (filteredAssets.isEmpty()) { + showEmptyState(); + } else { + displayAssets(filteredAssets); + } + + updateBulkActionButtons(); + } + + private List filterAssets(String searchText, String typeFilter, String folderFilter) { + List filtered = new ArrayList<>(); + + for (GlobalAsset asset : globalAssets) { + boolean nameMatch = asset.getFileName().toLowerCase().contains(searchText); + boolean typeMatch = matchesTypeFilter(asset, typeFilter); + boolean folderMatch = matchesFolderFilter(asset, folderFilter); + + if (nameMatch && typeMatch && folderMatch) { + filtered.add(asset); + } + } + + return filtered; + } + + private boolean matchesFolderFilter(GlobalAsset asset, String folderFilter) { + if ("All Assets".equals(folderFilter)) { + return true; + } else if ("Recent".equals(folderFilter)) { + // Show assets modified in the last 7 days + long weekAgo = System.currentTimeMillis() - (7 * 24 * 60 * 60 * 1000); + return asset.getTimestamp() > weekAgo; + } else { + // Regular folder match + String assetFolder = asset.getFolder(); + if (assetFolder == null) assetFolder = ""; + return folderFilter.equals(assetFolder); + } + } + + private boolean matchesTypeFilter(GlobalAsset asset, String typeFilter) { + if ("All Types".equals(typeFilter)) return true; + + String fileName = asset.getFileName().toLowerCase(); + if ("Images".equals(typeFilter)) { + return fileName.endsWith(".png") || fileName.endsWith(".jpg") || fileName.endsWith(".jpeg") || fileName.endsWith(".gif"); + } else if ("Sounds".equals(typeFilter)) { + return fileName.endsWith(".mp3") || fileName.endsWith(".wav") || fileName.endsWith(".ogg"); + } + + return false; + } + + private void showEmptyState() { + VerticalPanel emptyState = new VerticalPanel(); + emptyState.setWidth("100%"); + emptyState.setHorizontalAlignment(HasHorizontalAlignment.ALIGN_CENTER); + emptyState.setVerticalAlignment(HasVerticalAlignment.ALIGN_MIDDLE); + emptyState.getElement().getStyle().setProperty("padding", "60px 20px"); + emptyState.getElement().getStyle().setProperty("textAlign", "center"); + + // Empty icon + Label emptyIcon = new Label("📁"); + emptyIcon.getElement().getStyle().setProperty("fontSize", "64px"); + emptyIcon.getElement().getStyle().setProperty("opacity", "0.4"); + emptyIcon.getElement().getStyle().setProperty("marginBottom", "16px"); + emptyState.add(emptyIcon); + + // Message + Label emptyMessage = new Label("No assets found"); + emptyMessage.setStyleName("ode-ComponentRowLabel"); + emptyMessage.getElement().getStyle().setProperty("fontSize", "18px"); + emptyMessage.getElement().getStyle().setProperty("fontWeight", "500"); + emptyMessage.getElement().getStyle().setProperty("marginBottom", "8px"); + emptyState.add(emptyMessage); + + // Sub message + Label emptySubMessage = new Label("Try adjusting your search or upload new assets"); + emptySubMessage.setStyleName("ode-ComponentRowLabel"); + emptySubMessage.getElement().getStyle().setProperty("fontSize", "14px"); + emptySubMessage.getElement().getStyle().setProperty("opacity", "0.7"); + emptyState.add(emptySubMessage); + + assetListPanel.add(emptyState); + } + + private void displayAssets(List assets) { + for (final GlobalAsset asset : assets) { + HorizontalPanel assetRow = createAssetRow(asset); + assetListPanel.add(assetRow); + } + } + + private HorizontalPanel createAssetRow(final GlobalAsset asset) { + HorizontalPanel row = new HorizontalPanel(); + row.setWidth("100%"); + row.setVerticalAlignment(HasVerticalAlignment.ALIGN_MIDDLE); + row.setStyleName("ode-Box"); + row.getElement().getStyle().setProperty("minHeight", "60px"); + row.getElement().getStyle().setProperty("padding", "12px 16px"); + row.getElement().getStyle().setProperty("margin", "4px 0"); + row.getElement().getStyle().setProperty("borderRadius", "4px"); + row.getElement().getStyle().setProperty("cursor", "pointer"); + + // Checkbox for selection + HorizontalPanel checkboxContainer = new HorizontalPanel(); + checkboxContainer.setVerticalAlignment(HasVerticalAlignment.ALIGN_MIDDLE); + checkboxContainer.getElement().getStyle().setProperty("marginRight", "12px"); + + final CheckBox checkBox = new CheckBox(); + checkBox.getElement().getStyle().setProperty("cursor", "pointer"); + assetCheckBoxes.add(checkBox); + checkboxContainer.add(checkBox); + row.add(checkboxContainer); + + // Asset preview/icon with container + HorizontalPanel previewContainer = new HorizontalPanel(); + previewContainer.setVerticalAlignment(HasVerticalAlignment.ALIGN_MIDDLE); + previewContainer.getElement().getStyle().setProperty("marginRight", "12px"); + + Widget previewWidget = createPreviewWidget(asset); + previewContainer.add(previewWidget); + row.add(previewContainer); + + // Asset name and details + VerticalPanel detailsPanel = new VerticalPanel(); + detailsPanel.getElement().getStyle().setProperty("flex", "1"); + detailsPanel.setSpacing(2); + + // Asset name with type indicator + HorizontalPanel nameRow = new HorizontalPanel(); + nameRow.setVerticalAlignment(HasVerticalAlignment.ALIGN_MIDDLE); + + Label nameLabel = new Label(asset.getFileName()); + nameLabel.setStyleName("ode-ComponentRowLabel"); + nameLabel.getElement().getStyle().setProperty("fontSize", "15px"); + nameLabel.getElement().getStyle().setProperty("fontWeight", "500"); + nameLabel.getElement().getStyle().setProperty("marginRight", "8px"); + nameRow.add(nameLabel); + + // Add file type badge + String fileExt = getFileExtension(asset.getFileName()).toLowerCase(); + String badgeColor = getFileTypeBadgeColor(fileExt); + Label typeBadge = new Label(fileExt.toUpperCase()); + typeBadge.getElement().getStyle().setProperty("fontSize", "10px"); + typeBadge.getElement().getStyle().setProperty("fontWeight", "600"); + typeBadge.getElement().getStyle().setProperty("color", "white"); + typeBadge.getElement().getStyle().setProperty("backgroundColor", badgeColor); + typeBadge.getElement().getStyle().setProperty("padding", "2px 6px"); + typeBadge.getElement().getStyle().setProperty("borderRadius", "3px"); + typeBadge.getElement().getStyle().setProperty("textTransform", "uppercase"); + nameRow.add(typeBadge); + + detailsPanel.add(nameRow); + + // Enhanced metadata row with version and usage info + HorizontalPanel metadataRow = new HorizontalPanel(); + metadataRow.setVerticalAlignment(HasVerticalAlignment.ALIGN_MIDDLE); + + Label dateLabel = new Label(" " + formatDate(asset.getTimestamp())); + dateLabel.setStyleName("ode-ComponentRowLabel"); + dateLabel.getElement().getStyle().setProperty("fontSize", "12px"); + dateLabel.getElement().getStyle().setProperty("color", "#586069"); + dateLabel.getElement().getStyle().setProperty("marginRight", "12px"); + metadataRow.add(dateLabel); + + // Add folder info if exists + if (asset.getFolder() != null && !asset.getFolder().isEmpty()) { + Label folderLabel = new Label(" " + asset.getFolder()); + folderLabel.setStyleName("ode-ComponentRowLabel"); + folderLabel.getElement().getStyle().setProperty("fontSize", "12px"); + folderLabel.getElement().getStyle().setProperty("color", "#586069"); + folderLabel.getElement().getStyle().setProperty("marginRight", "12px"); + metadataRow.add(folderLabel); + } + + detailsPanel.add(metadataRow); + + // Project usage status row + HorizontalPanel statusRow = new HorizontalPanel(); + statusRow.setVerticalAlignment(HasVerticalAlignment.ALIGN_MIDDLE); + statusRow.getElement().getStyle().setProperty("marginTop", "4px"); + + // Add project usage indicator with async loading + final Label usageIndicator = new Label(" Checking usage..."); + usageIndicator.setStyleName("ode-ComponentRowLabel"); + usageIndicator.getElement().getStyle().setProperty("fontSize", "11px"); + usageIndicator.getElement().getStyle().setProperty("color", "#6c757d"); + usageIndicator.getElement().getStyle().setProperty("marginRight", "8px"); + statusRow.add(usageIndicator); + + // Load project usage asynchronously + globalAssetService.getProjectsUsingAsset(asset.getFileName(), new AsyncCallback>() { + @Override + public void onSuccess(List projectIds) { + if (projectIds != null && !projectIds.isEmpty()) { + usageIndicator.setText(" Used by " + projectIds.size() + " project" + (projectIds.size() == 1 ? "" : "s")); + usageIndicator.getElement().getStyle().setProperty("color", "#007bff"); + usageIndicator.setTitle("This asset is linked to " + projectIds.size() + " project(s)"); + } else { + usageIndicator.setText(" Not in use"); + usageIndicator.getElement().getStyle().setProperty("color", "#6c757d"); + usageIndicator.setTitle("This asset is not currently used by any projects"); + } + } + + @Override + public void onFailure(Throwable caught) { + usageIndicator.setText(" Unknown usage"); + usageIndicator.getElement().getStyle().setProperty("color", "#dc3545"); + } + }); + + // Version indicator (based on timestamp for now) + String versionText = getVersionText(asset.getTimestamp()); + Label versionLabel = new Label(" " + versionText); + versionLabel.setStyleName("ode-ComponentRowLabel"); + versionLabel.getElement().getStyle().setProperty("fontSize", "11px"); + versionLabel.getElement().getStyle().setProperty("color", getVersionColor(asset.getTimestamp())); + versionLabel.setTitle("Asset version: " + versionText); + statusRow.add(versionLabel); + + detailsPanel.add(statusRow); + + row.add(detailsPanel); + row.setCellWidth(detailsPanel, "100%"); + + // Action buttons with improved styling + HorizontalPanel actionPanel = new HorizontalPanel(); + actionPanel.setVerticalAlignment(HasVerticalAlignment.ALIGN_MIDDLE); + actionPanel.setSpacing(4); + + // Preview button + Button previewBtn = new Button("Preview"); + previewBtn.setStyleName("ode-ProjectListButton"); + previewBtn.getElement().getStyle().setProperty("padding", "4px 8px"); + previewBtn.getElement().getStyle().setProperty("fontSize", "12px"); + previewBtn.getElement().getStyle().setProperty("minWidth", "60px"); + previewBtn.addClickHandler(new ClickHandler() { + @Override + public void onClick(ClickEvent event) { + event.stopPropagation(); + previewAsset(asset); + } + }); + actionPanel.add(previewBtn); + + // Update version button + Button updateBtn = new Button("Update"); + updateBtn.setTitle("Upload New Version"); + updateBtn.setStyleName("ode-ProjectListButton"); + updateBtn.getElement().getStyle().setProperty("padding", "4px 8px"); + updateBtn.getElement().getStyle().setProperty("fontSize", "12px"); + updateBtn.getElement().getStyle().setProperty("minWidth", "60px"); + updateBtn.addClickHandler(new ClickHandler() { + @Override + public void onClick(ClickEvent event) { + event.stopPropagation(); + showUpdateAssetDialog(asset); + } + }); + actionPanel.add(updateBtn); + + // Add button + Button addBtn = new Button("Add"); + addBtn.setStyleName("ode-ProjectListButton"); + addBtn.getElement().getStyle().setProperty("padding", "4px 8px"); + addBtn.getElement().getStyle().setProperty("fontSize", "12px"); + addBtn.getElement().getStyle().setProperty("minWidth", "50px"); + addBtn.addClickHandler(new ClickHandler() { + @Override + public void onClick(ClickEvent event) { + event.stopPropagation(); + showAddToProjectDialog(asset); + } + }); + actionPanel.add(addBtn); + + // Delete button + Button deleteBtn = new Button("Delete"); + deleteBtn.setStyleName("ode-ProjectListButton"); + deleteBtn.getElement().getStyle().setProperty("padding", "4px 8px"); + deleteBtn.getElement().getStyle().setProperty("fontSize", "12px"); + deleteBtn.getElement().getStyle().setProperty("minWidth", "60px"); + deleteBtn.addClickHandler(new ClickHandler() { + @Override + public void onClick(ClickEvent event) { + event.stopPropagation(); + if (Window.confirm("Are you sure you want to delete '" + asset.getFileName() + "'?")) { + deleteAsset(asset); + } + } + }); + actionPanel.add(deleteBtn); + + row.add(actionPanel); + + // Drag and drop support + setupAssetDragDrop(row, asset); + + + // Checkbox change handler + checkBox.addClickHandler(new ClickHandler() { + @Override + public void onClick(ClickEvent event) { + event.stopPropagation(); + updateBulkActionButtons(); + } + }); + + return row; + } + + + private String getFileExtension(String fileName) { + int lastDot = fileName.lastIndexOf('.'); + return lastDot > 0 ? fileName.substring(lastDot + 1) : ""; + } + + private String getFileTypeBadgeColor(String extension) { + switch (extension.toLowerCase()) { + case "png": + case "jpg": + case "jpeg": + case "gif": + return "#28a745"; // Green for images + case "mp3": + case "wav": + case "ogg": + return "#007bff"; // Blue for audio + default: + return "#6c757d"; // Gray for other + } + } + + private Widget createPreviewWidget(GlobalAsset asset) { + String fileName = asset.getFileName().toLowerCase(); + String filePath = asset.getFolder() != null ? asset.getFolder() + "/" + asset.getFileName() : asset.getFileName(); + + // Preview container + HorizontalPanel previewContainer = new HorizontalPanel(); + previewContainer.setVerticalAlignment(HasVerticalAlignment.ALIGN_MIDDLE); + previewContainer.getElement().getStyle().setProperty("width", "48px"); + previewContainer.getElement().getStyle().setProperty("height", "48px"); + previewContainer.getElement().getStyle().setProperty("borderRadius", "4px"); + previewContainer.getElement().getStyle().setProperty("border", "1px solid #ddd"); + previewContainer.getElement().getStyle().setProperty("justifyContent", "center"); + previewContainer.getElement().getStyle().setProperty("alignItems", "center"); + previewContainer.getElement().getStyle().setProperty("overflow", "hidden"); + + if (StorageUtil.isImageFile(filePath)) { + // Create image preview with cache-busting parameter + String imageUrl = "/ode/download/globalasset/" + asset.getFileName() + "?t=" + asset.getTimestamp(); + Image img = new Image(imageUrl); + img.setWidth("44px"); + img.setHeight("44px"); + img.getElement().getStyle().setProperty("objectFit", "cover"); + img.getElement().getStyle().setProperty("borderRadius", "3px"); + previewContainer.add(img); + } else { + // Use icon for non-image files + Image iconImg; + if (StorageUtil.isAudioFile(filePath)) { + iconImg = new Image(images.player()); + } else if (StorageUtil.isVideoFile(filePath)) { + iconImg = new Image(images.image()); // Use image icon for video for now + } else { + iconImg = new Image(images.image()); + } + + iconImg.setWidth("32px"); + iconImg.setHeight("32px"); + previewContainer.add(iconImg); + } + + return previewContainer; + } + + private void previewAsset(GlobalAsset asset) { + GlobalAssetProjectNode projectNode = new GlobalAssetProjectNode( + asset.getFileName(), + asset.getFileName() + ); + + PreviewFileCommand previewCommand = new PreviewFileCommand(); + if (previewCommand.isSupported(projectNode)) { + previewCommand.execute(projectNode); + } else { + Window.alert("Preview not supported for this file type: " + asset.getFileName()); + } + } + + private void setupAssetDragDrop(HorizontalPanel row, GlobalAsset asset) { + row.getElement().setAttribute("draggable", "true"); + + row.addDomHandler(new DragStartHandler() { + @Override + public void onDragStart(DragStartEvent event) { + event.setData("text/plain", asset.getFileName()); + draggedAssetName = asset.getFileName(); + } + }, DragStartEvent.getType()); + } + + private String formatDate(long timestamp) { + java.util.Date date = new java.util.Date(timestamp); + String dateStr = date.toString(); + return dateStr.substring(4, 10); // "MMM dd" + } + + private String getVersionText(long timestamp) { + long now = System.currentTimeMillis(); + long ageMillis = now - timestamp; + + // Convert to days + long ageDays = ageMillis / (24 * 60 * 60 * 1000); + + if (ageDays == 0) { + return "Today"; + } else if (ageDays == 1) { + return "Yesterday"; + } else if (ageDays < 7) { + return ageDays + " days ago"; + } else if (ageDays < 30) { + long weeks = ageDays / 7; + return weeks + " week" + (weeks == 1 ? "" : "s") + " ago"; + } else if (ageDays < 365) { + long months = ageDays / 30; + return months + " month" + (months == 1 ? "" : "s") + " ago"; + } else { + long years = ageDays / 365; + return years + " year" + (years == 1 ? "" : "s") + " ago"; + } + } + + private String getVersionColor(long timestamp) { + long now = System.currentTimeMillis(); + long ageMillis = now - timestamp; + long ageDays = ageMillis / (24 * 60 * 60 * 1000); + + if (ageDays == 0) { + return "#28a745"; // Green for today + } else if (ageDays < 7) { + return "#007bff"; // Blue for this week + } else if (ageDays < 30) { + return "#ffc107"; // Yellow for this month + } else { + return "#6c757d"; // Gray for older + } + } + + private List getSelectedAssets() { + List selected = new ArrayList<>(); + String searchText = searchBox.getText().toLowerCase(); + String typeFilter = this.typeFilter.getSelectedValue(); + String folderFilter = selectedFolderIndex >= 0 && selectedFolderIndex < folders.size() + ? folders.get(selectedFolderIndex) : "All Assets"; + List filteredAssets = filterAssets(searchText, typeFilter, folderFilter); + + for (int i = 0; i < assetCheckBoxes.size() && i < filteredAssets.size(); i++) { + if (assetCheckBoxes.get(i).getValue()) { + selected.add(filteredAssets.get(i)); + } + } + return selected; + } + + private void updateBulkActionButtons() { + int selectedCount = 0; + for (CheckBox checkBox : assetCheckBoxes) { + if (checkBox.getValue()) { + selectedCount++; + } + } + + boolean hasSelection = selectedCount > 0; + addSelectedButton.setEnabled(hasSelection); + deleteSelectedButton.setEnabled(hasSelection); + + // Update button text with count + if (hasSelection) { + addSelectedButton.setText("Add " + selectedCount + " Selected"); + deleteSelectedButton.setText("Delete " + selectedCount + " Selected"); + } else { + addSelectedButton.setText("Add Selected"); + deleteSelectedButton.setText("Delete Selected"); + } + } + + + // Asset operations + private void deleteAsset(GlobalAsset asset) { + globalAssetService.deleteGlobalAsset(asset.getFileName(), new AsyncCallback() { + @Override + public void onSuccess(Void result) { + refreshGlobalAssets(); + } + + @Override + public void onFailure(Throwable caught) { + showDeleteError("Cannot Delete Asset", caught.getMessage()); + } + }); + } + + private void deleteSelectedAssets(List assets) { + for (GlobalAsset asset : assets) { + deleteAsset(asset); + } + } + + private void moveAssetToFolder(String assetName, String folderName) { + globalAssetService.updateGlobalAssetFolder(assetName, folderName, new AsyncCallback() { + @Override + public void onSuccess(Void result) { + refreshGlobalAssets(); + } + + @Override + public void onFailure(Throwable caught) { + Window.alert("Failed to move asset: " + caught.getMessage()); + } + }); + } + + // Dialog methods + private void showUploadDialog() { + final DialogBox dialog = new DialogBox(); + dialog.setText("Upload Asset to Library"); + dialog.setStyleName("ode-DialogBox"); + dialog.setModal(true); + dialog.setGlassEnabled(true); + + VerticalPanel dialogPanel = new VerticalPanel(); + dialogPanel.setSpacing(8); + + // Upload form + final FormPanel form = new FormPanel(); + form.setEncoding(FormPanel.ENCODING_MULTIPART); + form.setMethod(FormPanel.METHOD_POST); + + VerticalPanel formPanel = new VerticalPanel(); + formPanel.setSpacing(4); + + Label fileLabel = new Label("Select file to upload:"); + fileLabel.setStyleName("ode-ComponentRowLabel"); + formPanel.add(fileLabel); + + final FileUpload fileUpload = new FileUpload(); + fileUpload.setName(ServerLayout.UPLOAD_GLOBAL_ASSET_FORM_ELEMENT); + fileUpload.setStyleName("ode-TextBox"); + formPanel.add(fileUpload); + + final Label errorLabel = new Label(); + formPanel.add(errorLabel); + + form.setWidget(formPanel); + dialogPanel.add(form); + + // Button panel + HorizontalPanel buttonPanel = new HorizontalPanel(); + buttonPanel.setSpacing(4); + + Button cancelBtn = new Button("Cancel"); + cancelBtn.setStyleName("ode-ProjectListButton"); + + Button uploadBtn = new Button("Upload"); + uploadBtn.setStyleName("ode-ProjectListButton"); + + buttonPanel.add(cancelBtn); + buttonPanel.add(uploadBtn); + dialogPanel.add(buttonPanel); + + dialog.setWidget(dialogPanel); + + // Event handlers + uploadBtn.addClickHandler(new ClickHandler() { + @Override + public void onClick(ClickEvent event) { + String filename = fileUpload.getFilename(); + if (filename == null || filename.isEmpty()) { + showInlineError(errorLabel, "⚠ Please select a file to upload."); + return; + } + + String lower = filename.toLowerCase(); + if (!(lower.endsWith(".png") || lower.endsWith(".jpg") || lower.endsWith(".jpeg") || + lower.endsWith(".gif") || lower.endsWith(".mp3") || lower.endsWith(".wav") || + lower.endsWith(".ogg") || lower.endsWith(".ttf") || lower.endsWith(".otf") || + lower.endsWith(".mp4") || lower.endsWith(".avi") || lower.endsWith(".webm"))) { + showInlineError(errorLabel, "⚠ Unsupported file type. Supported formats: PNG, JPG, GIF, MP3, WAV, OGG, TTF, OTF, MP4, AVI, WebM"); + return; + } + + // Clear any previous errors + clearInlineError(errorLabel); + + // Check if asset already exists and handle conflicts + checkAssetExistsAndProceed(filename, dialog, form, fileUpload); + } + }); + + form.addSubmitCompleteHandler(new SubmitCompleteHandler() { + @Override + public void onSubmitComplete(SubmitCompleteEvent event) { + dialog.hide(); + String results = event.getResults(); + if (results != null && results.contains("SUCCESS")) { + refreshGlobalAssets(); + } else { + showUploadError(MESSAGES.fileUploadError(), "Please check your file and try again."); + } + } + }); + + cancelBtn.addClickHandler(new ClickHandler() { + @Override + public void onClick(ClickEvent event) { + dialog.hide(); + } + }); + + dialog.center(); + dialog.show(); + } + + private void checkAssetExistsAndProceed(String filename, DialogBox uploadDialog, FormPanel form, FileUpload fileUpload) { + // Check if asset with same name already exists + GlobalAsset existingAsset = null; + for (GlobalAsset asset : globalAssets) { + if (asset.getFileName().equals(filename)) { + existingAsset = asset; + break; + } + } + + if (existingAsset != null) { + // Show enhanced confirmation dialog with project usage info + showAssetUpdateConfirmationDialog(filename, existingAsset, uploadDialog, form, fileUpload); + } else { + // Proceed with upload + proceedWithUpload(filename, form); + } + } + + private void showAssetUpdateConfirmationDialog(String filename, GlobalAsset existingAsset, + DialogBox uploadDialog, FormPanel form, FileUpload fileUpload) { + final DialogBox confirmDialog = new DialogBox(); + confirmDialog.setText("Asset Already Exists"); + confirmDialog.setStyleName("ode-DialogBox"); + confirmDialog.setModal(true); + confirmDialog.setGlassEnabled(true); + + VerticalPanel dialogPanel = new VerticalPanel(); + dialogPanel.setSpacing(12); + dialogPanel.getElement().getStyle().setProperty("minWidth", "450px"); + + // Warning header with icon + HorizontalPanel warningHeader = new HorizontalPanel(); + warningHeader.setVerticalAlignment(HasVerticalAlignment.ALIGN_MIDDLE); + warningHeader.getElement().getStyle().setProperty("backgroundColor", "#fff3cd"); + warningHeader.getElement().getStyle().setProperty("padding", "12px"); + warningHeader.getElement().getStyle().setProperty("borderRadius", "6px"); + warningHeader.getElement().getStyle().setProperty("border", "1px solid #ffeaa7"); + + Label warningIcon = new Label("[warning]"); + warningIcon.getElement().getStyle().setProperty("fontSize", "24px"); + warningIcon.getElement().getStyle().setProperty("marginRight", "10px"); + warningHeader.add(warningIcon); + + VerticalPanel warningText = new VerticalPanel(); + Label warningTitle = new Label("Asset '" + filename + "' already exists"); + warningTitle.setStyleName("ode-ComponentRowLabel"); + warningTitle.getElement().getStyle().setProperty("fontWeight", "600"); + warningTitle.getElement().getStyle().setProperty("fontSize", "16px"); + warningTitle.getElement().getStyle().setProperty("color", "#856404"); + warningText.add(warningTitle); + + Label warningSubtitle = new Label("What would you like to do?"); + warningSubtitle.setStyleName("ode-ComponentRowLabel"); + warningSubtitle.getElement().getStyle().setProperty("fontSize", "14px"); + warningSubtitle.getElement().getStyle().setProperty("color", "#856404"); + warningText.add(warningSubtitle); + + warningHeader.add(warningText); + dialogPanel.add(warningHeader); + + // Asset info panel + HorizontalPanel assetInfo = new HorizontalPanel(); + assetInfo.setVerticalAlignment(HasVerticalAlignment.ALIGN_MIDDLE); + assetInfo.getElement().getStyle().setProperty("backgroundColor", "#f8f9fa"); + assetInfo.getElement().getStyle().setProperty("padding", "12px"); + assetInfo.getElement().getStyle().setProperty("borderRadius", "6px"); + assetInfo.getElement().getStyle().setProperty("border", "1px solid #e9ecef"); + + Widget previewWidget = createPreviewWidget(existingAsset); + assetInfo.add(previewWidget); + + VerticalPanel assetDetails = new VerticalPanel(); + assetDetails.getElement().getStyle().setProperty("marginLeft", "12px"); + + Label assetName = new Label(existingAsset.getFileName()); + assetName.setStyleName("ode-ComponentRowLabel"); + assetName.getElement().getStyle().setProperty("fontWeight", "600"); + assetName.getElement().getStyle().setProperty("fontSize", "15px"); + assetDetails.add(assetName); + + Label assetDate = new Label("Last modified: " + formatDate(existingAsset.getTimestamp())); + assetDate.setStyleName("ode-ComponentRowLabel"); + assetDate.getElement().getStyle().setProperty("fontSize", "12px"); + assetDate.getElement().getStyle().setProperty("color", "#6c757d"); + assetDetails.add(assetDate); + + if (existingAsset.getFolder() != null && !existingAsset.getFolder().isEmpty()) { + Label assetFolder = new Label("Folder: " + existingAsset.getFolder()); + assetFolder.setStyleName("ode-ComponentRowLabel"); + assetFolder.getElement().getStyle().setProperty("fontSize", "12px"); + assetFolder.getElement().getStyle().setProperty("color", "#6c757d"); + assetDetails.add(assetFolder); + } + + assetInfo.add(assetDetails); + dialogPanel.add(assetInfo); + + // Project usage info - enhanced with async loading + VerticalPanel usagePanel = new VerticalPanel(); + usagePanel.setWidth("100%"); + usagePanel.getElement().getStyle().setProperty("backgroundColor", "#e3f2fd"); + usagePanel.getElement().getStyle().setProperty("padding", "12px"); + usagePanel.getElement().getStyle().setProperty("borderRadius", "6px"); + usagePanel.getElement().getStyle().setProperty("border", "1px solid #bbdefb"); + + Label usageTitle = new Label(" Project Usage Impact"); + usageTitle.setStyleName("ode-ComponentRowLabel"); + usageTitle.getElement().getStyle().setProperty("fontWeight", "600"); + usageTitle.getElement().getStyle().setProperty("fontSize", "14px"); + usageTitle.getElement().getStyle().setProperty("color", "#1565c0"); + usagePanel.add(usageTitle); + + final Label usageInfo = new Label("Checking projects using this asset..."); + usageInfo.setStyleName("ode-ComponentRowLabel"); + usageInfo.getElement().getStyle().setProperty("fontSize", "13px"); + usageInfo.getElement().getStyle().setProperty("color", "#1976d2"); + usagePanel.add(usageInfo); + + dialogPanel.add(usagePanel); + + // Load project usage info asynchronously + globalAssetService.getProjectsUsingAsset(filename, new AsyncCallback>() { + @Override + public void onSuccess(List projectIds) { + if (projectIds != null && !projectIds.isEmpty()) { + usageInfo.setText("[warning] This asset is used by " + projectIds.size() + + " project(s). Updating will affect all projects using it."); + usageInfo.getElement().getStyle().setProperty("color", "#d32f2f"); + } else { + usageInfo.setText(" This asset is not currently used by any projects."); + usageInfo.getElement().getStyle().setProperty("color", "#388e3c"); + } + } + + @Override + public void onFailure(Throwable caught) { + usageInfo.setText("Unable to check project usage."); + usageInfo.getElement().getStyle().setProperty("color", "#f57c00"); + } + }); + + // Options section + Label optionsLabel = new Label("Choose your action:"); + optionsLabel.setStyleName("ode-ComponentRowLabel"); + optionsLabel.getElement().getStyle().setProperty("fontWeight", "600"); + optionsLabel.getElement().getStyle().setProperty("fontSize", "15px"); + optionsLabel.getElement().getStyle().setProperty("marginTop", "8px"); + dialogPanel.add(optionsLabel); + + // Option 1: Replace existing (creates new version) + final RadioButton replaceRadio = new RadioButton("updateOption", " Replace Existing Asset (Create New Version)"); + replaceRadio.setValue(true); + replaceRadio.setStyleName("ode-ComponentRowLabel"); + replaceRadio.getElement().getStyle().setProperty("marginTop", "8px"); + dialogPanel.add(replaceRadio); + + Label replaceDesc = new Label(" Updates the asset with your new file"); + replaceDesc.setStyleName("ode-ComponentRowLabel"); + replaceDesc.getElement().getStyle().setProperty("fontSize", "12px"); + replaceDesc.getElement().getStyle().setProperty("color", "#666666"); + replaceDesc.getElement().getStyle().setProperty("marginLeft", "24px"); + dialogPanel.add(replaceDesc); + + Label replaceDesc2 = new Label(" All projects using this asset will get the updated version"); + replaceDesc2.setStyleName("ode-ComponentRowLabel"); + replaceDesc2.getElement().getStyle().setProperty("fontSize", "12px"); + replaceDesc2.getElement().getStyle().setProperty("color", "#666666"); + replaceDesc2.getElement().getStyle().setProperty("marginLeft", "24px"); + dialogPanel.add(replaceDesc2); + + Label replaceDesc3 = new Label(" Previous version will be overwritten (cannot be undone)"); + replaceDesc3.setStyleName("ode-ComponentRowLabel"); + replaceDesc3.getElement().getStyle().setProperty("fontSize", "12px"); + replaceDesc3.getElement().getStyle().setProperty("color", "#d32f2f"); + replaceDesc3.getElement().getStyle().setProperty("marginLeft", "24px"); + dialogPanel.add(replaceDesc3); + + // Option 2: Save with different name + final RadioButton renameRadio = new RadioButton("updateOption", " Save with Different Name"); + renameRadio.setStyleName("ode-ComponentRowLabel"); + renameRadio.getElement().getStyle().setProperty("marginTop", "12px"); + dialogPanel.add(renameRadio); + + Label renameDesc = new Label(" Creates a new asset without affecting the existing one"); + renameDesc.setStyleName("ode-ComponentRowLabel"); + renameDesc.getElement().getStyle().setProperty("fontSize", "12px"); + renameDesc.getElement().getStyle().setProperty("color", "#666666"); + renameDesc.getElement().getStyle().setProperty("marginLeft", "24px"); + dialogPanel.add(renameDesc); + + Label renameDesc2 = new Label(" Existing projects will continue using the current version"); + renameDesc2.setStyleName("ode-ComponentRowLabel"); + renameDesc2.getElement().getStyle().setProperty("fontSize", "12px"); + renameDesc2.getElement().getStyle().setProperty("color", "#666666"); + renameDesc2.getElement().getStyle().setProperty("marginLeft", "24px"); + dialogPanel.add(renameDesc2); + + // Name input for rename option + HorizontalPanel namePanel = new HorizontalPanel(); + namePanel.setVerticalAlignment(HasVerticalAlignment.ALIGN_MIDDLE); + namePanel.getElement().getStyle().setProperty("marginLeft", "24px"); + namePanel.getElement().getStyle().setProperty("marginTop", "8px"); + + Label nameLabel = new Label("New name: "); + nameLabel.setStyleName("ode-ComponentRowLabel"); + nameLabel.getElement().getStyle().setProperty("fontSize", "12px"); + namePanel.add(nameLabel); + + final TextBox nameBox = new TextBox(); + nameBox.setText(suggestNewAssetName(filename)); + nameBox.setStyleName("ode-TextBox"); + nameBox.setEnabled(false); + nameBox.getElement().getStyle().setProperty("fontSize", "12px"); + nameBox.getElement().getStyle().setProperty("width", "200px"); + namePanel.add(nameBox); + + dialogPanel.add(namePanel); + + // Enable/disable name input based on radio selection + replaceRadio.addClickHandler(new ClickHandler() { + @Override + public void onClick(ClickEvent event) { + nameBox.setEnabled(false); + } + }); + + renameRadio.addClickHandler(new ClickHandler() { + @Override + public void onClick(ClickEvent event) { + nameBox.setEnabled(true); + nameBox.setFocus(true); + } + }); + + // Button panel + HorizontalPanel buttonPanel = new HorizontalPanel(); + buttonPanel.setSpacing(8); + buttonPanel.getElement().getStyle().setProperty("marginTop", "20px"); + + Button cancelBtn = new Button("Cancel"); + cancelBtn.setStyleName("ode-ProjectListButton"); + cancelBtn.getElement().getStyle().setProperty("backgroundColor", "#6c757d"); + cancelBtn.getElement().getStyle().setProperty("color", "white"); + + Button proceedBtn = new Button("Proceed"); + proceedBtn.setStyleName("ode-ProjectListButton"); + proceedBtn.getElement().getStyle().setProperty("backgroundColor", "#007bff"); + proceedBtn.getElement().getStyle().setProperty("color", "white"); + proceedBtn.getElement().getStyle().setProperty("fontWeight", "600"); + + buttonPanel.add(cancelBtn); + buttonPanel.add(proceedBtn); + dialogPanel.add(buttonPanel); + + confirmDialog.setWidget(dialogPanel); + + // Event handlers + proceedBtn.addClickHandler(new ClickHandler() { + @Override + public void onClick(ClickEvent event) { + if (replaceRadio.getValue()) { + // Replace existing asset + confirmDialog.hide(); + proceedWithUpload(filename, form); + } else if (renameRadio.getValue()) { + // Save with new name + String newName = nameBox.getText().trim(); + if (newName.isEmpty()) { + Window.alert("Please enter a new name for the asset."); + return; + } + if (newName.equals(filename)) { + Window.alert("New name must be different from the existing asset name."); + return; + } + + // Check if new name already exists + boolean nameExists = false; + for (GlobalAsset asset : globalAssets) { + if (asset.getFileName().equals(newName)) { + nameExists = true; + break; + } + } + + if (nameExists) { + Window.alert("An asset with the name '" + newName + "' already exists. Please choose a different name."); + return; + } + + confirmDialog.hide(); + proceedWithUpload(newName, form); + } + } + }); + + cancelBtn.addClickHandler(new ClickHandler() { + @Override + public void onClick(ClickEvent event) { + confirmDialog.hide(); + } + }); + + confirmDialog.center(); + confirmDialog.show(); + } + + private String suggestNewAssetName(String originalName) { + String baseName = originalName; + String extension = ""; + + int lastDot = originalName.lastIndexOf('.'); + if (lastDot > 0) { + baseName = originalName.substring(0, lastDot); + extension = originalName.substring(lastDot); + } + + // Try numbered variations + for (int i = 2; i <= 100; i++) { + String suggestion = baseName + "_" + i + extension; + boolean exists = false; + for (GlobalAsset asset : globalAssets) { + if (asset.getFileName().equals(suggestion)) { + exists = true; + break; + } + } + if (!exists) { + return suggestion; + } + } + + // Fallback with timestamp + return baseName + "_" + System.currentTimeMillis() + extension; + } + + private void proceedWithUpload(String filename, FormPanel form) { + String actualFilename = filename; + if (filename.contains("\\")) { + actualFilename = filename.substring(filename.lastIndexOf("\\") + 1); + } else if (filename.contains("/")) { + actualFilename = filename.substring(filename.lastIndexOf("/") + 1); + } + + final String validatedFilename = makeValidFilename(actualFilename); + if (!TextValidators.isValidCharFilename(validatedFilename)) { + showUploadError(MESSAGES.malformedFilenameTitle(), MESSAGES.malformedFilename()); + return; + } else if (!TextValidators.isValidLengthFilename(validatedFilename)) { + showUploadError(MESSAGES.filenameBadSizeTitle(), MESSAGES.filenameBadSize()); + return; + } + + actualFilename = validatedFilename; + + // Get the currently selected folder + String targetFolder = ""; + if (selectedFolderIndex >= 0 && selectedFolderIndex < folders.size()) { + String selectedFolder = folders.get(selectedFolderIndex); + // Don't use special folders as target folders - use empty string for root + if (!isSpecialFolder(selectedFolder)) { + targetFolder = selectedFolder; + } + } + + // Construct proper upload path: _global_/folder/filename or _global_/filename + String uploadPath = "_global_/"; + if (!targetFolder.isEmpty()) { + uploadPath += targetFolder + "/"; + } + uploadPath += actualFilename; + + form.setAction(GWT.getModuleBaseURL() + "upload/" + ServerLayout.UPLOAD_GLOBAL_ASSET + "/" + uploadPath); + form.submit(); + } + + private void showAddToProjectDialog(final GlobalAsset asset) { + final DialogBox dialog = new DialogBox(); + dialog.setText("Add Asset to Project"); + dialog.setStyleName("ode-DialogBox"); + dialog.setModal(true); + dialog.setGlassEnabled(true); + + VerticalPanel dialogPanel = new VerticalPanel(); + dialogPanel.setSpacing(8); + + // Asset info + HorizontalPanel assetInfo = new HorizontalPanel(); + assetInfo.setVerticalAlignment(HasVerticalAlignment.ALIGN_MIDDLE); + + String fileName = asset.getFileName().toLowerCase(); + ImageResource iconRes = fileName.endsWith(".mp3") || fileName.endsWith(".wav") || fileName.endsWith(".ogg") + ? images.player() + : images.image(); + + Image assetIcon = new Image(iconRes); + assetInfo.add(assetIcon); + + assetInfo.add(new HTML(" ")); + + Label assetName = new Label(asset.getFileName()); + assetName.setStyleName("ode-ComponentRowLabel"); + assetInfo.add(assetName); + + dialogPanel.add(assetInfo); + + // Options + Label optionsLabel = new Label("How would you like to add this asset?"); + optionsLabel.setStyleName("ode-ComponentRowLabel"); + dialogPanel.add(optionsLabel); + + final RadioButton trackRadio = new RadioButton("addOption", "Track Asset (Recommended)"); + trackRadio.setValue(true); + trackRadio.setStyleName("ode-ComponentRowLabel"); + dialogPanel.add(trackRadio); + + Label trackDesc = new Label("The asset will be updated in your project if the library version changes."); + trackDesc.setStyleName("ode-ComponentRowLabel"); + dialogPanel.add(trackDesc); + + final RadioButton copyRadio = new RadioButton("addOption", "Copy Asset"); + copyRadio.setStyleName("ode-ComponentRowLabel"); + dialogPanel.add(copyRadio); + + Label copyDesc = new Label("A copy will be added to your project and will not be updated."); + copyDesc.setStyleName("ode-ComponentRowLabel"); + dialogPanel.add(copyDesc); + + // Button panel + HorizontalPanel buttonPanel = new HorizontalPanel(); + buttonPanel.setSpacing(4); + + Button cancelBtn = new Button("Cancel"); + cancelBtn.setStyleName("ode-ProjectListButton"); + + Button addBtn = new Button("Add to Project"); + addBtn.setStyleName("ode-ProjectListButton"); + + buttonPanel.add(cancelBtn); + buttonPanel.add(addBtn); + dialogPanel.add(buttonPanel); + + dialog.setWidget(dialogPanel); + + // Event handlers + addBtn.addClickHandler(new ClickHandler() { + @Override + public void onClick(ClickEvent event) { + boolean track = trackRadio.getValue(); + long projectId = Ode.getInstance().getCurrentYoungAndroidProjectId(); + + globalAssetService.importAssetIntoProject(asset.getFileName(), String.valueOf(projectId), track, + new AsyncCallback() { + @Override + public void onSuccess(Void result) { + dialog.hide(); + + // Manually create and add the project node for the imported asset + Project project = Ode.getInstance().getProjectManager().getProject(projectId); + YoungAndroidProjectNode projectNode = (YoungAndroidProjectNode) project.getRootNode(); + YoungAndroidAssetsFolder assetsFolder = projectNode.getAssetsFolder(); + + // Create the asset node with the full imported path + String assetName = asset.getFileName(); + String fullAssetPath = "assets/_global_/"; + if (asset.getFolder() != null && !asset.getFolder().isEmpty()) { + fullAssetPath += asset.getFolder() + "/"; + } + fullAssetPath += assetName; + + YoungAndroidAssetNode assetNode = new YoungAndroidAssetNode(assetName, fullAssetPath); + project.addNode(assetsFolder, assetNode); + + // Refresh the project asset list and asset manager to make the asset visible + Ode.getInstance().getAssetListBox().getAssetList().refreshAssetList(projectId); + Ode.getInstance().getAssetManager().loadAssets(projectId); + } + + @Override + public void onFailure(Throwable caught) { + Window.alert("Failed to add asset to project: " + caught.getMessage()); + } + }); + } + }); + + cancelBtn.addClickHandler(new ClickHandler() { + @Override + public void onClick(ClickEvent event) { + dialog.hide(); + } + }); + + dialog.center(); + dialog.show(); + } + + private void showBulkAddToProjectDialog(final List assets) { + final DialogBox dialog = new DialogBox(); + dialog.setText("Add " + assets.size() + " Assets to Project"); + dialog.setStyleName("ode-DialogBox"); + dialog.setModal(true); + dialog.setGlassEnabled(true); + + VerticalPanel dialogPanel = new VerticalPanel(); + dialogPanel.setSpacing(8); + + // Assets list preview + Label assetsLabel = new Label("Selected assets (" + assets.size() + "):"); + assetsLabel.setStyleName("ode-ComponentRowLabel"); + dialogPanel.add(assetsLabel); + + // Scrollable list of asset names + ScrollPanel assetListScroll = new ScrollPanel(); + assetListScroll.setHeight("100px"); + + VerticalPanel assetList = new VerticalPanel(); + for (GlobalAsset asset : assets) { + Label assetName = new Label(" " + asset.getFileName()); + assetName.setStyleName("ode-ComponentRowLabel"); + assetList.add(assetName); + } + assetListScroll.add(assetList); + dialogPanel.add(assetListScroll); + + // Options + Label optionsLabel = new Label("How would you like to add these assets?"); + optionsLabel.setStyleName("ode-ComponentRowLabel"); + dialogPanel.add(optionsLabel); + + final RadioButton trackRadio = new RadioButton("bulkAddOption", "Track Usage (Recommended)"); + trackRadio.setValue(true); + trackRadio.setStyleName("ode-ComponentRowLabel"); + dialogPanel.add(trackRadio); + + Label trackDesc = new Label("Assets will be updated in your project if the library versions change."); + trackDesc.setStyleName("ode-ComponentRowLabel"); + dialogPanel.add(trackDesc); + + final RadioButton copyRadio = new RadioButton("bulkAddOption", "Copy Assets"); + copyRadio.setStyleName("ode-ComponentRowLabel"); + dialogPanel.add(copyRadio); + + Label copyDesc = new Label("Copies will be added to your project and will not be updated."); + copyDesc.setStyleName("ode-ComponentRowLabel"); + dialogPanel.add(copyDesc); + + // Button panel + HorizontalPanel buttonPanel = new HorizontalPanel(); + buttonPanel.setSpacing(4); + + Button cancelBtn = new Button("Cancel"); + cancelBtn.setStyleName("ode-ProjectListButton"); + + final Button addBtn = new Button("Add " + assets.size() + " Assets"); + addBtn.setStyleName("ode-ProjectListButton"); + + buttonPanel.add(cancelBtn); + buttonPanel.add(addBtn); + dialogPanel.add(buttonPanel); + + dialog.setWidget(dialogPanel); + + // Event handlers + addBtn.addClickHandler(new ClickHandler() { + @Override + public void onClick(ClickEvent event) { + boolean track = trackRadio.getValue(); + long projectId = Ode.getInstance().getCurrentYoungAndroidProjectId(); + + // Collect asset filenames + List assetFileNames = new ArrayList<>(); + for (GlobalAsset asset : assets) { + assetFileNames.add(asset.getFileName()); + } + + // Use bulk add method + globalAssetService.bulkAddAssetsToProject(assetFileNames, projectId, track, + new AsyncCallback() { + @Override + public void onSuccess(Void result) { + dialog.hide(); + + // Manually create and add project nodes for all imported assets + Project project = Ode.getInstance().getProjectManager().getProject(projectId); + YoungAndroidProjectNode projectNode = (YoungAndroidProjectNode) project.getRootNode(); + YoungAndroidAssetsFolder assetsFolder = projectNode.getAssetsFolder(); + + for (GlobalAsset asset : assets) { + // Create the asset node with the full imported path + String assetName = asset.getFileName(); + String fullAssetPath = "assets/_global_/"; + if (asset.getFolder() != null && !asset.getFolder().isEmpty()) { + fullAssetPath += asset.getFolder() + "/"; + } + fullAssetPath += assetName; + + YoungAndroidAssetNode assetNode = new YoungAndroidAssetNode(assetName, fullAssetPath); + project.addNode(assetsFolder, assetNode); + } + + // Refresh the project asset list and asset manager to make the assets visible + Ode.getInstance().getAssetListBox().getAssetList().refreshAssetList(projectId); + Ode.getInstance().getAssetManager().loadAssets(projectId); + } + + @Override + public void onFailure(Throwable caught) { + Window.alert("Failed to add assets: " + caught.getMessage()); + } + }); + } + }); + + cancelBtn.addClickHandler(new ClickHandler() { + @Override + public void onClick(ClickEvent event) { + dialog.hide(); + } + }); + + dialog.center(); + dialog.show(); + } + + + + // Folder management dialogs + private void showNewFolderDialog() { + final DialogBox dialog = new DialogBox(); + dialog.setText("Create New Folder"); + dialog.setStyleName("ode-DialogBox"); + dialog.setModal(true); + dialog.setGlassEnabled(true); + + VerticalPanel dialogPanel = new VerticalPanel(); + dialogPanel.setSpacing(8); + + Label nameLabel = new Label("Folder name:"); + nameLabel.setStyleName("ode-ComponentRowLabel"); + dialogPanel.add(nameLabel); + + final TextBox nameBox = new TextBox(); + nameBox.setStyleName("ode-TextBox"); + dialogPanel.add(nameBox); + + HorizontalPanel buttonPanel = new HorizontalPanel(); + buttonPanel.setSpacing(4); + + Button cancelBtn = new Button("Cancel"); + cancelBtn.setStyleName("ode-ProjectListButton"); + + Button createBtn = new Button("Create"); + createBtn.setStyleName("ode-ProjectListButton"); + + buttonPanel.add(cancelBtn); + buttonPanel.add(createBtn); + dialogPanel.add(buttonPanel); + + dialog.setWidget(dialogPanel); + + createBtn.addClickHandler(new ClickHandler() { + @Override + public void onClick(ClickEvent event) { + String folderName = nameBox.getText().trim(); + if (folderName.isEmpty()) { + Window.alert("Please enter a folder name."); + return; + } + if (folders.contains(folderName)) { + Window.alert("A folder with this name already exists."); + return; + } + + folders.add(folders.size() - 1, folderName); // Insert before "Recent" + updateFolderList(); + dialog.hide(); + } + }); + + cancelBtn.addClickHandler(new ClickHandler() { + @Override + public void onClick(ClickEvent event) { + dialog.hide(); + } + }); + + dialog.center(); + dialog.show(); + nameBox.setFocus(true); + } + + private void showRenameFolderDialog(final String oldFolderName) { + final DialogBox dialog = new DialogBox(); + dialog.setText("Rename Folder"); + dialog.setStyleName("ode-DialogBox"); + dialog.setModal(true); + dialog.setGlassEnabled(true); + dialog.setAutoHideEnabled(false); + + VerticalPanel dialogPanel = new VerticalPanel(); + dialogPanel.setSpacing(12); + dialogPanel.setWidth("300px"); + dialogPanel.getElement().getStyle().setProperty("padding", "16px"); + + Label nameLabel = new Label("New folder name:"); + nameLabel.setStyleName("ode-ComponentRowLabel"); + dialogPanel.add(nameLabel); + + final TextBox nameBox = new TextBox(); + nameBox.setText(oldFolderName); + nameBox.setStyleName("ode-TextBox"); + nameBox.getElement().getStyle().setProperty("width", "100%"); + dialogPanel.add(nameBox); + + HorizontalPanel buttonPanel = new HorizontalPanel(); + buttonPanel.setSpacing(8); + buttonPanel.setHorizontalAlignment(HasHorizontalAlignment.ALIGN_RIGHT); + buttonPanel.setWidth("100%"); + buttonPanel.getElement().getStyle().setProperty("marginTop", "12px"); + + Button cancelBtn = new Button("Cancel"); + cancelBtn.setStyleName("ode-ProjectListButton"); + + Button renameBtn = new Button("Rename"); + renameBtn.setStyleName("ode-ProjectListButton"); + + buttonPanel.add(cancelBtn); + buttonPanel.add(renameBtn); + dialogPanel.add(buttonPanel); + + dialog.setWidget(dialogPanel); + + renameBtn.addClickHandler(new ClickHandler() { + @Override + public void onClick(ClickEvent event) { + String newFolderName = nameBox.getText().trim(); + if (newFolderName.isEmpty()) { + Window.alert("Please enter a folder name."); + return; + } + if (newFolderName.equals(oldFolderName)) { + dialog.hide(); + return; + } + if (folders.contains(newFolderName)) { + Window.alert("A folder with this name already exists."); + return; + } + + // Update all assets in this folder + renameFolderForAssets(oldFolderName, newFolderName); + dialog.hide(); + } + }); + + cancelBtn.addClickHandler(new ClickHandler() { + @Override + public void onClick(ClickEvent event) { + dialog.hide(); + } + }); + + dialog.center(); + dialog.show(); + nameBox.selectAll(); + nameBox.setFocus(true); + } + + private void showDeleteFolderDialog(final String folderName) { + // Count assets in this folder + int assetCount = 0; + for (GlobalAsset asset : globalAssets) { + if (folderName.equals(asset.getFolder())) { + assetCount++; + } + } + + String message = assetCount > 0 + ? "Delete folder '" + folderName + "' and move " + assetCount + " asset(s) to root folder?" + : "Delete empty folder '" + folderName + "'?"; + + if (Window.confirm(message)) { + deleteFolderAndMoveAssets(folderName); + } + } + + private void renameFolderForAssets(final String oldFolderName, final String newFolderName) { + final List assetsToUpdate = new ArrayList(); + + // Find assets to update + for (GlobalAsset asset : globalAssets) { + if (oldFolderName.equals(asset.getFolder())) { + assetsToUpdate.add(asset); + } + } + + if (assetsToUpdate.isEmpty()) { + // Just update the folder list + int index = folders.indexOf(oldFolderName); + if (index >= 0) { + folders.set(index, newFolderName); + updateFolderList(); + } + return; + } + + // Update each asset's folder using the existing API + final int totalAssets = assetsToUpdate.size(); + final int[] completedCount = {0}; + + for (final GlobalAsset asset : assetsToUpdate) { + globalAssetService.updateGlobalAssetFolder(asset.getFileName(), newFolderName, new AsyncCallback() { + @Override + public void onSuccess(Void result) { + completedCount[0]++; + if (completedCount[0] == totalAssets) { + // All assets updated successfully + int index = folders.indexOf(oldFolderName); + if (index >= 0) { + folders.set(index, newFolderName); + updateFolderList(); + } + refreshGlobalAssets(); + } + } + + @Override + public void onFailure(Throwable caught) { + Window.alert("Failed to rename folder: " + caught.getMessage()); + } + }); + } + } + + private void deleteFolderAndMoveAssets(final String folderName) { + + final List assetsToMove = new ArrayList(); + for (GlobalAsset asset : globalAssets) { + if (folderName.equals(asset.getFolder())) { + assetsToMove.add(asset); + } + } + + if (assetsToMove.isEmpty()) { + // Just remove from folder list + folders.remove(folderName); + if (selectedFolderIndex > 0) { + selectedFolderIndex = 0; // Reset to "All Assets" + } + updateFolderList(); + refreshAssetList(); + } else { + // Move all assets to root folder (empty string) + final int totalAssets = assetsToMove.size(); + final int[] completedCount = {0}; + + for (final GlobalAsset asset : assetsToMove) { + globalAssetService.updateGlobalAssetFolder(asset.getFileName(), "", new AsyncCallback() { + @Override + public void onSuccess(Void result) { + completedCount[0]++; + if (completedCount[0] == totalAssets) { + // All assets moved successfully, now remove folder + folders.remove(folderName); + if (selectedFolderIndex > 0) { + selectedFolderIndex = 0; // Reset to "All Assets" + } + updateFolderList(); + refreshGlobalAssets(); + } + } + + @Override + public void onFailure(Throwable caught) { + Window.alert("Failed to delete folder: " + caught.getMessage()); + } + }); + } + } + } + /** + * Enhanced asset upload handler with conflict detection and resolution. + * Uses the new AssetUploadConflictDialog for better UX. + */ + private void handleAssetUploadWithConflictCheck(String filename, DialogBox uploadDialog, + FormPanel form, FileUpload fileUpload) { + // First check if asset exists using backend service + globalAssetService.assetExists(filename, new AsyncCallback() { + @Override + public void onSuccess(Boolean exists) { + if (exists) { + // Get detailed conflict information + globalAssetService.getAssetConflictInfo(filename, new AsyncCallback() { + @Override + public void onSuccess(AssetConflictInfo conflictInfo) { + showEnhancedConflictDialog(filename, conflictInfo, uploadDialog, form, fileUpload); + } + + @Override + public void onFailure(Throwable caught) { + // Fallback to simple existence check + showSimpleConflictDialog(filename, uploadDialog, form, fileUpload); + } + }); + } else { + // No conflict, proceed with upload + uploadDialog.hide(); + proceedWithUpload(filename, form); + } + } + + @Override + public void onFailure(Throwable caught) { + // Fallback to client-side check + handleAssetUploadFallback(filename, uploadDialog, form, fileUpload); + } + }); + } + + /** + * Shows the enhanced conflict resolution dialog with project impact information. + */ + private void showEnhancedConflictDialog(String filename, AssetConflictInfo conflictInfo, + DialogBox uploadDialog, FormPanel form, FileUpload fileUpload) { + // Extract project names from conflict info + List projectNames = new ArrayList(); + if (conflictInfo.getAffectedProjects() != null) { + for (AssetConflictInfo.ProjectInfo projectInfo : conflictInfo.getAffectedProjects()) { + projectNames.add(projectInfo.getProjectName()); + } + } + + AssetUploadConflictDialog conflictDialog = new AssetUploadConflictDialog( + filename, + conflictInfo.getExistingAsset(), + projectNames, + new ConflictResolutionCallback() { + @Override + public void onResolutionSelected(ConflictResolution resolution, + String newAssetName, boolean notifyProjects) { + handleConflictResolution(resolution, filename, newAssetName, notifyProjects, + uploadDialog, form, fileUpload); + } + } + ); + + conflictDialog.show(); + } + + /** + * Shows a simple conflict dialog as fallback when detailed info is unavailable. + */ + private void showSimpleConflictDialog(String filename, DialogBox uploadDialog, + FormPanel form, FileUpload fileUpload) { + boolean confirmed = Window.confirm( + "Asset '" + filename + "' already exists.\n\n" + + "Do you want to replace it? This may affect projects using this asset."); + + if (confirmed) { + uploadDialog.hide(); + proceedWithUpload(filename, form); + } + // If not confirmed, do nothing (keep upload dialog open) + } + + /** + * Handles the selected conflict resolution option. + */ + private void handleConflictResolution(ConflictResolution resolution, + String originalFilename, String newAssetName, + boolean notifyProjects, DialogBox uploadDialog, + FormPanel form, FileUpload fileUpload) { + uploadDialog.hide(); + + switch (resolution) { + case REPLACE_EXISTING: + // TODO: If notifyProjects is true, could send notifications + proceedWithUpload(originalFilename, form); + break; + + case CREATE_NEW_ASSET: + if (newAssetName != null && !newAssetName.trim().isEmpty()) { + proceedWithUpload(newAssetName.trim(), form); + } else { + Window.alert("Invalid new asset name provided."); + } + break; + + case SAVE_AS_DRAFT: + // TODO: Implement draft functionality + Window.alert("Draft functionality not yet implemented. Using replace for now."); + proceedWithUpload(originalFilename, form); + break; + } + } + + /** + * Fallback conflict handling using client-side asset list. + */ + private void handleAssetUploadFallback(String filename, DialogBox uploadDialog, + FormPanel form, FileUpload fileUpload) { + // Check client-side asset list + GlobalAsset existingAsset = null; + for (GlobalAsset asset : globalAssets) { + if (asset.getFileName().equals(filename)) { + existingAsset = asset; + break; + } + } + + if (existingAsset != null) { + showSimpleConflictDialog(filename, uploadDialog, form, fileUpload); + } else { + uploadDialog.hide(); + proceedWithUpload(filename, form); + } + } + + /** + * Shows version history dialog for an asset. + */ + private void showVersionHistory(GlobalAsset asset) { + AssetVersionHistoryDialog historyDialog = new AssetVersionHistoryDialog(asset, + new AssetVersionHistoryDialog.VersionActionCallback() { + @Override + public void onRollback(long timestamp) { + // TODO: Implement rollback functionality + Window.alert("Rollback functionality will be implemented in future versions."); + } + + @Override + public void onViewVersion(long timestamp) { + // TODO: Implement version viewing + Window.alert("Version viewing will be implemented in future versions."); + } + }); + + historyDialog.show(); + } + + /** + * Shows an inline error message with consistent styling + */ + private void showInlineError(Label errorLabel, String message) { + errorLabel.setText(message); + errorLabel.getElement().getStyle().setProperty("color", "#d32f2f"); + errorLabel.getElement().getStyle().setProperty("fontSize", "13px"); + errorLabel.getElement().getStyle().setProperty("fontWeight", "500"); + errorLabel.getElement().getStyle().setProperty("marginTop", "4px"); + } + + /** + * Clears any inline error styling and message + */ + private void clearInlineError(Label errorLabel) { + errorLabel.setText(""); + errorLabel.getElement().getStyle().setProperty("color", ""); + errorLabel.getElement().getStyle().setProperty("fontSize", ""); + errorLabel.getElement().getStyle().setProperty("fontWeight", ""); + errorLabel.getElement().getStyle().setProperty("marginTop", ""); + } + + /** + * Shows a user-friendly error dialog for upload failures + */ + private void showUploadError(String title, String message) { + final DialogBox errorDialog = new DialogBox(); + errorDialog.setText(title); + errorDialog.setModal(true); + errorDialog.setGlassEnabled(true); + errorDialog.setStyleName("ode-DialogBox"); + + VerticalPanel dialogPanel = new VerticalPanel(); + dialogPanel.setSpacing(12); + dialogPanel.setWidth("400px"); + + // Error icon and message + HorizontalPanel messagePanel = new HorizontalPanel(); + messagePanel.setSpacing(8); + messagePanel.setVerticalAlignment(HasVerticalAlignment.ALIGN_TOP); + + Label errorIcon = new Label("⚠"); + errorIcon.getElement().getStyle().setProperty("fontSize", "24px"); + errorIcon.getElement().getStyle().setProperty("color", "#d32f2f"); + messagePanel.add(errorIcon); + + Label errorMessage = new Label(message); + errorMessage.setStyleName("ode-ComponentRowLabel"); + errorMessage.getElement().getStyle().setProperty("wordWrap", "break-word"); + errorMessage.setWidth("340px"); + messagePanel.add(errorMessage); + + dialogPanel.add(messagePanel); + + // Buttons + HorizontalPanel buttonPanel = new HorizontalPanel(); + buttonPanel.setSpacing(8); + buttonPanel.setHorizontalAlignment(HasHorizontalAlignment.ALIGN_RIGHT); + + Button retryButton = new Button("Try Again"); + retryButton.setStyleName("ode-DialogButton"); + retryButton.addClickHandler(new ClickHandler() { + @Override + public void onClick(ClickEvent event) { + errorDialog.hide(); + showUploadDialog(); // Reopen the upload dialog + } + }); + + Button okButton = new Button("OK"); + okButton.setStyleName("ode-DialogButton"); + okButton.addClickHandler(new ClickHandler() { + @Override + public void onClick(ClickEvent event) { + errorDialog.hide(); + } + }); + + buttonPanel.add(retryButton); + buttonPanel.add(okButton); + dialogPanel.add(buttonPanel); + + errorDialog.setWidget(dialogPanel); + errorDialog.center(); + } + + /** + * Creates a valid filename by stripping path and whitespace. + */ + private String makeValidFilename(String uploadFilename) { + String filename = uploadFilename.substring( + Math.max(uploadFilename.lastIndexOf('/'), uploadFilename.lastIndexOf('\\')) + 1); + filename = filename.replaceAll("\\s", ""); + return filename; + } + + /** + * Displays deletion error dialog with better formatting for asset usage information. + */ + private void showDeleteError(String title, String message) { + final DialogBox errorDialog = new DialogBox(); + errorDialog.setText(title); + errorDialog.setStyleName("ode-DialogBox"); + errorDialog.setModal(true); + errorDialog.setGlassEnabled(true); + errorDialog.setAutoHideEnabled(false); + + VerticalPanel dialogPanel = new VerticalPanel(); + dialogPanel.setSpacing(16); + dialogPanel.setWidth("500px"); + dialogPanel.getElement().getStyle().setProperty("padding", "16px"); + + // Error header with icon + HorizontalPanel errorHeader = new HorizontalPanel(); + errorHeader.setVerticalAlignment(HasVerticalAlignment.ALIGN_TOP); + errorHeader.setSpacing(12); + errorHeader.getElement().getStyle().setProperty("backgroundColor", "#fef7f7"); + errorHeader.getElement().getStyle().setProperty("padding", "12px"); + errorHeader.getElement().getStyle().setProperty("borderRadius", "6px"); + errorHeader.getElement().getStyle().setProperty("border", "1px solid #f5c6cb"); + errorHeader.getElement().getStyle().setProperty("marginBottom", "12px"); + + Label errorIcon = new Label("⚠"); + errorIcon.getElement().getStyle().setProperty("fontSize", "24px"); + errorIcon.getElement().getStyle().setProperty("color", "#721c24"); + errorIcon.getElement().getStyle().setProperty("marginRight", "8px"); + errorHeader.add(errorIcon); + + VerticalPanel errorText = new VerticalPanel(); + Label errorTitle = new Label("Asset Cannot Be Deleted"); + errorTitle.setStyleName("ode-ComponentRowLabel"); + errorTitle.getElement().getStyle().setProperty("fontWeight", "600"); + errorTitle.getElement().getStyle().setProperty("fontSize", "16px"); + errorTitle.getElement().getStyle().setProperty("color", "#721c24"); + errorText.add(errorTitle); + + // Parse the error message to make it more readable + String displayMessage = message; + if (message.contains("is currently used by") && message.contains("project(s):")) { + // Enhanced error message - display it nicely + displayMessage = message.replace("Cannot delete asset", "This asset"); + } + + Label errorMsg = new Label(displayMessage); + errorMsg.setStyleName("ode-ComponentRowLabel"); + errorMsg.getElement().getStyle().setProperty("fontSize", "14px"); + errorMsg.getElement().getStyle().setProperty("color", "#721c24"); + errorMsg.getElement().getStyle().setProperty("lineHeight", "1.4"); + errorMsg.getElement().getStyle().setProperty("marginTop", "4px"); + errorMsg.getElement().getStyle().setProperty("wordWrap", "break-word"); + errorText.add(errorMsg); + + errorHeader.add(errorText); + dialogPanel.add(errorHeader); + + // Help text + if (message.contains("Please remove the asset from these projects first")) { + Label helpText = new Label("To delete this asset:"); + helpText.setStyleName("ode-ComponentRowLabel"); + helpText.getElement().getStyle().setProperty("fontWeight", "600"); + helpText.getElement().getStyle().setProperty("marginBottom", "8px"); + dialogPanel.add(helpText); + + VerticalPanel stepsList = new VerticalPanel(); + stepsList.getElement().getStyle().setProperty("marginLeft", "16px"); + + Label step1 = new Label("1. Open each project listed above"); + step1.setStyleName("ode-ComponentRowLabel"); + step1.getElement().getStyle().setProperty("fontSize", "14px"); + step1.getElement().getStyle().setProperty("marginBottom", "4px"); + stepsList.add(step1); + + Label step2 = new Label("2. Remove the asset from the project's assets"); + step2.setStyleName("ode-ComponentRowLabel"); + step2.getElement().getStyle().setProperty("fontSize", "14px"); + step2.getElement().getStyle().setProperty("marginBottom", "4px"); + stepsList.add(step2); + + Label step3 = new Label("3. Return here to delete the asset"); + step3.setStyleName("ode-ComponentRowLabel"); + step3.getElement().getStyle().setProperty("fontSize", "14px"); + stepsList.add(step3); + + dialogPanel.add(stepsList); + } + + // Button panel + HorizontalPanel buttonPanel = new HorizontalPanel(); + buttonPanel.setSpacing(8); + buttonPanel.setHorizontalAlignment(HasHorizontalAlignment.ALIGN_RIGHT); + buttonPanel.setWidth("100%"); + buttonPanel.getElement().getStyle().setProperty("marginTop", "16px"); + + Button okButton = new Button("OK"); + okButton.setStyleName("ode-ProjectListButton"); + okButton.addClickHandler(new ClickHandler() { + @Override + public void onClick(ClickEvent event) { + errorDialog.hide(); + } + }); + buttonPanel.add(okButton); + dialogPanel.add(buttonPanel); + + errorDialog.setWidget(dialogPanel); + errorDialog.center(); + } + + // Version management methods from Neo version + private void showUpdateAssetDialog(final GlobalAsset asset) { + final DialogBox dialog = new DialogBox(); + dialog.setText("Upload New Version - " + asset.getFileName()); + dialog.setStyleName("ode-DialogBox"); + dialog.setModal(true); + dialog.setGlassEnabled(true); + + VerticalPanel dialogPanel = new VerticalPanel(); + dialogPanel.setSpacing(12); + dialogPanel.setWidth("400px"); + + // Current asset info + HorizontalPanel currentAssetInfo = new HorizontalPanel(); + currentAssetInfo.setVerticalAlignment(HasVerticalAlignment.ALIGN_MIDDLE); + currentAssetInfo.getElement().getStyle().setProperty("marginBottom", "16px"); + currentAssetInfo.getElement().getStyle().setProperty("padding", "8px"); + currentAssetInfo.getElement().getStyle().setProperty("border", "1px solid #e0e0e0"); + currentAssetInfo.getElement().getStyle().setProperty("borderRadius", "4px"); + + String fileName = asset.getFileName().toLowerCase(); + Image assetIcon; + if (fileName.endsWith(".mp3") || fileName.endsWith(".wav") || fileName.endsWith(".ogg")) { + assetIcon = new Image(images.player()); + } else { + assetIcon = new Image(images.image()); + } + assetIcon.setWidth("24px"); + assetIcon.setHeight("24px"); + assetIcon.getElement().getStyle().setProperty("marginRight", "8px"); + currentAssetInfo.add(assetIcon); + + VerticalPanel currentInfo = new VerticalPanel(); + Label currentName = new Label("Current: " + asset.getFileName()); + currentName.setStyleName("ode-ComponentRowLabel"); + currentName.getElement().getStyle().setProperty("fontWeight", "500"); + currentInfo.add(currentName); + + Label currentVersion = new Label("Last updated: " + formatDate(asset.getTimestamp())); + currentVersion.setStyleName("ode-ComponentRowLabel"); + currentVersion.getElement().getStyle().setProperty("fontSize", "11px"); + currentVersion.getElement().getStyle().setProperty("opacity", "0.7"); + currentInfo.add(currentVersion); + currentAssetInfo.add(currentInfo); + + dialogPanel.add(currentAssetInfo); + + // Upload form + final FormPanel form = new FormPanel(); + form.setEncoding(FormPanel.ENCODING_MULTIPART); + form.setMethod(FormPanel.METHOD_POST); + + VerticalPanel formPanel = new VerticalPanel(); + formPanel.setSpacing(8); + + // File input + Label fileLabel = new Label("Select new version file:"); + fileLabel.setStyleName("ode-ComponentRowLabel"); + formPanel.add(fileLabel); + + final FileUpload fileUpload = new FileUpload(); + fileUpload.setName(ServerLayout.UPLOAD_GLOBAL_ASSET_FORM_ELEMENT); + fileUpload.setStyleName("ode-TextBox"); + fileUpload.getElement().getStyle().setProperty("width", "100%"); + formPanel.add(fileUpload); + + // Version notes + Label notesLabel = new Label("Version notes (optional):"); + notesLabel.setStyleName("ode-ComponentRowLabel"); + formPanel.add(notesLabel); + + final TextBox versionNotes = new TextBox(); + versionNotes.setStyleName("ode-TextBox"); + versionNotes.getElement().getStyle().setProperty("width", "100%"); + versionNotes.getElement().setPropertyString("placeholder", "Describe changes in this version..."); + formPanel.add(versionNotes); + + // Auto-update projects checkbox + final CheckBox autoUpdate = new CheckBox("Automatically update projects using this asset"); + autoUpdate.setValue(true); + autoUpdate.setStyleName("ode-ComponentRowLabel"); + autoUpdate.getElement().getStyle().setProperty("fontSize", "12px"); + autoUpdate.getElement().getStyle().setProperty("marginTop", "8px"); + formPanel.add(autoUpdate); + + Label autoUpdateDesc = new Label("Projects with tracking enabled will receive this update automatically."); + autoUpdateDesc.setStyleName("ode-ComponentRowLabel"); + autoUpdateDesc.getElement().getStyle().setProperty("fontSize", "10px"); + autoUpdateDesc.getElement().getStyle().setProperty("opacity", "0.7"); + autoUpdateDesc.getElement().getStyle().setProperty("marginLeft", "20px"); + formPanel.add(autoUpdateDesc); + + // Error label + final Label errorLabel = new Label(); + errorLabel.getElement().getStyle().setProperty("color", "#d93025"); + errorLabel.getElement().getStyle().setProperty("fontSize", "12px"); + formPanel.add(errorLabel); + + form.setWidget(formPanel); + dialogPanel.add(form); + + // Button panel + HorizontalPanel buttonPanel = new HorizontalPanel(); + buttonPanel.setSpacing(8); + buttonPanel.setHorizontalAlignment(HasHorizontalAlignment.ALIGN_RIGHT); + buttonPanel.setWidth("100%"); + + Button cancelBtn = new Button("Cancel"); + cancelBtn.setStyleName("ode-ProjectListButton"); + + Button uploadBtn = new Button("Upload New Version"); + uploadBtn.setStyleName("ode-ProjectListButton"); + + buttonPanel.add(cancelBtn); + buttonPanel.add(uploadBtn); + dialogPanel.add(buttonPanel); + + dialog.setWidget(dialogPanel); + + // Event handlers + uploadBtn.addClickHandler(new ClickHandler() { + @Override + public void onClick(ClickEvent event) { + String filename = fileUpload.getFilename(); + if (filename == null || filename.isEmpty()) { + errorLabel.setText("Please select a file."); + return; + } + + // Extract actual filename + String actualFilename = filename; + if (filename.contains("\\")) { + actualFilename = filename.substring(filename.lastIndexOf("\\") + 1); + } else if (filename.contains("/")) { + actualFilename = filename.substring(filename.lastIndexOf("/") + 1); + } + + // Validate file type matches existing asset + String newExt = getFileExtension(actualFilename).toLowerCase(); + String currentExt = getFileExtension(asset.getFileName()).toLowerCase(); + if (!newExt.equals(currentExt)) { + errorLabel.setText("New version must have the same file type as the current asset (" + currentExt + ")."); + return; + } + + // Use the original asset name for the upload (maintaining the same filename) + String uploadPath = "_global_/"; + if (asset.getFolder() != null && !asset.getFolder().isEmpty()) { + uploadPath += asset.getFolder() + "/"; + } + uploadPath += asset.getFileName(); // Keep the same filename + + form.setAction(GWT.getModuleBaseURL() + "upload/" + ServerLayout.UPLOAD_GLOBAL_ASSET + "/" + uploadPath); + form.submit(); + } + }); + + form.addSubmitCompleteHandler(new SubmitCompleteHandler() { + @Override + public void onSubmitComplete(SubmitCompleteEvent event) { + dialog.hide(); + String results = event.getResults(); + if (results != null && results.contains("SUCCESS")) { + refreshGlobalAssets(); + + // If auto-update is enabled, sync all projects using this asset + if (autoUpdate.getValue()) { + syncAssetWithProjects(asset.getFileName()); + } + + // Show success notification + Window.alert("Asset updated successfully!" + + (autoUpdate.getValue() ? " Projects using this asset will be updated." : "")); + } else { + showUploadError(MESSAGES.fileUploadError(), "Failed to upload new version. Please try again."); + } + } + }); + + cancelBtn.addClickHandler(new ClickHandler() { + @Override + public void onClick(ClickEvent event) { + dialog.hide(); + } + }); + + dialog.center(); + dialog.show(); + } + + + private void syncAssetWithProjects(String assetFileName) { + + // Get current project ID if we're in a project + long currentProjectId = Ode.getInstance().getCurrentYoungAndroidProjectId(); + + if (currentProjectId != 0) { + // Sync with current project + globalAssetService.syncProjectGlobalAsset(assetFileName, currentProjectId, new AsyncCallback() { + @Override + public void onSuccess(Boolean result) { + if (result) { + // Force refresh of current project's asset list and editors + refreshCurrentProjectAssets(); + } else { + } + } + + @Override + public void onFailure(Throwable caught) { + } + }); + } + + // Also get list of all projects using this asset and sync them + globalAssetService.getProjectsUsingAsset(assetFileName, new AsyncCallback>() { + @Override + public void onSuccess(List projectIds) { + // The server-side should handle the actual synchronization + // Client-side we just need to refresh if current project is affected + if (projectIds.contains(Ode.getInstance().getCurrentYoungAndroidProjectId())) { + refreshCurrentProjectAssets(); + } + } + + @Override + public void onFailure(Throwable caught) { + } + }); + } + + private void refreshCurrentProjectAssets() { + // Force refresh of project assets and any open editors + long projectId = Ode.getInstance().getCurrentYoungAndroidProjectId(); + if (projectId != 0) { + // Refresh the project manager - this will update asset lists + Ode.getInstance().getProjectManager().getProject(projectId); + + // The server-side sync should handle updating the project assets + // Client-side we just notify that a refresh might be needed + } + } + +} diff --git a/appinventor/appengine/src/com/google/appinventor/client/assetlibrary/AssetUploadConflictDialog.java b/appinventor/appengine/src/com/google/appinventor/client/assetlibrary/AssetUploadConflictDialog.java new file mode 100644 index 00000000000..89a44428a77 --- /dev/null +++ b/appinventor/appengine/src/com/google/appinventor/client/assetlibrary/AssetUploadConflictDialog.java @@ -0,0 +1,294 @@ +package com.google.appinventor.client.assetlibrary; + +import com.google.gwt.event.dom.client.ClickEvent; +import com.google.gwt.event.dom.client.ClickHandler; +import com.google.gwt.user.client.ui.*; +import com.google.appinventor.shared.rpc.project.GlobalAsset; +import java.util.List; + +/** + * Dialog displayed when user uploads an asset that already exists. + * Provides options for handling the conflict with clear impact preview. + */ +public class AssetUploadConflictDialog extends DialogBox { + + public enum ConflictResolution { + REPLACE_EXISTING, + CREATE_NEW_ASSET, + SAVE_AS_DRAFT + } + + public interface ConflictResolutionCallback { + void onResolutionSelected(ConflictResolution resolution, String newAssetName, boolean notifyProjects); + } + + private final String assetName; + private final GlobalAsset existingAsset; + private final List affectedProjects; + private final ConflictResolutionCallback callback; + + // UI Components + private RadioButton replaceOption; + private RadioButton createNewOption; + private RadioButton draftOption; + private TextBox newNameBox; + private CheckBox notifyCheckBox; + private Label impactLabel; + private Button confirmButton; + private Button cancelButton; + + public AssetUploadConflictDialog(String assetName, GlobalAsset existingAsset, + List affectedProjects, ConflictResolutionCallback callback) { + super(false, true); // non-auto-hide, modal + this.assetName = assetName; + this.existingAsset = existingAsset; + this.affectedProjects = affectedProjects; + this.callback = callback; + + initializeDialog(); + setupEventHandlers(); + updateImpactPreview(); + } + + private void initializeDialog() { + setText("Asset Already Exists"); + setStyleName("asset-conflict-dialog"); + + VerticalPanel mainPanel = new VerticalPanel(); + mainPanel.setStyleName("conflict-dialog-content"); + mainPanel.setSpacing(12); + + // Header with asset info + mainPanel.add(createHeaderPanel()); + + // Conflict resolution options + mainPanel.add(createOptionsPanel()); + + // Impact preview + mainPanel.add(createImpactPanel()); + + // Action buttons + mainPanel.add(createButtonPanel()); + + setWidget(mainPanel); + center(); + } + + private Widget createHeaderPanel() { + HorizontalPanel headerPanel = new HorizontalPanel(); + headerPanel.setStyleName("conflict-header"); + headerPanel.setVerticalAlignment(HasVerticalAlignment.ALIGN_MIDDLE); + headerPanel.setSpacing(8); + + // Warning icon + Label warningIcon = new Label("[WARNING]"); + warningIcon.setStyleName("warning-icon"); + headerPanel.add(warningIcon); + + // Message + VerticalPanel messagePanel = new VerticalPanel(); + Label titleLabel = new Label("\"" + assetName + "\" already exists"); + titleLabel.setStyleName("conflict-title"); + Label subtitleLabel = new Label("Choose how to handle this conflict:"); + subtitleLabel.setStyleName("conflict-subtitle"); + + messagePanel.add(titleLabel); + messagePanel.add(subtitleLabel); + headerPanel.add(messagePanel); + + return headerPanel; + } + + private Widget createOptionsPanel() { + VerticalPanel optionsPanel = new VerticalPanel(); + optionsPanel.setStyleName("conflict-options"); + optionsPanel.setSpacing(8); + + // Replace existing option + replaceOption = new RadioButton("conflictGroup", "Replace existing version"); + replaceOption.setStyleName("conflict-option"); + replaceOption.setValue(true); // Default selection + + Label replaceWarning = new Label("[WARNING] This will update projects automatically"); + replaceWarning.setStyleName("option-warning"); + + optionsPanel.add(replaceOption); + optionsPanel.add(replaceWarning); + + // Create new asset option + createNewOption = new RadioButton("conflictGroup", "Create new asset with different name"); + createNewOption.setStyleName("conflict-option"); + + HorizontalPanel newNamePanel = new HorizontalPanel(); + newNamePanel.setVerticalAlignment(HasVerticalAlignment.ALIGN_MIDDLE); + newNamePanel.setSpacing(8); + + Label suggestedLabel = new Label("Suggested:"); + suggestedLabel.setStyleName("suggested-label"); + newNameBox = new TextBox(); + newNameBox.setValue(generateSuggestedName(assetName)); + newNameBox.setStyleName("new-name-input"); + newNameBox.setEnabled(false); + + newNamePanel.add(suggestedLabel); + newNamePanel.add(newNameBox); + + optionsPanel.add(createNewOption); + optionsPanel.add(newNamePanel); + + // Save as draft option + draftOption = new RadioButton("conflictGroup", "Save as draft version"); + draftOption.setStyleName("conflict-option"); + + Label draftInfo = new Label("Review changes before publishing to projects"); + draftInfo.setStyleName("option-info"); + + optionsPanel.add(draftOption); + optionsPanel.add(draftInfo); + + return optionsPanel; + } + + private Widget createImpactPanel() { + VerticalPanel impactPanel = new VerticalPanel(); + impactPanel.setStyleName("impact-panel"); + impactPanel.setSpacing(8); + + impactLabel = new Label(); + impactLabel.setStyleName("impact-label"); + impactPanel.add(impactLabel); + + // Notify checkbox (only visible for replace option) + notifyCheckBox = new CheckBox("Notify project owners of update"); + notifyCheckBox.setStyleName("notify-checkbox"); + notifyCheckBox.setValue(true); + impactPanel.add(notifyCheckBox); + + return impactPanel; + } + + private Widget createButtonPanel() { + HorizontalPanel buttonPanel = new HorizontalPanel(); + buttonPanel.setStyleName("dialog-buttons"); + buttonPanel.setSpacing(8); + buttonPanel.setHorizontalAlignment(HasHorizontalAlignment.ALIGN_RIGHT); + + cancelButton = new Button("Cancel"); + cancelButton.setStyleName("secondary-button"); + + confirmButton = new Button("Continue"); + confirmButton.setStyleName("primary-button"); + + buttonPanel.add(cancelButton); + buttonPanel.add(confirmButton); + + return buttonPanel; + } + + private void setupEventHandlers() { + // Radio button handlers + replaceOption.addClickHandler(new ClickHandler() { + @Override + public void onClick(ClickEvent event) { + updateImpactPreview(); + newNameBox.setEnabled(false); + } + }); + + createNewOption.addClickHandler(new ClickHandler() { + @Override + public void onClick(ClickEvent event) { + updateImpactPreview(); + newNameBox.setEnabled(true); + newNameBox.setFocus(true); + } + }); + + draftOption.addClickHandler(new ClickHandler() { + @Override + public void onClick(ClickEvent event) { + updateImpactPreview(); + newNameBox.setEnabled(false); + } + }); + + // Button handlers + confirmButton.addClickHandler(new ClickHandler() { + @Override + public void onClick(ClickEvent event) { + handleConfirm(); + } + }); + + cancelButton.addClickHandler(new ClickHandler() { + @Override + public void onClick(ClickEvent event) { + hide(); + } + }); + } + + private void updateImpactPreview() { + String impactText; + boolean showNotifyOption = false; + + if (replaceOption.getValue()) { + impactText = "Impact: " + affectedProjects.size() + " projects will receive the updated asset"; + showNotifyOption = true; + confirmButton.setText("Replace Asset"); + } else if (createNewOption.getValue()) { + impactText = "Impact: No existing projects will be affected"; + confirmButton.setText("Create New Asset"); + } else if (draftOption.getValue()) { + impactText = "Impact: Changes will be saved for review, no projects updated yet"; + confirmButton.setText("Save as Draft"); + } else { + impactText = ""; + } + + impactLabel.setText(impactText); + notifyCheckBox.setVisible(showNotifyOption); + } + + private void handleConfirm() { + ConflictResolution resolution; + String newAssetName = null; + boolean notifyProjects = notifyCheckBox.getValue(); + + if (replaceOption.getValue()) { + resolution = ConflictResolution.REPLACE_EXISTING; + } else if (createNewOption.getValue()) { + resolution = ConflictResolution.CREATE_NEW_ASSET; + newAssetName = newNameBox.getValue().trim(); + if (newAssetName.isEmpty()) { + showValidationError("Please enter a name for the new asset."); + return; + } + } else { + resolution = ConflictResolution.SAVE_AS_DRAFT; + } + + hide(); + callback.onResolutionSelected(resolution, newAssetName, notifyProjects); + } + + private void showValidationError(String message) { + // Simple validation error - could be enhanced with better UI + newNameBox.addStyleName("input-error"); + } + + private String generateSuggestedName(String originalName) { + String nameWithoutExtension; + String extension = ""; + + int lastDot = originalName.lastIndexOf('.'); + if (lastDot > 0) { + nameWithoutExtension = originalName.substring(0, lastDot); + extension = originalName.substring(lastDot); + } else { + nameWithoutExtension = originalName; + } + + return nameWithoutExtension + "_v2" + extension; + } +} \ No newline at end of file diff --git a/appinventor/appengine/src/com/google/appinventor/client/assetlibrary/AssetVersionHistoryDialog.java b/appinventor/appengine/src/com/google/appinventor/client/assetlibrary/AssetVersionHistoryDialog.java new file mode 100644 index 00000000000..b59d6e37807 --- /dev/null +++ b/appinventor/appengine/src/com/google/appinventor/client/assetlibrary/AssetVersionHistoryDialog.java @@ -0,0 +1,189 @@ +package com.google.appinventor.client.assetlibrary; + +import com.google.gwt.event.dom.client.ClickEvent; +import com.google.gwt.event.dom.client.ClickHandler; +import com.google.gwt.user.client.ui.*; +import com.google.appinventor.shared.rpc.project.GlobalAsset; +import java.util.Date; + +/** + * Dialog for displaying version history of an asset. + * Shows timestamp-based versioning information and allows rollback functionality. + */ +public class AssetVersionHistoryDialog extends DialogBox { + + public interface VersionActionCallback { + void onRollback(long timestamp); + void onViewVersion(long timestamp); + } + + private final GlobalAsset asset; + private final VersionActionCallback callback; + + public AssetVersionHistoryDialog(GlobalAsset asset, VersionActionCallback callback) { + super(false, true); // non-auto-hide, modal + this.asset = asset; + this.callback = callback; + + initializeDialog(); + } + + private void initializeDialog() { + setText("Version History: " + asset.getFileName()); + setStyleName("version-history-dialog"); + + VerticalPanel mainPanel = new VerticalPanel(); + mainPanel.setStyleName("version-dialog-content"); + mainPanel.setSpacing(12); + + // Header with asset info + mainPanel.add(createHeaderPanel()); + + // Version information (for now, just current version) + mainPanel.add(createVersionListPanel()); + + // Action buttons + mainPanel.add(createButtonPanel()); + + setWidget(mainPanel); + center(); + } + + private Widget createHeaderPanel() { + HorizontalPanel headerPanel = new HorizontalPanel(); + headerPanel.setStyleName("version-header"); + headerPanel.setVerticalAlignment(HasVerticalAlignment.ALIGN_MIDDLE); + headerPanel.setSpacing(12); + + // Asset info + VerticalPanel assetInfo = new VerticalPanel(); + + Label assetName = new Label(asset.getFileName()); + assetName.setStyleName("version-asset-name"); + assetInfo.add(assetName); + + if (asset.getFolder() != null && !asset.getFolder().isEmpty()) { + Label folderLabel = new Label("Folder: " + asset.getFolder()); + folderLabel.setStyleName("version-asset-folder"); + assetInfo.add(folderLabel); + } + + headerPanel.add(assetInfo); + + return headerPanel; + } + + private Widget createVersionListPanel() { + VerticalPanel versionPanel = new VerticalPanel(); + versionPanel.setStyleName("version-list-panel"); + versionPanel.setSpacing(8); + + Label versionTitle = new Label("📋 Version History"); + versionTitle.setStyleName("version-list-title"); + versionPanel.add(versionTitle); + + // Current version entry + versionPanel.add(createVersionEntry(asset.getTimestamp(), true)); + + // Note about future versions + Label futureNote = new Label("💡 Future versions will appear here as assets are updated"); + futureNote.setStyleName("version-future-note"); + versionPanel.add(futureNote); + + return versionPanel; + } + + private Widget createVersionEntry(long timestamp, boolean isCurrent) { + HorizontalPanel versionEntry = new HorizontalPanel(); + versionEntry.setStyleName("version-entry"); + versionEntry.setVerticalAlignment(HasVerticalAlignment.ALIGN_MIDDLE); + versionEntry.setSpacing(12); + + if (isCurrent) { + versionEntry.addStyleName("version-entry-current"); + } + + // Version info + VerticalPanel versionInfo = new VerticalPanel(); + + String versionLabel = isCurrent ? "[check] Current Version" : "📄 Version"; + Label versionStatus = new Label(versionLabel); + versionStatus.setStyleName("version-status"); + versionInfo.add(versionStatus); + + Date versionDate = new Date(timestamp); + Label dateLabel = new Label("Modified: " + versionDate.toString()); + dateLabel.setStyleName("version-date"); + versionInfo.add(dateLabel); + + versionEntry.add(versionInfo); + + // Actions (only for non-current versions in the future) + if (!isCurrent) { + HorizontalPanel actions = new HorizontalPanel(); + actions.setSpacing(8); + + Button viewBtn = new Button("👁 View"); + viewBtn.setStyleName("version-action-button"); + viewBtn.addClickHandler(new ClickHandler() { + @Override + public void onClick(ClickEvent event) { + if (callback != null) { + callback.onViewVersion(timestamp); + } + } + }); + + Button rollbackBtn = new Button("[rollback] Rollback"); + rollbackBtn.setStyleName("version-action-button rollback-button"); + rollbackBtn.addClickHandler(new ClickHandler() { + @Override + public void onClick(ClickEvent event) { + if (confirmRollback()) { + if (callback != null) { + callback.onRollback(timestamp); + } + hide(); + } + } + }); + + actions.add(viewBtn); + actions.add(rollbackBtn); + versionEntry.add(actions); + } else { + Label currentTag = new Label("📍 Active"); + currentTag.setStyleName("current-version-tag"); + versionEntry.add(currentTag); + } + + return versionEntry; + } + + private boolean confirmRollback() { + return com.google.gwt.user.client.Window.confirm( + "Are you sure you want to rollback to this version?\n\n" + + "This will replace the current version and may affect projects using this asset." + ); + } + + private Widget createButtonPanel() { + HorizontalPanel buttonPanel = new HorizontalPanel(); + buttonPanel.setStyleName("dialog-buttons"); + buttonPanel.setSpacing(8); + buttonPanel.setHorizontalAlignment(HasHorizontalAlignment.ALIGN_RIGHT); + + Button closeButton = new Button("Close"); + closeButton.setStyleName("primary-button"); + closeButton.addClickHandler(new ClickHandler() { + @Override + public void onClick(ClickEvent event) { + hide(); + } + }); + + buttonPanel.add(closeButton); + + return buttonPanel; + } +} \ No newline at end of file diff --git a/appinventor/appengine/src/com/google/appinventor/client/editor/simple/components/MockComponent.java b/appinventor/appengine/src/com/google/appinventor/client/editor/simple/components/MockComponent.java index 52f61baefb2..0e464a62206 100644 --- a/appinventor/appengine/src/com/google/appinventor/client/editor/simple/components/MockComponent.java +++ b/appinventor/appengine/src/com/google/appinventor/client/editor/simple/components/MockComponent.java @@ -40,8 +40,10 @@ import com.google.appinventor.shared.simple.ComponentDatabaseInterface; import com.google.appinventor.shared.simple.ComponentDatabaseInterface.ComponentDefinition; import com.google.appinventor.shared.simple.ComponentDatabaseInterface.PropertyDefinition; +import com.google.appinventor.shared.rpc.ServerLayout; import com.google.appinventor.shared.storage.StorageUtil; import com.google.gwt.dom.client.NativeEvent; +import com.google.gwt.http.client.URL; import com.google.gwt.event.dom.client.ClickEvent; import com.google.gwt.event.dom.client.ClickHandler; import com.google.gwt.event.dom.client.DomEvent; @@ -948,11 +950,43 @@ protected ProjectNode getAssetNode(String name) { * Returns null if the image property value is blank or not recognized as an * asset. */ - protected String convertImagePropertyValueToUrl(String text) { - if (text.length() > 0) { - ProjectNode asset = getAssetNode(text); - if (asset != null) { - return StorageUtil.getFileUrl(asset.getProjectId(), asset.getFileId()); + protected String convertImagePropertyValueToUrl(String fileIdOrName) { + if (fileIdOrName != null && !fileIdOrName.isEmpty()) { + long projectId = editor.getProjectId(); // Needed for project assets + + if (fileIdOrName.startsWith("_global_/")) { + // It's a global asset. Use the DOWNLOAD_USERFILE endpoint. + // The path is already the full path needed by DOWNLOAD_USERFILE. + // UploadServlet now routes /ode/upload/globalasset/* to use importUserFile, + // so downloads should also use the userfile path. + // Add cache-busting parameter to prevent stale asset display + // Uses the same pattern as StorageUtil.getFileUrl() for consistency + return ServerLayout.ODE_BASEURL + ServerLayout.DOWNLOAD_SERVLET_BASE + + ServerLayout.DOWNLOAD_USERFILE + "/" + URL.encodePathSegment(fileIdOrName) + + "?t=" + System.currentTimeMillis(); + } else if (fileIdOrName.startsWith("assets/_global_/")) { + // It's a global asset that was imported into the project. + // It should be treated as a regular project asset, not a global asset. + // Use the same method as regular project assets. + return StorageUtil.getFileUrl(projectId, fileIdOrName); + } else { + // Assume it's a project asset. + // It could be a full path like "assets/image.png" or a simple name "image.png". + // StorageUtil.getFileUrl expects the path relative to the project root (e.g., "assets/image.png"). + + String effectivePath = fileIdOrName; + if (!fileIdOrName.contains("/")) { + // Simple name, attempt to resolve to a full project asset path. + ProjectNode asset = getAssetNode(fileIdOrName); // getAssetNode matches by simple name + if (asset != null) { + effectivePath = asset.getFileId(); // This is typically "assets/image.png" + } else { + // Simple name, not found as a project asset. Cannot form a URL. + return null; + } + } + // At this point, effectivePath should be like "assets/image.png" + return StorageUtil.getFileUrl(projectId, effectivePath); } } return null; diff --git a/appinventor/appengine/src/com/google/appinventor/client/editor/youngandroid/AssetManagerPanel.java b/appinventor/appengine/src/com/google/appinventor/client/editor/youngandroid/AssetManagerPanel.java new file mode 100644 index 00000000000..12ce7db933e --- /dev/null +++ b/appinventor/appengine/src/com/google/appinventor/client/editor/youngandroid/AssetManagerPanel.java @@ -0,0 +1,302 @@ +// -*- mode: java; c-basic-offset: 2; -*- +// Copyright 2009-2011 Google, All Rights reserved +// Copyright 2011-2024 MIT, All rights reserved +// Released under the Apache License, Version 2.0 +// http://www.apache.org/licenses/LICENSE-2.0 + +package com.google.appinventor.client.editor.youngandroid; + +import static com.google.appinventor.client.Ode.MESSAGES; + +import com.google.appinventor.shared.storage.StorageUtil; +import com.google.appinventor.shared.rpc.project.ProjectNode; +import com.google.appinventor.shared.rpc.project.GlobalAssetProjectNode; +import com.google.gwt.user.client.ui.*; +import com.google.gwt.event.dom.client.*; +import com.google.gwt.core.client.GWT; +import com.google.gwt.uibinder.client.UiBinder; +import com.google.gwt.uibinder.client.UiField; +import com.google.gwt.uibinder.client.UiTemplate; +import com.google.gwt.dom.client.Document; +import com.google.gwt.dom.client.StyleElement; +import com.google.gwt.event.logical.shared.ValueChangeEvent; +import com.google.gwt.event.logical.shared.ValueChangeHandler; +import com.google.gwt.user.client.DOM; + +/** + * Panel for managing assets in the App Inventor Designer. + * Provides functionality for uploading, organizing, and managing project assets. + */ +public class AssetManagerPanel extends Composite { + interface AssetManagerPanelUiBinder extends UiBinder {} + private static final AssetManagerPanelUiBinder UI_BINDER = GWT.create(AssetManagerPanelUiBinder.class); + + @UiField + VerticalPanel mainPanel; + + @UiField + HorizontalPanel toolbarPanel; + + @UiField + TextBox searchBox; + + @UiField + Button uploadButton; + + @UiField + Button createFolderButton; + + @UiField + ListBox typeFilter; + + @UiField + VerticalPanel foldersPanel; + + @UiField + VerticalPanel tagsPanel; + + @UiField + VerticalPanel previewPanel; + + @UiField + VerticalPanel propertiesPanel; + + private ProjectNode selectedAssetNode; + + public AssetManagerPanel() { + initWidget(UI_BINDER.createAndBindUi(this)); + + // Initialize UI components + initializeUI(); + + // Add event handlers + addEventHandlers(); + } + + private void initializeUI() { + // Set up search box + searchBox.setTitle(MESSAGES.searchAssetsPlaceholder()); + + // Set up upload button + uploadButton.setText(MESSAGES.uploadAssetButton()); + + // Set up create folder button + createFolderButton.setText(MESSAGES.createFolderButton()); + + // Set up type filter + typeFilter.addItem(MESSAGES.allTypesFilter()); + typeFilter.addItem(MESSAGES.imagesFilter()); + typeFilter.addItem(MESSAGES.audioFilter()); + typeFilter.addItem(MESSAGES.videoFilter()); + typeFilter.addItem(MESSAGES.otherFilter()); + } + + private void addEventHandlers() { + // Search box handler + searchBox.addKeyUpHandler(new KeyUpHandler() { + @Override + public void onKeyUp(KeyUpEvent event) { + // TODO: Implement search functionality + } + }); + + // Upload button handler + uploadButton.addClickHandler(new ClickHandler() { + @Override + public void onClick(ClickEvent event) { + // TODO: Implement file upload dialog + } + }); + + // Create folder button handler + createFolderButton.addClickHandler(new ClickHandler() { + @Override + public void onClick(ClickEvent event) { + // TODO: Implement create folder dialog + } + }); + + // Type filter handler + typeFilter.addChangeHandler(new ChangeHandler() { + @Override + public void onChange(ChangeEvent event) { + // TODO: Implement type filtering + } + }); + + } + + /** + * Updates the folders panel with the current folder structure + */ + private void updateFoldersPanel() { + // TODO: Implement folder structure update + } + + /** + * Updates the tags panel with the current tags + */ + private void updateTagsPanel() { + // TODO: Implement tags update + } + + /** + * Updates the preview panel with the selected asset + */ + public void updatePreviewPanel(ProjectNode node) { + this.selectedAssetNode = node; + previewPanel.clear(); + if (node == null) { + return; + } + + String fileSuffix; + String fileUrl; + + if (node instanceof GlobalAssetProjectNode) { + fileSuffix = node.getFileId(); + fileUrl = "/ode/download/globalasset/" + node.getFileId(); + } else { + fileSuffix = node.getProjectId() + "/" + node.getFileId(); + fileUrl = StorageUtil.getFileUrl(node.getProjectId(), node.getFileId()); + } + + Widget previewWidget = null; + + if (StorageUtil.isImageFile(fileSuffix)) { // Image Preview + String fileType = StorageUtil.getContentTypeForFilePath(fileSuffix); + if (fileType.endsWith("png") || fileType.endsWith("jpeg") || fileType.endsWith("gif") + || fileType.endsWith("bmp") || fileType.endsWith("svg+xml")) { + Image img = new Image(fileUrl); + img.getElement().getStyle().setProperty("maxWidth","600px"); + previewWidget = img; + } + } else if (StorageUtil.isAudioFile(fileSuffix)) { // Audio Preview + String fileType = StorageUtil.getContentTypeForFilePath(fileSuffix); + if (fileType.endsWith("mp3") || fileType.endsWith("wav") || fileType.endsWith("ogg")) { + previewWidget = new HTML(""); + } + } else if (StorageUtil.isVideoFile(fileSuffix)) { // Video Preview + String fileType = StorageUtil.getContentTypeForFilePath(fileSuffix); + if (fileType.endsWith("avi") || fileType.endsWith("mp4") || fileType.endsWith("webm")) { + previewWidget = new HTML(""); + } + } else if (StorageUtil.isFontFile(fileSuffix)) { // Font Preview + String fileType = StorageUtil.getContentTypeForFilePath(fileSuffix); + if (fileType.endsWith("ttf") || fileType.endsWith("otf")) { + previewWidget = getFontResourcePreviewPanel(fileUrl); + } + } + + if (previewWidget != null) { + previewPanel.add(previewWidget); + } else { + previewPanel.add(new HTML(MESSAGES.filePreviewError())); + } + } + + private VerticalPanel getFontResourcePreviewPanel(String fontResourceURL) { + VerticalPanel fontResourcePreviewPanel = new VerticalPanel(); + fontResourcePreviewPanel.setWidth("600px"); + fontResourcePreviewPanel.setHeight("400px"); + + HorizontalPanel fontPropertiesPanel = new HorizontalPanel(); + fontPropertiesPanel.setHeight("100px"); + fontPropertiesPanel.setWidth("600px"); + fontPropertiesPanel.setVerticalAlignment(HasVerticalAlignment.ALIGN_MIDDLE); + + final TextBox textPreviewTextBox = new TextBox(); + textPreviewTextBox.getElement().setPropertyString("placeholder", "Text for Preview"); + + final TextBox fontSize = new TextBox(); + fontSize.getElement().setPropertyString("placeholder", "Font Size"); + fontSize.setText("16"); + + CheckBox isFontBold = new CheckBox("Font Bold"); + CheckBox isFontItalic = new CheckBox("Font Italic"); + + fontPropertiesPanel.add(textPreviewTextBox); + fontPropertiesPanel.add(fontSize); + fontPropertiesPanel.add(isFontBold); + fontPropertiesPanel.add(isFontItalic); + + fontResourcePreviewPanel.add(fontPropertiesPanel); + + VerticalPanel fontPreviewPanel = new VerticalPanel(); + fontPreviewPanel.setHeight("300px"); + fontPreviewPanel.setWidth("600px"); + fontPreviewPanel.setHorizontalAlignment(HasHorizontalAlignment.ALIGN_CENTER); + fontPreviewPanel.setVerticalAlignment(HasVerticalAlignment.ALIGN_MIDDLE); + + StyleElement styleElement = Document.get().createStyleElement(); + String resource = "@font-face {"; + resource += "font-family: testFontFamily;"; + resource += "src: url(\"" + fontResourceURL + "\");"; + resource += "}"; + styleElement.setInnerText(resource); + + fontResourcePreviewPanel.getElement().insertFirst(styleElement); + + final Label previewText = new Label(); + DOM.setStyleAttribute(previewText.getElement(), "fontFamily", "testFontFamily"); + DOM.setStyleAttribute(previewText.getElement(), "fontSize", + (int)(16 * 0.9) + "px"); + + fontPreviewPanel.add(previewText); + fontResourcePreviewPanel.add(fontPreviewPanel); + + textPreviewTextBox.addKeyUpHandler(new KeyUpHandler() { + @Override + public void onKeyUp(KeyUpEvent keyUpEvent) { + previewText.setText(textPreviewTextBox.getText()); + } + }); + + fontSize.addKeyUpHandler(new KeyUpHandler() { + @Override + public void onKeyUp(KeyUpEvent keyUpEvent) { + if (fontSize.getText() != null && !fontSize.getText().equals("")) { + try { + DOM.setStyleAttribute(previewText.getElement(), "fontSize", + (int)(Float.parseFloat(fontSize.getText()) * 0.9) + "px"); + } catch (NumberFormatException e) { + // Ignore this. If we throw an exception here, the project is unrecoverable. + } + } + } + }); + + isFontBold.addValueChangeHandler(new ValueChangeHandler() { + @Override + public void onValueChange(ValueChangeEvent valueChangeEvent) { + if (valueChangeEvent.getValue()) { + DOM.setStyleAttribute(previewText.getElement(), "fontWeight", "bold"); + } else { + DOM.setStyleAttribute(previewText.getElement(), "fontWeight", "normal"); + } + } + }); + + isFontItalic.addValueChangeHandler(new ValueChangeHandler() { + @Override + public void onValueChange(ValueChangeEvent valueChangeEvent) { + if (valueChangeEvent.getValue()) { + DOM.setStyleAttribute(previewText.getElement(), "fontStyle", "italic"); + } else { + DOM.setStyleAttribute(previewText.getElement(), "fontStyle", "normal"); + } + } + }); + + return fontResourcePreviewPanel; + } + + /** + * Updates the properties panel with the selected asset's properties + */ + private void updatePropertiesPanel() { + // TODO: Implement properties update + } +} \ No newline at end of file diff --git a/appinventor/appengine/src/com/google/appinventor/client/editor/youngandroid/AssetManagerPanel.ui.xml b/appinventor/appengine/src/com/google/appinventor/client/editor/youngandroid/AssetManagerPanel.ui.xml new file mode 100644 index 00000000000..f75b52870b6 --- /dev/null +++ b/appinventor/appengine/src/com/google/appinventor/client/editor/youngandroid/AssetManagerPanel.ui.xml @@ -0,0 +1,156 @@ + + + + + + .mainPanel { + width: 100%; + height: 300px; + display: flex; + flex-direction: column; + background-color: white; + } + + .toolbarPanel { + padding: 4px; + background-color: #f5f5f5; + border-bottom: 1px solid #ddd; + display: flex; + align-items: center; + gap: 4px; + } + + .searchBox { + flex: 1; + min-width: 150px; + padding: 4px; + border: 1px solid #ddd; + border-radius: 2px; + } + + .contentPanel { + display: flex; + flex: 1; + overflow: hidden; + } + + .leftPanel { + width: 180px; + border-right: 1px solid #ddd; + display: flex; + flex-direction: column; + overflow-y: auto; + } + + .centerPanel { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + } + + .rightPanel { + width: 200px; + border-left: 1px solid #ddd; + display: flex; + flex-direction: column; + overflow-y: auto; + } + + .sectionTitle { + font-weight: bold; + padding: 4px 8px; + background-color: #f5f5f5; + border-bottom: 1px solid #ddd; + font-size: 12px; + } + + .sectionContent { + padding: 4px; + flex: 1; + overflow-y: auto; + } + + .previewPanel { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + background-color: #fafafa; + border-bottom: 1px solid #ddd; + padding: 8px; + } + + .propertiesPanel { + padding: 8px; + font-size: 12px; + } + + .propertyRow { + display: flex; + margin-bottom: 4px; + } + + .propertyLabel { + width: 80px; + font-weight: bold; + } + + .propertyValue { + flex: 1; + } + + .button { + padding: 4px 8px; + border: 1px solid #ddd; + border-radius: 2px; + background-color: white; + cursor: pointer; + font-size: 12px; + } + + .button:hover { + background-color: #f5f5f5; + } + + .uploadButton { + padding: 4px 8px; + border: none; + border-radius: 2px; + background-color: #4285f4; + color: white; + cursor: pointer; + font-size: 12px; + } + + .uploadButton:hover { + background-color: #3367d6; + } + + + + + + + + + + + + + Folders + + + Tags + + + + + + + + + + \ No newline at end of file diff --git a/appinventor/appengine/src/com/google/appinventor/client/editor/youngandroid/YaProjectEditor.java b/appinventor/appengine/src/com/google/appinventor/client/editor/youngandroid/YaProjectEditor.java index 39a35c840c0..6900532440d 100644 --- a/appinventor/appengine/src/com/google/appinventor/client/editor/youngandroid/YaProjectEditor.java +++ b/appinventor/appengine/src/com/google/appinventor/client/editor/youngandroid/YaProjectEditor.java @@ -71,6 +71,9 @@ import java.util.Set; import java.util.logging.Logger; +import com.google.gwt.uibinder.client.UiField; +import com.google.appinventor.client.editor.youngandroid.AssetManagerPanel; + /** * Project editor for Young Android projects. Each instance corresponds to * one project that has been opened in this App Inventor session. @@ -92,6 +95,9 @@ interface ClassicUi extends UiBinder {} @UiTemplate("YaProjectEditorCombined.ui.xml") interface CombinedUi extends UiBinder {} + @UiField + AssetManagerPanel assetManagerPanel; + // FileEditors in a YA project come in sets. Every form in the project has // a YaFormEditor for editing the UI, and a YaBlocksEditor for editing the // blocks representation of the program logic. Some day it may also have an @@ -225,6 +231,11 @@ public void onSuccess(String result) { defaultCloudDBToken = result; } }); + + // Initialize asset manager panel + if (assetManagerPanel != null) { + assetManagerPanel.setVisible(true); + } } public String getDefaultCloudDBToken() { diff --git a/appinventor/appengine/src/com/google/appinventor/client/editor/youngandroid/YaProjectEditorCombined.ui.xml b/appinventor/appengine/src/com/google/appinventor/client/editor/youngandroid/YaProjectEditorCombined.ui.xml index c5829a6d1ca..24dfda8e59b 100644 --- a/appinventor/appengine/src/com/google/appinventor/client/editor/youngandroid/YaProjectEditorCombined.ui.xml +++ b/appinventor/appengine/src/com/google/appinventor/client/editor/youngandroid/YaProjectEditorCombined.ui.xml @@ -19,6 +19,7 @@ + diff --git a/appinventor/appengine/src/com/google/appinventor/client/editor/youngandroid/properties/ListWithNone.java b/appinventor/appengine/src/com/google/appinventor/client/editor/youngandroid/properties/ListWithNone.java index 8704ddf6c18..3237c733b2e 100644 --- a/appinventor/appengine/src/com/google/appinventor/client/editor/youngandroid/properties/ListWithNone.java +++ b/appinventor/appengine/src/com/google/appinventor/client/editor/youngandroid/properties/ListWithNone.java @@ -21,6 +21,7 @@ interface ListBoxWrapper { String getItem(int index); void removeItem(int index); void setSelectedIndex(int index); + int getItemCount(); } private final ListBoxWrapper listBoxWrapper; @@ -136,4 +137,26 @@ String getDisplayItemForValue(String value) { } throw new IllegalArgumentException("Illegal value: " + value); } + + /** + * Removes the 'None' entry if present as the first item. + */ + void clearNoneItem() { + if (!values.isEmpty() && values.get(0).equals("")) { + values.remove(0); + if (listBoxWrapper.getItemCount() > 0) { + listBoxWrapper.removeItem(0); + } + } + } + + /** + * Ensures the 'None' entry is present as the first item. + */ + void updateNoneItem() { + if (values.isEmpty() || !values.get(0).equals("")) { + values.add(0, ""); + listBoxWrapper.addItem(noneDisplayItem); + } + } } diff --git a/appinventor/appengine/src/com/google/appinventor/client/editor/youngandroid/properties/YoungAndroidAssetSelectorPropertyEditor.java b/appinventor/appengine/src/com/google/appinventor/client/editor/youngandroid/properties/YoungAndroidAssetSelectorPropertyEditor.java index 274d069388f..0ab549fc919 100644 --- a/appinventor/appengine/src/com/google/appinventor/client/editor/youngandroid/properties/YoungAndroidAssetSelectorPropertyEditor.java +++ b/appinventor/appengine/src/com/google/appinventor/client/editor/youngandroid/properties/YoungAndroidAssetSelectorPropertyEditor.java @@ -14,6 +14,11 @@ import com.google.appinventor.client.explorer.project.Project; import com.google.appinventor.client.explorer.project.ProjectChangeListener; import com.google.appinventor.client.widgets.properties.AdditionalChoicePropertyEditor; +import com.google.appinventor.client.OdeAsyncCallback; +import com.google.appinventor.shared.rpc.globalasset.GlobalAssetService; +import com.google.appinventor.shared.rpc.globalasset.GlobalAssetServiceAsync; +import com.google.appinventor.shared.rpc.project.GlobalAsset; +import com.google.gwt.core.client.GWT; import com.google.appinventor.client.wizards.FileUploadWizard; import com.google.appinventor.client.wizards.FileUploadWizard.FileUploadedCallback; import com.google.appinventor.shared.rpc.project.FileNode; @@ -22,6 +27,8 @@ import com.google.appinventor.shared.rpc.project.youngandroid.YoungAndroidAssetNode; import com.google.appinventor.shared.rpc.project.youngandroid.YoungAndroidAssetsFolder; import com.google.appinventor.shared.rpc.project.youngandroid.YoungAndroidProjectNode; +import java.util.List; +import java.util.ArrayList; import com.google.gwt.event.dom.client.ChangeEvent; import com.google.gwt.event.dom.client.ChangeHandler; import com.google.gwt.event.dom.client.ClickEvent; @@ -41,10 +48,12 @@ public final class YoungAndroidAssetSelectorPropertyEditor extends AdditionalCho implements ProjectChangeListener { // UI elements private final ListBox assetsList; +private final List assetFileIds = new ArrayList(); // To store actual fileIds private final ListWithNone choices; private final YoungAndroidAssetsFolder assetsFolder; + private final GlobalAssetServiceAsync globalAssetService = GWT.create(GlobalAssetService.class); /** * Creates a new property editor for selecting a Young Android asset. @@ -88,14 +97,15 @@ public void removeItem(int index) { public void setSelectedIndex(int index) { assetsList.setSelectedIndex(index); } + + @Override + public int getItemCount() { + return assetsList.getItemCount(); + } }); // Fill choices with the assets. - if (assetsFolder != null) { - for (ProjectNode node : assetsFolder.getChildren()) { - choices.addItem(node.getName()); - } - } + loadAssetChoices(); Button addButton = new Button(MESSAGES.addButton()); addButton.setWidth("100%"); @@ -105,9 +115,10 @@ public void onClick(ClickEvent event) { FileUploadedCallback callback = new FileUploadedCallback() { @Override public void onFileUploaded(FolderNode folderNode, FileNode fileNode) { - // At this point, the asset has been uploaded to the server, and - // has even been added to the assetsFolder. We are all set! - choices.selectValue(fileNode.getName()); + loadAssetChoices(); + if (fileNode != null) { + choices.selectValue(fileNode.getFileId()); + } closeAdditionalChoiceDialog(true); } }; @@ -167,52 +178,168 @@ protected String getPropertyValueSummary() { if (isMultipleValues()) { return MESSAGES.multipleValues(); } - String value = property.getValue(); - if (choices.containsValue(value)) { - return choices.getDisplayItemForValue(value); + String currentFileId = property.getValue(); + if (currentFileId == null || currentFileId.isEmpty()) { + return MESSAGES.noneCaption(); + } + + for (int i = 0; i < assetFileIds.size(); i++) { + if (assetFileIds.get(i).equals(currentFileId)) { + int indexInListBox = i; + // Adjust index if "None" is the first item in the displayed list + if (assetsList.getItemCount() > 0 && assetsList.getItemText(0).equals(MESSAGES.noneCaption())) { + indexInListBox = i + 1; + } + if (indexInListBox >= 0 && indexInListBox < assetsList.getItemCount()) { + return assetsList.getItemText(indexInListBox); + } + } } - return value; + // If the fileId is not in assetFileIds, it might be a manually entered path or an old value. + // Display it, possibly formatted if it's a global asset. + if (currentFileId.startsWith("assets/_global_/")) { + String pathWithoutPrefix = currentFileId.substring("assets/_global_/".length()); + return "[G] " + pathWithoutPrefix + " (missing?)"; + } + // For project assets, it might be just "asset.png" if it's an old project or manually entered. + // Or "assets/asset.png" if it was saved with the new logic. + if (currentFileId.startsWith("assets/")) { + return currentFileId.substring("assets/".length()) + " (missing?)"; + } + return currentFileId + " (missing?)"; } @Override protected boolean okAction() { - int selected = assetsList.getSelectedIndex(); - if (selected == -1) { + int selectedIndexInListBox = assetsList.getSelectedIndex(); + if (selectedIndexInListBox == -1) { Window.alert(MESSAGES.noAssetSelected()); return false; } + + String selectedDisplayName = assetsList.getItemText(selectedIndexInListBox); + String valueToSet; + + if (selectedDisplayName.equals(MESSAGES.noneCaption())) { + valueToSet = ""; // Represents "None" + } else { + int actualIndexInAssetFileIds = selectedIndexInListBox; + // Adjust index if "None" is the first item in the displayed list + if (assetsList.getItemCount() > 0 && assetsList.getItemText(0).equals(MESSAGES.noneCaption())) { + actualIndexInAssetFileIds = selectedIndexInListBox - 1; + } + + if (actualIndexInAssetFileIds >= 0 && actualIndexInAssetFileIds < assetFileIds.size()) { + valueToSet = assetFileIds.get(actualIndexInAssetFileIds); + } else { + Window.alert("Error: Asset selection out of sync. Please try again."); + return false; + } + } + boolean multiple = isMultipleValues(); setMultipleValues(false); - property.setValue(choices.getValueAtIndex(selected), multiple); + property.setValue(valueToSet, multiple); return true; } + private void loadAssetChoices() { + assetsList.clear(); // Directly clear the ListBox + assetFileIds.clear(); + + choices.clearNoneItem(); + + if (assetsFolder != null) { + for (ProjectNode node : assetsFolder.getChildren()) { + addAssetChoice(node.getName(), node.getFileId()); + } + } + + finalizeAssetLoading(); + } + + private void finalizeAssetLoading() { + choices.updateNoneItem(); + } + + private void addAssetChoice(String name, String fileId) { + String displayName = name; + + // Handle old-style global assets that were added to the project + if (fileId.startsWith("assets/_global_/")) { + String pathWithoutPrefix = fileId.substring("assets/_global_/".length()); + // Show global assets with a [G] prefix to distinguish them + displayName = "[G] " + pathWithoutPrefix; + } + // Handle new-style global assets with folder prefix (e.g., "assets/icons_home.png") + else if (fileId.startsWith("assets/") && name.contains("_")) { + // Check if this might be a folder-prefixed global asset + String assetName = fileId.substring("assets/".length()); + if (assetName.contains("_")) { + int underscoreIndex = assetName.indexOf("_"); + String possibleFolder = assetName.substring(0, underscoreIndex); + String possibleFilename = assetName.substring(underscoreIndex + 1); + // If it looks like a folder_filename pattern, show with [G] prefix + if (possibleFolder.length() > 0 && possibleFilename.length() > 0) { + displayName = "[G] " + possibleFolder + "/" + possibleFilename; + } + } + } + + // choices.addItem will add to assetsList (the UI ListBox) + choices.addItem(displayName); + assetFileIds.add(fileId); // Store the actual fileId in parallel + } + + private void removeAssetChoice(String fileIdToRemove) { + int indexToRemove = -1; + for (int i = 0; i < assetFileIds.size(); i++) { + if (assetFileIds.get(i).equals(fileIdToRemove)) { + indexToRemove = i; + break; + } + } + if (indexToRemove != -1) { + assetFileIds.remove(indexToRemove); + int indexInListBox = indexToRemove; + if (assetsList.getItemCount() > 0 && assetsList.getItemText(0).equals(MESSAGES.noneCaption())) { + if (indexToRemove + 1 < assetsList.getItemCount()) { + indexInListBox = indexToRemove + 1; + } + } + String currentPropertyValue = property.getValue(); + loadAssetChoices(); + choices.selectValue(currentPropertyValue); + } + } + // ProjectChangeListener implementation @Override public void onProjectLoaded(Project project) { + loadAssetChoices(); } @Override public void onProjectNodeAdded(Project project, ProjectNode node) { - // Check whether our asset was updated. - if (node instanceof YoungAndroidAssetNode) { - String assetName = node.getName(); - - // Add it to the list if it isn't already there. - // It could already be there if the user adds an asset that's already there, which is the way - // to replace the asset. - if (!choices.containsValue(assetName)) { - choices.addItem(assetName); + if (node instanceof YoungAndroidAssetNode && node.getProjectId() == assetsFolder.getProjectId()) { + // Check if it's a new asset not yet in our list (could be a replacement) + boolean found = false; + for (String fileId : assetFileIds) { + if (fileId.equals(node.getFileId())) { + found = true; + break; + } + } + if (!found) { + addAssetChoice(node.getName(), node.getFileId()); } - // Check whether our asset was updated. + // If this newly added node corresponds to the current property value, + // refresh the component (e.g. image preview). String currentValue = property.getValue(); - if (assetName.equals(currentValue)) { - // Our asset was updated. - // Set the property value to blank and then back to the current value. - // This will force the component to update itself (for example, it will refresh its image). - property.setValue(""); + if (node.getFileId().equals(currentValue)) { + property.setValue(""); // Force refresh property.setValue(currentValue); } } @@ -220,18 +347,14 @@ public void onProjectNodeAdded(Project project, ProjectNode node) { @Override public void onProjectNodeRemoved(Project project, ProjectNode node) { - if (node instanceof YoungAndroidAssetNode) { - String assetName = node.getName(); + if (node instanceof YoungAndroidAssetNode && node.getProjectId() == assetsFolder.getProjectId()) { + String removedFileId = node.getFileId(); + String currentPropertyValue = property.getValue(); - // Check whether our asset was removed. - String currentValue = property.getValue(); - if (node.getName().equals(currentValue)) { - // Our asset was removed. - property.setValue(""); + if (removedFileId.equals(currentPropertyValue)) { + property.setValue(""); // Clear property if the selected asset was removed } - - // Remove the asset from the list. - choices.removeValue(assetName); + removeAssetChoice(removedFileId); // Remove from our lists } } } diff --git a/appinventor/appengine/src/com/google/appinventor/client/editor/youngandroid/properties/YoungAndroidComponentSelectorPropertyEditor.java b/appinventor/appengine/src/com/google/appinventor/client/editor/youngandroid/properties/YoungAndroidComponentSelectorPropertyEditor.java index 9930effecb2..42914298e7c 100644 --- a/appinventor/appengine/src/com/google/appinventor/client/editor/youngandroid/properties/YoungAndroidComponentSelectorPropertyEditor.java +++ b/appinventor/appengine/src/com/google/appinventor/client/editor/youngandroid/properties/YoungAndroidComponentSelectorPropertyEditor.java @@ -95,6 +95,11 @@ public void removeItem(int index) { public void setSelectedIndex(int index) { componentsList.setSelectedIndex(index); } + + @Override + public int getItemCount() { + return componentsList.getItemCount(); + } }); // At this point, the editor hasn't finished loading. diff --git a/appinventor/appengine/src/com/google/appinventor/client/editor/youngandroid/properties/YoungAndroidDataColumnSelectorProperty.java b/appinventor/appengine/src/com/google/appinventor/client/editor/youngandroid/properties/YoungAndroidDataColumnSelectorProperty.java index aed8b813f4d..e0394f77186 100644 --- a/appinventor/appengine/src/com/google/appinventor/client/editor/youngandroid/properties/YoungAndroidDataColumnSelectorProperty.java +++ b/appinventor/appengine/src/com/google/appinventor/client/editor/youngandroid/properties/YoungAndroidDataColumnSelectorProperty.java @@ -143,6 +143,11 @@ public void removeItem(int index) { public void setSelectedIndex(int index) { columnsList.setSelectedIndex(index); } + + @Override + public int getItemCount() { + return columnsList.getItemCount(); + } }); } diff --git a/appinventor/appengine/src/com/google/appinventor/client/editor/youngandroid/properties/YoungAndroidFontTypefaceChoicePropertyEditor.java b/appinventor/appengine/src/com/google/appinventor/client/editor/youngandroid/properties/YoungAndroidFontTypefaceChoicePropertyEditor.java index 125b448378e..f1b5a874b08 100644 --- a/appinventor/appengine/src/com/google/appinventor/client/editor/youngandroid/properties/YoungAndroidFontTypefaceChoicePropertyEditor.java +++ b/appinventor/appengine/src/com/google/appinventor/client/editor/youngandroid/properties/YoungAndroidFontTypefaceChoicePropertyEditor.java @@ -87,6 +87,11 @@ public void removeItem(int index) { public void setSelectedIndex(int index) { fontAssetsList.setSelectedIndex(index); } + + @Override + public int getItemCount() { + return fontAssetsList.getItemCount(); + } }); choices.addItem("0", MESSAGES.defaultFontTypeface()); diff --git a/appinventor/appengine/src/com/google/appinventor/client/editor/youngandroid/properties/YoungAndroidGeoJSONPropertyEditor.java b/appinventor/appengine/src/com/google/appinventor/client/editor/youngandroid/properties/YoungAndroidGeoJSONPropertyEditor.java index ba6e2c2f91d..67a8059612c 100644 --- a/appinventor/appengine/src/com/google/appinventor/client/editor/youngandroid/properties/YoungAndroidGeoJSONPropertyEditor.java +++ b/appinventor/appengine/src/com/google/appinventor/client/editor/youngandroid/properties/YoungAndroidGeoJSONPropertyEditor.java @@ -76,6 +76,11 @@ public String getItem(int index) { public void addItem(String item) { assetsList.addItem(item); } + + @Override + public int getItemCount() { + return assetsList.getItemCount(); + } }); // Fill choices with assets. diff --git a/appinventor/appengine/src/com/google/appinventor/client/explorer/commands/PreviewFileCommand.java b/appinventor/appengine/src/com/google/appinventor/client/explorer/commands/PreviewFileCommand.java index b169bc3e9ea..921b2d0d82f 100644 --- a/appinventor/appengine/src/com/google/appinventor/client/explorer/commands/PreviewFileCommand.java +++ b/appinventor/appengine/src/com/google/appinventor/client/explorer/commands/PreviewFileCommand.java @@ -17,6 +17,7 @@ import com.google.gwt.event.logical.shared.ValueChangeHandler; import com.google.gwt.user.client.DOM; import com.google.gwt.user.client.ui.*; +import com.google.appinventor.shared.rpc.project.GlobalAssetProjectNode; import static com.google.appinventor.client.Ode.MESSAGES; @@ -95,14 +96,21 @@ public void onClick(ClickEvent event) { * @return widget */ private Widget generateFilePreview(ProjectNode node) { - String fileSuffix = node.getProjectId() + "/" + node.getFileId(); - String fileUrl = StorageUtil.getFileUrl(node.getProjectId(), node.getFileId()); + String fileSuffix; + String fileUrl; + if (node instanceof GlobalAssetProjectNode) { + fileSuffix = node.getFileId(); + fileUrl = "/ode/download/globalasset/" + node.getFileId(); + } else { + fileSuffix = node.getProjectId() + "/" + node.getFileId(); + fileUrl = StorageUtil.getFileUrl(node.getProjectId(), node.getFileId()); + } if (StorageUtil.isImageFile(fileSuffix)) { // Image Preview String fileType = StorageUtil.getContentTypeForFilePath(fileSuffix); // Support preview for file types that all major browser support if (fileType.endsWith("png") || fileType.endsWith("jpeg") || fileType.endsWith("gif") - || fileType.endsWith("bmp") || fileType.endsWith("svg+xml")) { + || fileType.endsWith("bmp") || fileType.endsWith("svg+xml") || fileType.endsWith("webp")) { Image img = new Image(fileUrl); img.getElement().getStyle().setProperty("maxWidth","600px"); return img; diff --git a/appinventor/appengine/src/com/google/appinventor/client/explorer/youngandroid/AssetList.java b/appinventor/appengine/src/com/google/appinventor/client/explorer/youngandroid/AssetList.java index fdf9fafe0b7..d3496b02564 100644 --- a/appinventor/appengine/src/com/google/appinventor/client/explorer/youngandroid/AssetList.java +++ b/appinventor/appengine/src/com/google/appinventor/client/explorer/youngandroid/AssetList.java @@ -10,37 +10,53 @@ import com.google.appinventor.client.Images; import com.google.appinventor.client.Ode; +import com.google.appinventor.client.OdeAsyncCallback; +import com.google.appinventor.client.wizards.GlobalAssetUpdateDialog; +import com.google.appinventor.shared.rpc.project.GlobalAsset; +import com.google.appinventor.shared.rpc.globalasset.GlobalAssetService; +import com.google.appinventor.shared.rpc.globalasset.GlobalAssetServiceAsync; +import com.google.gwt.core.client.GWT; +import com.google.gwt.event.dom.client.ClickEvent; +import com.google.gwt.event.dom.client.ClickHandler; +import com.google.gwt.event.dom.client.ContextMenuEvent; +import com.google.gwt.event.dom.client.ContextMenuHandler; +import com.google.gwt.user.client.Window; +import com.google.gwt.user.client.ui.TreeItem; +import com.google.gwt.user.client.ui.Image; import com.google.appinventor.client.explorer.project.Project; import com.google.appinventor.client.explorer.project.ProjectChangeListener; import com.google.appinventor.client.explorer.project.ProjectNodeContextMenu; +import com.google.appinventor.client.widgets.ContextMenu; import com.google.appinventor.client.widgets.TextButton; import com.google.appinventor.client.wizards.FileUploadWizard; +import com.google.appinventor.client.wizards.FileUploadWizard.FileUploadedCallback; +import com.google.appinventor.shared.rpc.project.FileNode; +import com.google.appinventor.shared.rpc.project.FolderNode; import com.google.appinventor.shared.rpc.project.ProjectNode; +import com.google.appinventor.shared.rpc.project.GlobalAssetProjectNode; import com.google.appinventor.shared.rpc.project.youngandroid.YoungAndroidAssetNode; import com.google.appinventor.shared.rpc.project.youngandroid.YoungAndroidAssetsFolder; import com.google.appinventor.shared.rpc.project.youngandroid.YoungAndroidProjectNode; import com.google.appinventor.shared.storage.StorageUtil; -import com.google.gwt.event.dom.client.ClickEvent; -import com.google.gwt.event.dom.client.ClickHandler; -import com.google.gwt.event.dom.client.MouseMoveEvent; -import com.google.gwt.event.dom.client.MouseMoveHandler; import com.google.gwt.event.dom.client.BlurEvent; import com.google.gwt.event.dom.client.BlurHandler; import com.google.gwt.event.dom.client.FocusEvent; import com.google.gwt.event.dom.client.FocusHandler; -import com.google.gwt.event.dom.client.KeyCodes; -import com.google.gwt.event.dom.client.KeyDownHandler; import com.google.gwt.event.logical.shared.SelectionEvent; import com.google.gwt.event.logical.shared.SelectionHandler; import com.google.gwt.user.client.Event; import com.google.gwt.user.client.ui.Composite; import com.google.gwt.user.client.ui.HTML; -import com.google.gwt.user.client.ui.Image; import com.google.gwt.user.client.ui.SimplePanel; import com.google.gwt.user.client.ui.Tree; -import com.google.gwt.user.client.ui.TreeItem; import com.google.gwt.user.client.ui.VerticalPanel; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; import java.util.logging.Logger; +import com.google.gwt.user.client.Command; +import com.google.appinventor.client.explorer.commands.PreviewFileCommand; /** * The asset list shows all the project's assets, and lets the @@ -58,8 +74,23 @@ public class AssetList extends Composite implements ProjectChangeListener { private long projectId; private Project project; private YoungAndroidAssetsFolder assetsFolder; - private int clientX; - private int clientY; + private final GlobalAssetServiceAsync globalAssetService = GWT.create(GlobalAssetService.class); + + private class AssetListItemData { + String displayName; + String fullPath; + boolean isGlobal; + ProjectNode projectNode; + GlobalAsset globalAsset; + + AssetListItemData(String displayName, String fullPath, boolean isGlobal, ProjectNode projectNode, GlobalAsset globalAsset) { + this.displayName = displayName; + this.fullPath = fullPath; + this.isGlobal = isGlobal; + this.projectNode = projectNode; + this.globalAsset = globalAsset; + } + } /** * Creates a new AssetList @@ -78,8 +109,26 @@ public AssetList() { addButton.addClickHandler(new ClickHandler() { @Override public void onClick(ClickEvent event) { - if (assetsFolder != null) { - new FileUploadWizard(assetsFolder).show(); + if (assetsFolder != null) { // assetsFolder is null if no project is loaded + // Define a callback that always refreshes the AssetList + FileUploadWizard.FileUploadedCallback refreshCallback = + new FileUploadWizard.FileUploadedCallback() { + @Override + public void onFileUploaded(FolderNode folderNode, FileNode fileNode) { + // folderNode and fileNode might be null if a global asset was uploaded + // by the wizard, depending on its internal changes. + // Regardless, always refresh the AssetList. + refreshAssetList(); + } + }; + new FileUploadWizard(assetsFolder, refreshCallback).show(); + } else { + // Handle case where no project is loaded, perhaps disable add button or show message + // Or, if global assets can be uploaded without a project context (requires a different wizard invocation) + // For now, assume addButton is for project context or wizard handles null assetsFolder for global-only. + // The current FileUploadWizard constructor takes a FolderNode, so it expects a project context. + // This implies the "Add global asset" checkbox in the wizard is for adding to the user's global store + // *while a project is open*. } } }); @@ -94,23 +143,90 @@ public void onClick(ClickEvent event) { initWidget(panel); assetList.setScrollOnSelectEnabled(false); - assetList.sinkEvents(Event.ONMOUSEMOVE); - assetList.addMouseMoveHandler(new MouseMoveHandler() { + assetList.sinkEvents(Event.ONCONTEXTMENU); + + assetList.addDomHandler(new ContextMenuHandler() { @Override - public void onMouseMove(MouseMoveEvent event) { - clientX = event.getClientX(); - clientY = event.getClientY(); + public void onContextMenu(ContextMenuEvent event) { + event.preventDefault(); + event.stopPropagation(); + TreeItem selected = assetList.getSelectedItem(); + if (selected != null) { + Object userObject = selected.getUserObject(); + if (userObject instanceof AssetListItemData) { + AssetListItemData itemData = (AssetListItemData) userObject; + if (itemData.isGlobal) { + final ContextMenu menu = new ContextMenu(); + menu.setPopupPosition(event.getNativeEvent().getClientX(), event.getNativeEvent().getClientY()); + menu.addItem(MESSAGES.previewButton(), new Command() { + @Override + public void execute() { + menu.hide(); + ProjectNode globalAssetProjectNode = new GlobalAssetProjectNode( + itemData.globalAsset.getFileName(), + itemData.globalAsset.getFolder() + "/" + itemData.globalAsset.getFileName() + ); + // Use PreviewFileCommand to open preview dialog/tab for global assets + new PreviewFileCommand().execute(globalAssetProjectNode); + } + }); + menu.addItem(MESSAGES.downloadButton(), new Command() { + @Override + public void execute() { + menu.hide(); + downloadGlobalAsset(itemData.globalAsset); + } + }); + menu.addItem(MESSAGES.linkToProjectButton(), new Command() { + @Override + public void execute() { + menu.hide(); + if (projectId != 0) { + globalAssetService.linkGlobalAssetToProject(projectId, itemData.globalAsset.getFileName(), itemData.globalAsset.getTimestamp(), + new OdeAsyncCallback(MESSAGES.linkGlobalAssetError()) { + @Override + public void onSuccess(Void result) { + Ode.getInstance().getEditorManager().getOpenProjectEditor(projectId).getFileEditor(itemData.fullPath); + + // Asset has been linked server-side - refresh the asset list to show changes + refreshAssetList(); + } + }); + } else { + Window.alert(MESSAGES.noProjectOpenForLinking()); + } + } + }); + menu.show(); + } else if (itemData.projectNode != null) { + ProjectNodeContextMenu.show(itemData.projectNode, selected.getWidget(), event.getNativeEvent().getClientX(), event.getNativeEvent().getClientY()); + } + } + } } - }); + }, ContextMenuEvent.getType()); + assetList.addSelectionHandler(new SelectionHandler() { @Override public void onSelection(SelectionEvent event) { TreeItem selected = event.getSelectedItem(); - ProjectNode node = (ProjectNode) selected.getUserObject(); - // The actual menu is determined by what is registered for the filenode - // type in CommandRegistry.java - ProjectNodeContextMenu.show(node, selected.getWidget(), clientX, clientY); - }}); + Object userObject = selected.getUserObject(); + if (userObject instanceof AssetListItemData) { + AssetListItemData itemData = (AssetListItemData) userObject; + if (!itemData.isGlobal && itemData.projectNode != null) { + // Ode.getInstance().getAssetManagerPanel().updatePreviewPanel(itemData.projectNode); + } else if (itemData.isGlobal) { + // For global assets, create a ProjectNode from GlobalAsset for preview + // This is a temporary ProjectNode for preview purposes only + ProjectNode globalAssetProjectNode = new GlobalAssetProjectNode( + itemData.globalAsset.getFileName(), + itemData.globalAsset.getFolder() + "/" + itemData.globalAsset.getFileName() // Use folder/filename as fileId for preview + ); + // Ode.getInstance().getAssetManagerPanel().updatePreviewPanel(globalAssetProjectNode); + } + } + } + }); assetList.addFocusHandler(new FocusHandler() { @Override public void onFocus(FocusEvent event) { @@ -126,38 +242,59 @@ public void onBlur(BlurEvent event) { } /* - * Populate the asset tree with files from the project's assets folder. + * Populate the asset tree with files from the project's assets folder and global assets. */ private void refreshAssetList() { - final Images images = Ode.getImageBundle(); LOG.info("AssetList: refreshing for project " + projectId); assetList.clear(); + final List itemsToDisplay = new ArrayList(); + // Load Project Assets if (assetsFolder != null) { for (ProjectNode node : assetsFolder.getChildren()) { - // Add the name to the tree. We need to enclose it in a span - // because the CSS style for selection specifies a span. String nodeName = node.getName(); - if (nodeName.length() > 20) - nodeName = nodeName.substring(0, 8) + "..." + nodeName.substring(nodeName.length() - 9, - nodeName.length()); - - String fileSuffix = node.getProjectId() + "/" + node.getFileId(); - String treeItemText = ""; - if (StorageUtil.isImageFile(fileSuffix)) { - treeItemText += new Image(images.mediaIconImg()); - } else if (StorageUtil.isAudioFile(fileSuffix )) { - treeItemText += new Image(images.mediaIconAudio()); - } else if (StorageUtil.isVideoFile(fileSuffix )) { - treeItemText += new Image(images.mediaIconVideo()); - } - treeItemText += nodeName + ""; - TreeItem treeItem = new TreeItem(new HTML(treeItemText)); - // keep a pointer from the tree item back to the actual node - treeItem.setUserObject(node); - assetList.addItem(treeItem); + // TODO: Apply existing truncation logic for nodeName if needed for display consistency + // String truncatedNodeName = nodeName; + // if (nodeName.length() > 20) + // truncatedNodeName = nodeName.substring(0, 8) + "..." + nodeName.substring(nodeName.length() - 9, nodeName.length()); + itemsToDisplay.add(new AssetListItemData(nodeName, node.getFileId(), false, node, null)); } } + + // Fetch and Prepare Global Assets + populateTreeItems(itemsToDisplay); + } + + private void populateTreeItems(List items) { + // Sort the items by displayName + Collections.sort(items, new Comparator() { + @Override + public int compare(AssetListItemData o1, AssetListItemData o2) { + return o1.displayName.compareToIgnoreCase(o2.displayName); + } + }); + + assetList.clear(); // Clear again before adding sorted items + final Images images = Ode.getImageBundle(); + + // Add project assets + for (AssetListItemData itemData : items) { + String treeItemText = ""; + String pathForIconDetection = (itemData.projectNode != null ? itemData.projectNode.getProjectId() + "/" + itemData.projectNode.getFileId() : itemData.fullPath); + if (StorageUtil.isImageFile(pathForIconDetection)) { + treeItemText += new Image(images.mediaIconImg()); + } else if (StorageUtil.isAudioFile(pathForIconDetection)) { + treeItemText += new Image(images.mediaIconAudio()); + } else if (StorageUtil.isVideoFile(pathForIconDetection)) { + treeItemText += new Image(images.mediaIconVideo()); + } else { + treeItemText += new Image(images.fileIcon()); + } + treeItemText += itemData.displayName + ""; + TreeItem treeItem = new TreeItem(new HTML(treeItemText)); + treeItem.setUserObject(itemData); + assetList.addItem(treeItem); + } } public void refreshAssetList(long projectId) { @@ -191,8 +328,8 @@ public void onProjectLoaded(Project project) { @Override public void onProjectNodeAdded(Project project, ProjectNode node) { - LOG.info("AssetList: got projectNodeAdded for node " + node.getFileId() - + " and project " + project.getProjectId() + ", current project is " + projectId); + LOG.info("AssetList: got projectNodeAdded for node " + node.getFileId() + + " and project " + project.getProjectId() + ", current project is " + projectId); if (node instanceof YoungAndroidAssetNode) { refreshAssetList(); } @@ -200,8 +337,8 @@ public void onProjectNodeAdded(Project project, ProjectNode node) { @Override public void onProjectNodeRemoved(Project project, ProjectNode node) { - LOG.info("AssetList: got onProjectNodeRemoved for node " + node.getFileId() - + " and project " + project.getProjectId() + ", current project is " + projectId); + LOG.info("AssetList: got onProjectNodeRemoved for node " + node.getFileId() + + " and project " + project.getProjectId() + ", current project is " + projectId); if (node instanceof YoungAndroidAssetNode) { refreshAssetList(); } @@ -210,4 +347,11 @@ public void onProjectNodeRemoved(Project project, ProjectNode node) { public Tree getTree() { return assetList; } + + private void downloadGlobalAsset(GlobalAsset globalAsset) { + // Construct the download URL for the global asset + String downloadUrl = GWT.getModuleBaseURL() + "download/globalasset/" + globalAsset.getFileName(); + Window.open(downloadUrl, "_blank", ""); + } + } diff --git a/appinventor/appengine/src/com/google/appinventor/client/style/neo/ImagesNeo.java b/appinventor/appengine/src/com/google/appinventor/client/style/neo/ImagesNeo.java index 789651a1020..cfe9ec734fc 100644 --- a/appinventor/appengine/src/com/google/appinventor/client/style/neo/ImagesNeo.java +++ b/appinventor/appengine/src/com/google/appinventor/client/style/neo/ImagesNeo.java @@ -10,6 +10,7 @@ import com.google.gwt.resources.client.ImageResource; public interface ImagesNeo extends Images { + ImagesNeo INSTANCE = com.google.gwt.core.client.GWT.create(ImagesNeo.class); /* * These are from Google's Material Icon set https://fonts.google.com/icons * */ @@ -643,4 +644,11 @@ public interface ImagesNeo extends Images { */ @Source("images/trendline.png") ImageResource trendline(); + + /** + * Sync icon for updated global assets. + * Material icon: sync + */ + @Source("images/syncIcon.png") + ImageResource syncIcon(); } diff --git a/appinventor/appengine/src/com/google/appinventor/client/style/neo/Ode.ui.xml b/appinventor/appengine/src/com/google/appinventor/client/style/neo/Ode.ui.xml index acbfea01cd5..115416f5155 100644 --- a/appinventor/appengine/src/com/google/appinventor/client/style/neo/Ode.ui.xml +++ b/appinventor/appengine/src/com/google/appinventor/client/style/neo/Ode.ui.xml @@ -9,6 +9,7 @@ xmlns:neo="urn:import:com.google.appinventor.client.style.neo" xmlns:box="urn:import:com.google.appinventor.client.boxes" xmlns:widgets="urn:import:com.google.appinventor.client.widgets" + xmlns:assetlib="urn:import:com.google.appinventor.client.assetlibrary" ui:generatedFormat="com.google.gwt.i18n.server.PropertyCatalogFactory" ui:generatedKeys="com.google.gwt.i18n.server.keygen.MethodNameKeyGenerator" ui:generateLocales="default"> @@ -43,6 +44,8 @@ + + diff --git a/appinventor/appengine/src/com/google/appinventor/client/style/neo/TopToolbarNeo.java b/appinventor/appengine/src/com/google/appinventor/client/style/neo/TopToolbarNeo.java index 44745738ca9..99a56f0d599 100644 --- a/appinventor/appengine/src/com/google/appinventor/client/style/neo/TopToolbarNeo.java +++ b/appinventor/appengine/src/com/google/appinventor/client/style/neo/TopToolbarNeo.java @@ -8,6 +8,7 @@ import com.google.appinventor.client.Ode; import com.google.appinventor.client.TopToolbar; import com.google.appinventor.client.widgets.DropDownButton; +import com.google.appinventor.client.widgets.DropDownItem; import com.google.appinventor.client.widgets.Toolbar; import com.google.gwt.core.client.GWT; import com.google.gwt.uibinder.client.UiBinder; @@ -26,6 +27,7 @@ interface TopToolbarUiBinderneo extends UiBinder {} @UiField DropDownButton buildDropDown; @UiField DropDownButton settingsDropDown; @UiField DropDownButton adminDropDown; + @UiField DropDownItem assetLibraryDropDown; @UiField (provided = true) Boolean hasWriteAccess; @Override diff --git a/appinventor/appengine/src/com/google/appinventor/client/style/neo/TopToolbarNeo.ui.xml b/appinventor/appengine/src/com/google/appinventor/client/style/neo/TopToolbarNeo.ui.xml index 9022065a336..eeef9d6684b 100644 --- a/appinventor/appengine/src/com/google/appinventor/client/style/neo/TopToolbarNeo.ui.xml +++ b/appinventor/appengine/src/com/google/appinventor/client/style/neo/TopToolbarNeo.ui.xml @@ -14,7 +14,7 @@ - @@ -28,6 +28,9 @@ + + + diff --git a/appinventor/appengine/src/com/google/appinventor/client/style/neo/images/syncIcon.png b/appinventor/appengine/src/com/google/appinventor/client/style/neo/images/syncIcon.png new file mode 100644 index 00000000000..c3170c5f841 Binary files /dev/null and b/appinventor/appengine/src/com/google/appinventor/client/style/neo/images/syncIcon.png differ diff --git a/appinventor/appengine/src/com/google/appinventor/client/wizards/FileUploadWizard.java b/appinventor/appengine/src/com/google/appinventor/client/wizards/FileUploadWizard.java index b8bdbee6f38..6abdcab7f10 100644 --- a/appinventor/appengine/src/com/google/appinventor/client/wizards/FileUploadWizard.java +++ b/appinventor/appengine/src/com/google/appinventor/client/wizards/FileUploadWizard.java @@ -278,5 +278,4 @@ protected void FocusFirst(FocusEvent event) { upload.setFocus(true); } -} - +} \ No newline at end of file diff --git a/appinventor/appengine/src/com/google/appinventor/client/wizards/GlobalAssetUpdateDialog.java b/appinventor/appengine/src/com/google/appinventor/client/wizards/GlobalAssetUpdateDialog.java new file mode 100644 index 00000000000..b06995fdf6c --- /dev/null +++ b/appinventor/appengine/src/com/google/appinventor/client/wizards/GlobalAssetUpdateDialog.java @@ -0,0 +1,89 @@ +// -*- mode: java; c-basic-offset: 2; -*- +// Copyright 2024 MIT, All rights reserved +// Released under the Apache License, Version 2.0 +// http://www.apache.org/licenses/LICENSE-2.0 + +package com.google.appinventor.client.wizards; + +import static com.google.appinventor.client.Ode.MESSAGES; + +import com.google.appinventor.client.Ode; +import com.google.appinventor.client.OdeAsyncCallback; +import com.google.appinventor.shared.rpc.project.GlobalAsset; +import com.google.appinventor.shared.rpc.globalasset.GlobalAssetService; +import com.google.appinventor.shared.rpc.globalasset.GlobalAssetServiceAsync; +import com.google.gwt.core.client.GWT; +import com.google.gwt.event.dom.client.ClickEvent; +import com.google.gwt.uibinder.client.UiBinder; +import com.google.gwt.uibinder.client.UiField; +import com.google.gwt.uibinder.client.UiHandler; +import com.google.gwt.user.client.ui.Button; +import com.google.gwt.user.client.ui.DialogBox; +import com.google.gwt.user.client.ui.HTML; +import com.google.gwt.user.client.ui.VerticalPanel; + +import java.util.logging.Logger; + +public class GlobalAssetUpdateDialog extends DialogBox { + + interface GlobalAssetUpdateDialogUiBinder extends UiBinder {} + + private static final GlobalAssetUpdateDialogUiBinder uibinder = GWT.create(GlobalAssetUpdateDialogUiBinder.class); + + private static final Logger LOG = Logger.getLogger(GlobalAssetUpdateDialog.class.getName()); + + @UiField HTML message; + @UiField Button updateButton; + @UiField Button cancelButton; + + private final GlobalAssetServiceAsync globalAssetService = GWT.create(GlobalAssetService.class); + private final long projectId; + private final GlobalAsset linkedAsset; + private final GlobalAsset latestAsset; + + public GlobalAssetUpdateDialog(long projectId, GlobalAsset linkedAsset, GlobalAsset latestAsset) { + super(false, true); // Auto-hide, modal + this.projectId = projectId; + this.linkedAsset = linkedAsset; + this.latestAsset = latestAsset; + + setWidget(uibinder.createAndBindUi(this)); + setText(MESSAGES.globalAssetUpdateDialogTitle()); + setAnimationEnabled(true); + setGlassEnabled(true); + + // Display message about the update + String msg = MESSAGES.globalAssetUpdateMessage(linkedAsset.getFileName(), + new java.util.Date(linkedAsset.getTimestamp()).toString(), + new java.util.Date(latestAsset.getTimestamp()).toString()); + message.setHTML(msg); + } + + @UiHandler("updateButton") + void onUpdateClick(ClickEvent e) { + LOG.info("Updating global asset " + linkedAsset.getFileName() + " in project " + projectId); + // Call RPC to update the asset in the project + globalAssetService.linkGlobalAssetToProject(projectId, latestAsset.getFileName(), latestAsset.getTimestamp(), + new OdeAsyncCallback(MESSAGES.globalAssetUpdateError()) { + @Override + public void onSuccess(Void result) { + Ode.getInstance().getEditorManager().getOpenProjectEditor(projectId).getFileEditor(linkedAsset.getFileName()); + // Refresh asset list to reflect the updated timestamp/status + // TODO: Need a way to refresh AssetList from here + // Ode.getInstance().getAssetManager().refreshAssetList(); + hide(); + } + + @Override + public void onFailure(Throwable caught) { + super.onFailure(caught); + hide(); + } + }); + } + + @UiHandler("cancelButton") + void onCancelClick(ClickEvent e) { + hide(); + } +} diff --git a/appinventor/appengine/src/com/google/appinventor/client/wizards/GlobalAssetUpdateDialog.ui.xml b/appinventor/appengine/src/com/google/appinventor/client/wizards/GlobalAssetUpdateDialog.ui.xml new file mode 100644 index 00000000000..4d7b0fe44c5 --- /dev/null +++ b/appinventor/appengine/src/com/google/appinventor/client/wizards/GlobalAssetUpdateDialog.ui.xml @@ -0,0 +1,17 @@ + + + + + + + + + + Update + Cancel + + + + + \ No newline at end of file diff --git a/appinventor/appengine/src/com/google/appinventor/images/syncIcon.png b/appinventor/appengine/src/com/google/appinventor/images/syncIcon.png new file mode 100644 index 00000000000..c3170c5f841 Binary files /dev/null and b/appinventor/appengine/src/com/google/appinventor/images/syncIcon.png differ diff --git a/appinventor/appengine/src/com/google/appinventor/server/DownloadServlet.java b/appinventor/appengine/src/com/google/appinventor/server/DownloadServlet.java index ffd382e3e3f..ee1a9a6a431 100644 --- a/appinventor/appengine/src/com/google/appinventor/server/DownloadServlet.java +++ b/appinventor/appengine/src/com/google/appinventor/server/DownloadServlet.java @@ -265,6 +265,14 @@ public void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOExc projectId, false, false, zipName, includeYail, false, false, false, false, true); downloadableFile = zipFile.getRawFile(); + + } else if (downloadKind.equals(ServerLayout.DOWNLOAD_GLOBAL_ASSET)) { + uriComponents = uri.split("/"); + String fileName = uriComponents.length > 0 ? uriComponents[uriComponents.length - 1] : null; + if (fileName == null || fileName.trim().isEmpty() || fileName.equals("globalasset")) { + throw new IllegalArgumentException("Missing global asset file name."); + } + downloadableFile = fileExporter.exportGlobalAsset(userId, fileName); } else if (downloadKind.equals(ServerLayout.DOWNLOAD_CSR)) { byte[] csr = getCSR(); if (csr == null) { diff --git a/appinventor/appengine/src/com/google/appinventor/server/FileExporter.java b/appinventor/appengine/src/com/google/appinventor/server/FileExporter.java index 3f3bf37f61e..db3ee05b2fd 100644 --- a/appinventor/appengine/src/com/google/appinventor/server/FileExporter.java +++ b/appinventor/appengine/src/com/google/appinventor/server/FileExporter.java @@ -109,4 +109,15 @@ ProjectSourceZip exportProjectSourceZip(String userId, long projectId, * (file is not known) */ RawFile exportUserFile(String userId, String filePath) throws IOException; + + /** + * Exports a specific global asset file. + * + * @param userId the user ID who owns the global asset + * @param fileName the full path of the global asset file + * @return RawFile with the name and content + * @throws IllegalArgumentException if download request cannot be fulfilled + * (file is not known) + */ + RawFile exportGlobalAsset(String userId, String fileName) throws IOException; } diff --git a/appinventor/appengine/src/com/google/appinventor/server/FileExporterImpl.java b/appinventor/appengine/src/com/google/appinventor/server/FileExporterImpl.java index 6843f1f6df8..54f587eda55 100644 --- a/appinventor/appengine/src/com/google/appinventor/server/FileExporterImpl.java +++ b/appinventor/appengine/src/com/google/appinventor/server/FileExporterImpl.java @@ -11,6 +11,9 @@ import com.google.appinventor.shared.rpc.project.ProjectSourceZip; import com.google.appinventor.shared.rpc.project.RawFile; import com.google.appinventor.shared.storage.StorageUtil; +import com.google.appinventor.server.storage.StoredData; +import org.json.JSONArray; +import org.json.JSONObject; import java.io.ByteArrayOutputStream; import java.io.FileNotFoundException; @@ -77,9 +80,24 @@ public ProjectSourceZip exportProjectSourceZip(String userId, long projectId, boolean forAppStore, boolean locallyCachedApp) throws IOException { // Download project source files as a zip. - return storageIo.exportProjectSourceZip(userId, projectId, + ProjectSourceZip projectSourceZip = storageIo.exportProjectSourceZip(userId, projectId, includeProjectHistory, includeAndroidKeystore, zipName, includeYail, includeScreenShots, forGallery, fatalError, forAppStore, locallyCachedApp); + + // Add global asset references to metadata + List linkedGlobalAssets = storageIo.getProjectGlobalAssets(userId, projectId); + if (!linkedGlobalAssets.isEmpty()) { + JSONArray jsonArray = new JSONArray(); + for (StoredData.ProjectGlobalAsset pga : linkedGlobalAssets) { + JSONObject jsonObject = new JSONObject(); + jsonObject.put("fileName", pga.globalAssetFileName); + jsonObject.put("timestamp", pga.timestamp); + jsonArray.put(jsonObject); + } + projectSourceZip.setMetadata(jsonArray.toString()); + } + + return projectSourceZip; } @Override @@ -246,6 +264,24 @@ public RawFile exportUserFile(String userId, String filePath) throws IOException } } + @Override + public RawFile exportGlobalAsset(String userId, String fileName) throws IOException { + // Download a specific global asset file. + if (fileName == null || fileName.trim().isEmpty()) { + throw new IllegalArgumentException("Global asset file name cannot be null or empty."); + } + if (userId == null || userId.trim().isEmpty()) { + throw new IllegalArgumentException("User ID cannot be null or empty."); + } + try { + byte[] content = storageIo.downloadRawGlobalAsset(userId, fileName); + return new RawFile(StorageUtil.basename(fileName), content); + } catch (RuntimeException e) { + throw new RuntimeException("Error downloading global asset file: " + fileName + + " user=" + userId, e); + } + } + /* * Filters a list of file names, removing those that don't start with the given prefix. */ diff --git a/appinventor/appengine/src/com/google/appinventor/server/FileImporter.java b/appinventor/appengine/src/com/google/appinventor/server/FileImporter.java index 6ccca5f1e74..ea7b7476328 100644 --- a/appinventor/appengine/src/com/google/appinventor/server/FileImporter.java +++ b/appinventor/appengine/src/com/google/appinventor/server/FileImporter.java @@ -80,6 +80,17 @@ long importFile(String userId, long projectId, String fileName, InputStream uplo void importUserFile(String userId, String fileName, InputStream uploadedFileStream) throws IOException; + /** + * Adds the global asset on the server and imports its content. + * + * @param userId the userId + * @param fileName global asset file name (e.g., "_global_/folder/asset.png") + * @param uploadedFileStream uploaded file + * @throws IOException if any file operation fails + */ + void importGlobalAsset(String userId, String fileName, InputStream uploadedFileStream) + throws IOException; + /** * Returns the names of all the projects belonging to the user. * @@ -95,4 +106,5 @@ void importUserFile(String userId, String fileName, InputStream uploadedFileStre */ String importTempFile(InputStream inStream) throws IOException; + } diff --git a/appinventor/appengine/src/com/google/appinventor/server/FileImporterImpl.java b/appinventor/appengine/src/com/google/appinventor/server/FileImporterImpl.java index b28e5011bfa..c1edb8f6a21 100644 --- a/appinventor/appengine/src/com/google/appinventor/server/FileImporterImpl.java +++ b/appinventor/appengine/src/com/google/appinventor/server/FileImporterImpl.java @@ -15,6 +15,8 @@ import com.google.appinventor.server.properties.json.ServerJsonParser; import com.google.appinventor.server.storage.StorageIo; import com.google.appinventor.server.storage.StorageIoInstanceHolder; +import com.google.appinventor.server.storage.StoredData; +import com.google.appinventor.server.storage.ObjectifyException; import com.google.appinventor.shared.rpc.UploadResponse; import com.google.appinventor.shared.rpc.project.Project; import com.google.appinventor.shared.rpc.project.RawFile; @@ -45,6 +47,10 @@ import javax.annotation.Nullable; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + /** * Implementation of {@link FileImporter} based on {@link StorageIo} * @@ -168,6 +174,24 @@ public UserProject importProject(String userId, String projectName, project.setProjectHistory(projectHistory); } long projectId = storageIo.createProject(userId, project, projectSettings); + + // Handle global asset references from metadata + if (projectHistory != null && projectHistory.startsWith("{\"globalAssets\":")) { // Assuming JSON format + try { + JSONObject metadataJson = new JSONObject(projectHistory); + if (metadataJson.has("globalAssets")) { + JSONArray globalAssetsJson = metadataJson.getJSONArray("globalAssets"); + for (int i = 0; i < globalAssetsJson.length(); i++) { + JSONObject assetJson = globalAssetsJson.getJSONObject(i); + String globalAssetFileName = assetJson.getString("fileName"); + long timestamp = assetJson.getLong("timestamp"); + storageIo.addProjectGlobalAsset(userId, projectId, globalAssetFileName, timestamp); + } + } + } catch (JSONException e) { + LOG.log(Level.WARNING, "Failed to parse global asset metadata during import for project " + projectId, e); + } + } return storageIo.getUserProject(userId, projectId); } @@ -223,6 +247,28 @@ public void importUserFile(String userId, String fileName, InputStream uploadedF storageIo.uploadRawUserFile(userId, fileName, content); } + @Override + public void importGlobalAsset(String userId, String fileName, InputStream uploadedFileStream) + throws IOException { + byte[] content = ByteStreams.toByteArray(uploadedFileStream); + + // The fileName for global assets will be in the format "_global_/folder/asset.png" + // or "_global_/asset.png". We need to extract the folder and the actual file name. + String[] parts = fileName.split("/", 3); + String folder = ""; + String assetName; + if (parts.length == 3) { + folder = parts[1]; + assetName = parts[2]; + } else if (parts.length == 2) { + assetName = parts[1]; + } else { + throw new IllegalArgumentException("Invalid global asset file name: " + fileName); + } + + storageIo.uploadGlobalAsset(userId, folder, assetName, content); + } + @Override public String importTempFile(InputStream inStream) throws IOException { return storageIo.uploadTempFile(ByteStreams.toByteArray(inStream)); diff --git a/appinventor/appengine/src/com/google/appinventor/server/GlobalAssetServiceImpl.java b/appinventor/appengine/src/com/google/appinventor/server/GlobalAssetServiceImpl.java new file mode 100644 index 00000000000..d7139990f52 --- /dev/null +++ b/appinventor/appengine/src/com/google/appinventor/server/GlobalAssetServiceImpl.java @@ -0,0 +1,547 @@ +// -*- mode: java; c-basic-offset: 2; -*- +// Copyright 2009-2011 Google, All Rights reserved +// Copyright 2011-2012 MIT, All rights reserved +// Released under the Apache License, Version 2.0 +// http://www.apache.org/licenses/LICENSE-2.0 + +package com.google.appinventor.server; + +import com.google.gwt.user.client.rpc.RemoteServiceRelativePath; +import com.google.appinventor.server.storage.StorageIo; +import com.google.appinventor.server.storage.StorageIoInstanceHolder; +import com.google.appinventor.server.storage.StoredData; +import com.google.appinventor.shared.rpc.globalasset.GlobalAssetService; +import com.google.appinventor.shared.rpc.globalasset.AssetConflictInfo; +import com.google.appinventor.shared.rpc.project.GlobalAsset; + +import java.util.ArrayList; +import java.util.List; +import java.util.logging.Logger; +import org.json.JSONObject; + +import com.google.gwt.user.server.rpc.RemoteServiceServlet; +import com.google.appinventor.server.util.JsonpUtil; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +/** + * The servlet for global asset management. + * + */ +@RemoteServiceRelativePath("globalassets") +public class GlobalAssetServiceImpl extends OdeRemoteServiceServlet implements GlobalAssetService { + + private static final Logger LOG = Logger.getLogger(GlobalAssetServiceImpl.class.getName()); + + private final StorageIo storageIo = StorageIoInstanceHolder.getInstance(); + + @Override + public List getGlobalAssets() { + String userId = userInfoProvider.getUserId(); + List storedAssets = storageIo.getGlobalAssets(userId); + List globalAssets = new ArrayList(); + for (StoredData.GlobalAssetData storedAsset : storedAssets) { + globalAssets.add(new GlobalAsset(userId, storedAsset.fileName, storedAsset.folder, storedAsset.timestamp, new ArrayList())); + } + return globalAssets; + } + + @Override + public void deleteGlobalAsset(String fileName) { + String userId = userInfoProvider.getUserId(); + StoredData.GlobalAssetData globalAssetData = storageIo.getGlobalAssetByFileName(userId, fileName); + + if (globalAssetData == null) { + LOG.warning("Attempted to delete non-existent global asset: " + fileName + " for user " + userId); + throw new RuntimeException("Global asset not found: " + fileName); + } + + // Check for usage using the new ProjectGlobalAssetData system + List usingProjects = storageIo.getProjectsUsingGlobalAsset(fileName, userId); + if (!usingProjects.isEmpty()) { + // Get project names for better error messaging + StringBuilder projectNames = new StringBuilder(); + for (int i = 0; i < Math.min(usingProjects.size(), 3); i++) { // Show up to 3 project names + try { + String projectName = storageIo.getProjectName(userId, usingProjects.get(i)); + if (projectName != null) { + if (projectNames.length() > 0) { + projectNames.append(", "); + } + projectNames.append(projectName); + } + } catch (Exception e) { + // If we can't get project name, just use the ID + if (projectNames.length() > 0) { + projectNames.append(", "); + } + projectNames.append("Project #" + usingProjects.get(i)); + } + } + + if (usingProjects.size() > 3) { + projectNames.append(" and " + (usingProjects.size() - 3) + " other project(s)"); + } + + String errorMessage = "Cannot delete asset '" + fileName + "' because it is currently used by " + + usingProjects.size() + " project(s): " + projectNames.toString() + + ". Please remove the asset from these projects first."; + + throw new RuntimeException(errorMessage); + } + + // Delete the asset - cleanup will be handled when projects remove the asset + storageIo.deleteGlobalAsset(userId, fileName); + } + + @Override + public void linkGlobalAssetToProject(long projectId, String globalAssetId, long timestamp) { + String userId = userInfoProvider.getUserId(); + StoredData.GlobalAssetData globalAssetData = storageIo.getGlobalAssetByFileName(userId, globalAssetId); + if (globalAssetData != null) { + // Construct the file name for the project, e.g., "assets/_global_/folder/asset.png" + String projectFileName = "assets/_global_/"; + if (globalAssetData.folder != null && !globalAssetData.folder.isEmpty()) { + projectFileName += globalAssetData.folder + "/"; + } + projectFileName += globalAssetData.fileName; + + try { + // Add the global asset as a source file to the project + storageIo.addSourceFilesToProject(userId, projectId, true, projectFileName); + // Upload the content of the global asset to the project file + byte[] globalAssetContent = storageIo.downloadRawGlobalAsset(userId, globalAssetId); + storageIo.uploadRawFileForce(projectId, projectFileName, userId, globalAssetContent); + LOG.info("Successfully linked global asset " + globalAssetId + " to project " + projectId); + } catch (Exception e) { + LOG.severe("Error linking global asset " + globalAssetId + " to project " + projectId + ": " + e.getMessage()); + throw new RuntimeException("Failed to link global asset: " + e.getMessage()); + } + } else { + LOG.warning("Attempted to link non-existent global asset: " + globalAssetId + " for user " + userId); + throw new RuntimeException("Global asset not found: " + globalAssetId); + } + } + + @Override + public boolean isGlobalAssetUpdated(String globalAssetId, long currentTimestamp) { + String userId = userInfoProvider.getUserId(); + StoredData.GlobalAssetData globalAssetData = storageIo.getGlobalAssetByFileName(userId, globalAssetId); + if (globalAssetData != null) { + return globalAssetData.timestamp > currentTimestamp; + } + // If the asset is not found, consider it not updated (or handle as an error based on UX needs) + return false; + } + + @Override + public GlobalAsset getGlobalAsset(String fileName) { + String userId = userInfoProvider.getUserId(); + StoredData.GlobalAssetData storedAsset = storageIo.getGlobalAssetByFileName(userId, fileName); + if (storedAsset != null) { + return new GlobalAsset(userId, storedAsset.fileName, storedAsset.folder, storedAsset.timestamp, new ArrayList()); + } + return null; + } + + @Override + public void uploadGlobalAsset(String name, String type, byte[] content, List tags, String folder) { + String userId = userInfoProvider.getUserId(); + storageIo.uploadGlobalAsset(userId, folder, name, content); + // TODO(user): Add tags to the asset. + } + + @Override + public void updateGlobalAsset(String id, String name, List tags, String folder) { + String userId = userInfoProvider.getUserId(); + LOG.info("Updating global asset with ID: " + id + " for user: " + userId); + LOG.info("New name: " + name); + LOG.info("New tags: " + tags); + LOG.info("New folder: " + folder); + + StoredData.GlobalAssetData globalAssetData = storageIo.getGlobalAsset(userId, id); + if (globalAssetData == null) { + LOG.warning("Attempted to update non-existent global asset: " + id + " for user: " + userId); + throw new RuntimeException("Global asset not found: " + id); + } + + // Update metadata fields + globalAssetData.fileName = name; // Assuming 'name' is the new fileName + globalAssetData.folder = folder; + // Tags are not directly stored in GlobalAssetData, but can be added if needed. + // globalAssetData.tags = tags; // Uncomment if tags are added to StoredData.GlobalAssetData + + // Save updated entity + byte[] globalAssetContent = storageIo.downloadRawGlobalAsset(userId, id); + storageIo.uploadGlobalAsset(userId, folder, name, globalAssetContent); // Re-upload with new metadata + } + + @Override + public void importAssetIntoProject(String assetId, String projectIdStr, boolean trackUsage) { + String userId = userInfoProvider.getUserId(); + long projectId = Long.parseLong(projectIdStr); + StoredData.GlobalAssetData globalAssetData = storageIo.getGlobalAssetByFileName(userId, assetId); + + if (globalAssetData == null) { + LOG.warning("Attempted to import non-existent global asset: " + assetId + " for user " + userId); + throw new RuntimeException("Global asset not found: " + assetId); + } + + // If tracking, add project ID to referencedBy list + if (trackUsage) { + List referencedBy = globalAssetData.referencedBy; + if (referencedBy == null) { + referencedBy = new ArrayList<>(); + } + if (!referencedBy.contains(projectId)) { + referencedBy.add(projectId); + storageIo.updateGlobalAssetReferencedBy(userId, assetId, referencedBy); + } + } + + // Construct the file name for the project, e.g., "assets/_global_/folder/asset.png" + String projectFileName = "assets/_global_/"; + if (globalAssetData.folder != null && !globalAssetData.folder.isEmpty()) { + projectFileName += globalAssetData.folder + "/"; + } + projectFileName += globalAssetData.fileName; + + try { + // Add the global asset as a source file to the project + storageIo.addSourceFilesToProject(userId, projectId, true, projectFileName); + + // Upload the content of the global asset to the project file + byte[] globalAssetContent = storageIo.downloadRawGlobalAsset(userId, assetId); + if (globalAssetContent == null || globalAssetContent.length == 0) { + LOG.severe("Failed to download global asset content for: " + assetId + " user: " + userId); + throw new RuntimeException("Failed to download global asset content: " + assetId); + } + + LOG.info("Importing global asset " + assetId + " to project " + projectId + " as " + projectFileName + + " (size: " + globalAssetContent.length + " bytes)"); + storageIo.uploadRawFileForce(projectId, projectFileName, userId, globalAssetContent); + + // Create the relationship record for tracking (if tracking is enabled) + if (trackUsage) { + storageIo.addProjectGlobalAssetRelation(projectId, assetId, userId, trackUsage, projectFileName); + } + + LOG.info("Successfully imported global asset " + assetId + " into project " + projectId + ", tracked: " + trackUsage); + } catch (Exception e) { + LOG.severe("Error importing global asset " + assetId + " to project " + projectId + ": " + e.getMessage()); + throw new RuntimeException("Failed to import global asset: " + e.getMessage()); + } + } + + @Override + public void updateGlobalAssetFolder(String assetId, String folder) { + String userId = userInfoProvider.getUserId(); + storageIo.updateGlobalAssetFolder(userId, assetId, folder); + } + + @Override + public boolean syncGlobalAsset(String assetId, String projectIdStr) { + String userId = userInfoProvider.getUserId(); + long projectId = Long.parseLong(projectIdStr); + + StoredData.GlobalAssetData globalAssetData = storageIo.getGlobalAssetByFileName(userId, assetId); + if (globalAssetData == null) { + LOG.warning("Attempted to sync non-existent global asset: " + assetId + " for user " + userId); + return false; + } + + String projectSettings = storageIo.loadProjectSettings(userId, projectId); + JSONObject projectSettingsJson; + long syncedAtTimestamp = 0; + String localCopyBlobKey = null; + + try { + if (projectSettings != null && !projectSettings.isEmpty()) { + projectSettingsJson = new JSONObject(projectSettings); + String assetMetadataKey = "globalAsset_" + assetId.replace('.', '_'); + if (projectSettingsJson.has(assetMetadataKey)) { + JSONObject globalAssetMetadata = projectSettingsJson.getJSONObject(assetMetadataKey); + syncedAtTimestamp = globalAssetMetadata.optLong("syncedAtTimestamp", 0); + localCopyBlobKey = globalAssetMetadata.optString("localCopyBlobKey", null); + } + } else { + projectSettingsJson = new JSONObject(); + } + + if (globalAssetData.timestamp > syncedAtTimestamp) { + // Global asset is newer, replace local copy + String projectFileName = "assets/_global_/"; + if (globalAssetData.folder != null && !globalAssetData.folder.isEmpty()) { + projectFileName += globalAssetData.folder + "/"; + } + projectFileName += globalAssetData.fileName; + + byte[] globalAssetContent = storageIo.downloadRawGlobalAsset(userId, assetId); + storageIo.uploadRawFileForce(projectId, projectFileName, userId, globalAssetContent); + + // Update syncedAtTimestamp in project metadata + JSONObject globalAssetMetadata = new JSONObject(); + globalAssetMetadata.put("globalAssetKey", assetId); + globalAssetMetadata.put("syncedAtTimestamp", globalAssetData.timestamp); + if (localCopyBlobKey != null) { + globalAssetMetadata.put("localCopyBlobKey", localCopyBlobKey); + } + + projectSettingsJson.put("globalAsset_" + assetId.replace('.', '_'), globalAssetMetadata); + storageIo.storeProjectSettings(userInfoProvider.getSessionId(), projectId, projectSettingsJson.toString()); + + LOG.info("Successfully synced global asset " + assetId + " in project " + projectId); + return true; + } else { + LOG.info("Global asset " + assetId + " in project " + projectId + " is already up-to-date."); + return false; + } + } catch (Exception e) { + LOG.severe("Error syncing global asset " + assetId + " in project " + projectId + ": " + e.getMessage()); + throw new RuntimeException("Failed to sync global asset: " + e.getMessage()); + } + } + + // New efficient relationship-based methods + @Override + public void addAssetToProject(String assetFileName, long projectId, boolean trackUsage) { + String userId = userInfoProvider.getUserId(); + + // Get the global asset + StoredData.GlobalAssetData globalAssetData = storageIo.getGlobalAssetByFileName(userId, assetFileName); + if (globalAssetData == null) { + LOG.warning("Attempted to add non-existent global asset: " + assetFileName + " for user " + userId); + throw new RuntimeException("Global asset not found: " + assetFileName); + } + + // Check if relationship already exists + if (storageIo.getProjectGlobalAssetRelation(projectId, assetFileName, userId) != null) { + LOG.info("Global asset " + assetFileName + " already exists in project " + projectId); + return; + } + + // Construct the project file path + String projectFileName = "assets/_global_/"; + if (globalAssetData.folder != null && !globalAssetData.folder.isEmpty()) { + projectFileName += globalAssetData.folder + "/"; + } + projectFileName += globalAssetData.fileName; + + try { + // Add the source file to the project + storageIo.addSourceFilesToProject(userId, projectId, true, projectFileName); + + // Upload the content to the project + byte[] globalAssetContent = storageIo.downloadRawGlobalAsset(userId, assetFileName); + storageIo.uploadRawFileForce(projectId, projectFileName, userId, globalAssetContent); + + // Create the relationship record + storageIo.addProjectGlobalAssetRelation(projectId, assetFileName, userId, trackUsage, projectFileName); + + LOG.info("Successfully added global asset " + assetFileName + " to project " + projectId + ", tracked: " + trackUsage); + } catch (Exception e) { + LOG.severe("Error adding global asset " + assetFileName + " to project " + projectId + ": " + e.getMessage()); + throw new RuntimeException("Failed to add global asset: " + e.getMessage()); + } + } + + @Override + public void removeAssetFromProject(String assetFileName, long projectId) { + String userId = userInfoProvider.getUserId(); + + try { + // Get the relationship to find the local path + StoredData.ProjectGlobalAssetData relation = storageIo.getProjectGlobalAssetRelation(projectId, assetFileName, userId); + if (relation == null) { + LOG.warning("Attempted to remove non-existent asset relationship: " + assetFileName + " from project " + projectId); + return; + } + + // Remove the file from the project + storageIo.deleteFile(userId, projectId, relation.localAssetPath); + + // Remove the relationship record + storageIo.removeProjectGlobalAssetRelation(projectId, assetFileName, userId); + + LOG.info("Successfully removed global asset " + assetFileName + " from project " + projectId); + } catch (Exception e) { + LOG.severe("Error removing global asset " + assetFileName + " from project " + projectId + ": " + e.getMessage()); + throw new RuntimeException("Failed to remove global asset: " + e.getMessage()); + } + } + + @Override + public List getProjectGlobalAssets(long projectId) { + String userId = userInfoProvider.getUserId(); + List relations = storageIo.getProjectGlobalAssetRelations(projectId); + List globalAssets = new ArrayList<>(); + + for (StoredData.ProjectGlobalAssetData relation : relations) { + if (relation.globalAssetUserId.equals(userId)) { + StoredData.GlobalAssetData globalAssetData = storageIo.getGlobalAssetByFileName(userId, relation.globalAssetFileName); + if (globalAssetData != null) { + globalAssets.add(new GlobalAsset(userId, globalAssetData.fileName, globalAssetData.folder, + globalAssetData.timestamp, new ArrayList())); + } + } + } + + return globalAssets; + } + + @Override + public List getProjectsUsingAsset(String assetFileName) { + String userId = userInfoProvider.getUserId(); + return storageIo.getProjectsUsingGlobalAsset(assetFileName, userId); + } + + @Override + public boolean syncProjectGlobalAsset(String assetFileName, long projectId) { + String userId = userInfoProvider.getUserId(); + + // Get the relationship + StoredData.ProjectGlobalAssetData relation = storageIo.getProjectGlobalAssetRelation(projectId, assetFileName, userId); + if (relation == null || !relation.trackUsage) { + LOG.info("Asset " + assetFileName + " in project " + projectId + " is not tracked for syncing"); + return false; + } + + // Get the current global asset + StoredData.GlobalAssetData globalAssetData = storageIo.getGlobalAssetByFileName(userId, assetFileName); + if (globalAssetData == null) { + LOG.warning("Global asset " + assetFileName + " no longer exists"); + return false; + } + + // Check if sync is needed + if (globalAssetData.timestamp <= relation.syncedTimestamp) { + LOG.info("Global asset " + assetFileName + " in project " + projectId + " is already up-to-date"); + return false; + } + + try { + // Update the project file with new content + byte[] globalAssetContent = storageIo.downloadRawGlobalAsset(userId, assetFileName); + storageIo.uploadRawFileForce(projectId, relation.localAssetPath, userId, globalAssetContent); + + // Update the sync timestamp + storageIo.updateProjectGlobalAssetSyncTimestamp(projectId, assetFileName, userId, globalAssetData.timestamp); + + LOG.info("Successfully synced global asset " + assetFileName + " in project " + projectId); + return true; + } catch (Exception e) { + LOG.severe("Error syncing global asset " + assetFileName + " in project " + projectId + ": " + e.getMessage()); + throw new RuntimeException("Failed to sync global asset: " + e.getMessage()); + } + } + + @Override + public void bulkAddAssetsToProject(List assetFileNames, long projectId, boolean trackUsage) { + String userId = userInfoProvider.getUserId(); + int successCount = 0; + int errorCount = 0; + + for (String assetFileName : assetFileNames) { + try { + addAssetToProject(assetFileName, projectId, trackUsage); + successCount++; + } catch (Exception e) { + errorCount++; + LOG.warning("Failed to add asset " + assetFileName + " to project " + projectId + ": " + e.getMessage()); + } + } + + LOG.info("Bulk add completed for project " + projectId + ": " + successCount + " successful, " + errorCount + " errors"); + + if (errorCount > 0) { + throw new RuntimeException("Bulk add completed with " + errorCount + " errors. " + successCount + " assets were added successfully."); + } + } + + @Override + public boolean assetExists(String fileName) { + String userId = userInfoProvider.getUserId(); + StoredData.GlobalAssetData globalAssetData = storageIo.getGlobalAssetByFileName(userId, fileName); + return globalAssetData != null; + } + + @Override + public AssetConflictInfo getAssetConflictInfo(String fileName) { + String userId = userInfoProvider.getUserId(); + + // Get the existing asset + StoredData.GlobalAssetData globalAssetData = storageIo.getGlobalAssetByFileName(userId, fileName); + if (globalAssetData == null) { + return null; // No conflict if asset doesn't exist + } + + // Convert to GlobalAsset DTO + GlobalAsset existingAsset = new GlobalAsset(userId, globalAssetData.fileName, + globalAssetData.folder, globalAssetData.timestamp, + new ArrayList()); + + // Get affected projects + List affectedProjects = new ArrayList<>(); + List projectIds = getProjectsUsingAsset(fileName); + + for (Long projectId : projectIds) { + try { + // Get project name using the available method + String projectName = storageIo.getProjectName(userId, projectId); + if (projectName != null) { + // Get the relationship info to check if it's tracked + StoredData.ProjectGlobalAssetData relationData = + storageIo.getProjectGlobalAssetRelation(projectId, fileName, userId); + + boolean isTracked = (relationData != null && relationData.trackUsage); + long lastSyncTime = (relationData != null) ? relationData.syncedTimestamp : 0; + + AssetConflictInfo.ProjectInfo projectInfo = new AssetConflictInfo.ProjectInfo( + projectId, projectName, isTracked, lastSyncTime); + + affectedProjects.add(projectInfo); + } + } catch (Exception e) { + LOG.warning("Error getting project info for project " + projectId + ": " + e.getMessage()); + } + } + + return new AssetConflictInfo(existingAsset, affectedProjects, + affectedProjects.size(), globalAssetData.timestamp); + } + + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, java.io.IOException { + // This is needed for JSONP + String userId = userInfoProvider.getUserId(); + String op = req.getParameter("op"); + if (op != null && op.equals("getGlobalAssets")) { + List globalAssets = getGlobalAssets(); + JsonpUtil.writeJsonResponse(resp, globalAssets); + } else if (req.getPathInfo() != null && req.getPathInfo().startsWith("/globalasset/")) { + // Handle global asset download + String fileName = req.getPathInfo().substring("/globalasset/".length()); + StoredData.GlobalAssetData storedAsset = storageIo.getGlobalAssetByFileName(userId, fileName); + if (storedAsset != null) { + // Download the actual content (handles both datastore and GCS storage) + byte[] content = storageIo.downloadRawGlobalAsset(userId, fileName); + if (content != null) { + resp.setStatus(HttpServletResponse.SC_OK); + resp.setHeader("Content-Disposition", "attachment; filename=\"" + storedAsset.fileName + "\""); + resp.setContentType(storedAsset.mimeType != null ? storedAsset.mimeType : "application/octet-stream"); + resp.getOutputStream().write(content); + } else { + resp.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + resp.getWriter().write("Error downloading global asset content: " + fileName); + } + } else { + resp.setStatus(HttpServletResponse.SC_NOT_FOUND); + resp.getWriter().write("Global asset not found: " + fileName); + } + } else { + super.doGet(req, resp); + } + } +} \ No newline at end of file diff --git a/appinventor/appengine/src/com/google/appinventor/server/UploadServlet.java b/appinventor/appengine/src/com/google/appinventor/server/UploadServlet.java index 6d59b6d72dc..0357fadf22a 100644 --- a/appinventor/appengine/src/com/google/appinventor/server/UploadServlet.java +++ b/appinventor/appengine/src/com/google/appinventor/server/UploadServlet.java @@ -16,6 +16,7 @@ import org.apache.commons.fileupload.FileItemStream; import org.apache.commons.fileupload.FileItemIterator; import org.apache.commons.fileupload.servlet.ServletFileUpload; +import org.apache.commons.fileupload.util.Streams; import java.io.IOException; import java.io.InputStream; @@ -158,6 +159,23 @@ public void doPost(HttpServletRequest req, HttpServletResponse resp) { uploadResponse = new UploadResponse(UploadResponse.Status.SUCCESS, 0, fileImporter.importTempFile(uploadedStream)); + } else if (uploadKind.equals(ServerLayout.UPLOAD_GLOBAL_ASSET)) { + uriComponents = uri.split("/", SPLIT_LIMIT_USERFILE); + if (USERFILE_PATH_INDEX >= uriComponents.length) { + throw CrashReport.createAndLogError(LOG, req, null, + new IllegalArgumentException("Missing global asset file path.")); + } + String fileName = uriComponents[USERFILE_PATH_INDEX]; + InputStream uploadedStream; + try { + uploadedStream = getRequestStream(req, ServerLayout.UPLOAD_GLOBAL_ASSET_FORM_ELEMENT); + } catch (Exception e) { + throw CrashReport.createAndLogError(LOG, req, null, e); + } + + fileImporter.importGlobalAsset(userInfoProvider.getUserId(), fileName, uploadedStream); + uploadResponse = new UploadResponse(UploadResponse.Status.SUCCESS); + } else { throw CrashReport.createAndLogError(LOG, req, null, new IllegalArgumentException("Unknown upload kind: " + uploadKind)); diff --git a/appinventor/appengine/src/com/google/appinventor/server/project/CommonProjectService.java b/appinventor/appengine/src/com/google/appinventor/server/project/CommonProjectService.java index 1a20f7d6f0b..bd4b3bd599f 100644 --- a/appinventor/appengine/src/com/google/appinventor/server/project/CommonProjectService.java +++ b/appinventor/appengine/src/com/google/appinventor/server/project/CommonProjectService.java @@ -155,6 +155,22 @@ public long addFile(String userId, long projectId, String fileId) { * @return modification date for project */ public long deleteFile(String userId, long projectId, String fileId) { + // Check if this is a global asset and clean up the relationship + if (fileId.startsWith("assets/_global_/")) { + // Extract the asset filename from the path (e.g., "assets/_global_/folder/file.png" -> "file.png") + String assetFileName = fileId.substring(fileId.lastIndexOf('/') + 1); + + // Remove the ProjectGlobalAssetData relationship if it exists + try { + storageIo.removeProjectGlobalAssetRelation(projectId, assetFileName, userId); + } catch (Exception e) { + // Log warning but don't fail the deletion if cleanup fails + java.util.logging.Logger.getLogger(CommonProjectService.class.getName()) + .warning("Failed to clean up global asset relationship for " + assetFileName + + " in project " + projectId + ": " + e.getMessage()); + } + } + final long date = storageIo.deleteFile(userId, projectId, fileId); storageIo.removeSourceFilesFromProject(userId, projectId, false, fileId); return date; @@ -172,8 +188,8 @@ public long deleteFiles(String userId, long projectId, String directory) { // TODO(user): This is not efficient. for (String fileId : storageIo.getProjectSourceFiles(userId, projectId)) { if (fileId.startsWith(directory + '/') && fileId.indexOf('/', directory.length() + 1) == -1) { - storageIo.deleteFile(userId, projectId, fileId); - storageIo.removeSourceFilesFromProject(userId, projectId, false, fileId); + // Use the deleteFile method to ensure proper cleanup for global assets + deleteFile(userId, projectId, fileId); } } return storageIo.getProjectDateModified(userId, projectId); @@ -189,8 +205,8 @@ public long deleteFolder(String userId, long projectId, String directory) { // TODO(user) : This is also not efficient for (String fileId : storageIo.getProjectSourceFiles(userId, projectId)) { if (fileId.startsWith(directory)) { - storageIo.deleteFile(userId, projectId, fileId); - storageIo.removeSourceFilesFromProject(userId, projectId, false, fileId); + // Use the deleteFile method to ensure proper cleanup for global assets + deleteFile(userId, projectId, fileId); } } return storageIo.getProjectDateCreated(userId, projectId); diff --git a/appinventor/appengine/src/com/google/appinventor/server/project/youngandroid/YoungAndroidProjectService.java b/appinventor/appengine/src/com/google/appinventor/server/project/youngandroid/YoungAndroidProjectService.java index 58a11a37cf4..df4f8a37f74 100644 --- a/appinventor/appengine/src/com/google/appinventor/server/project/youngandroid/YoungAndroidProjectService.java +++ b/appinventor/appengine/src/com/google/appinventor/server/project/youngandroid/YoungAndroidProjectService.java @@ -477,6 +477,21 @@ public long deleteFile(String userId, long projectId, String fileId) { return storageIo.getProjectDateModified(userId, projectId); } else { + // Check if this is a global asset and clean up the relationship + if (fileId.startsWith("assets/_global_/")) { + // Extract the asset filename from the path (e.g., "assets/_global_/folder/file.png" -> "file.png") + String assetFileName = fileId.substring(fileId.lastIndexOf('/') + 1); + + // Remove the ProjectGlobalAssetData relationship if it exists + try { + storageIo.removeProjectGlobalAssetRelation(projectId, assetFileName, userId); + } catch (Exception e) { + // Log warning but don't fail the deletion if cleanup fails + LOG.warning("Failed to clean up global asset relationship for " + assetFileName + + " in project " + projectId + ": " + e.getMessage()); + } + } + return super.deleteFile(userId, projectId, fileId); } } diff --git a/appinventor/appengine/src/com/google/appinventor/server/storage/ObjectifyStorageIo.java b/appinventor/appengine/src/com/google/appinventor/server/storage/ObjectifyStorageIo.java index 31dc511f84c..1f6e684be7c 100644 --- a/appinventor/appengine/src/com/google/appinventor/server/storage/ObjectifyStorageIo.java +++ b/appinventor/appengine/src/com/google/appinventor/server/storage/ObjectifyStorageIo.java @@ -65,11 +65,13 @@ import com.google.common.base.Strings; import com.google.common.collect.Lists; import com.google.common.io.ByteStreams; +import java.nio.channels.Channels; import com.googlecode.objectify.Key; import com.googlecode.objectify.Objectify; import com.googlecode.objectify.ObjectifyService; import com.googlecode.objectify.Query; +import java.util.ArrayList; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; @@ -198,6 +200,9 @@ private class Result { ObjectifyService.register(UserProjectData.class); ObjectifyService.register(FileData.class); ObjectifyService.register(UserFileData.class); + ObjectifyService.register(StoredData.GlobalAssetData.class); + ObjectifyService.register(StoredData.ProjectGlobalAssetData.class); + ObjectifyService.register(StoredData.ProjectGlobalAsset.class); ObjectifyService.register(MotdData.class); ObjectifyService.register(RendezvousData.class); ObjectifyService.register(WhiteListData.class); @@ -256,6 +261,11 @@ private class Result { initAllowedTutorialUrls(); } + @Override + public List getUserFiles(final String userId) { + return getUserFiles(userId, null); + } + @Override public User getUser(String userId) { return getUser(userId, null); @@ -1034,14 +1044,19 @@ private UserFileData createUserFile(Objectify datastore, Key userKey, } @Override - public List getUserFiles(final String userId) { + public List getUserFiles(final String userId, @Nullable final String pathPrefixFilter) { final List fileList = new ArrayList(); try { runJobWithRetries(new JobRetryHelper() { @Override public void run(Objectify datastore) { Key userKey = userKey(userId); - for (UserFileData ufd : datastore.query(UserFileData.class).ancestor(userKey)) { + Query query = datastore.query(UserFileData.class).ancestor(userKey); + if (pathPrefixFilter != null && !pathPrefixFilter.isEmpty()) { + query = query.filter("fileName >=", pathPrefixFilter) + .filter("fileName <", pathPrefixFilter + "\ufffd"); + } + for (UserFileData ufd : query) { fileList.add(ufd.fileName); } } @@ -1124,6 +1139,147 @@ private void addUserFileContents(Objectify datastore, String userId, String file datastore.put(ufd); } + @Override + public void uploadGlobalAsset(final String userId, final String folder, final String assetName, final byte[] content) { + validateGCS(); + try { + runJobWithRetries(new JobRetryHelper() { + @Override + public void run(Objectify datastore) throws ObjectifyException { + StoredData.GlobalAssetData gad = datastore.find(globalAssetKey(userKey(userId), assetName)); + boolean isUpdate = (gad != null); + + if (gad == null) { + gad = new StoredData.GlobalAssetData(); + gad.fileName = assetName; + gad.userKey = userKey(userId); + } + + // Clean up old GCS file if this was previously stored in GCS + if (isUpdate && isTrue(gad.isGCS) && gad.gcsName != null) { + try { + gcsService.delete(new GcsFilename(getGcsBucketToUse(FileData.RoleEnum.SOURCE), gad.gcsName)); + } catch (IOException e) { + LOG.log(Level.WARNING, "Unable to delete old GCS file " + gad.gcsName + " for global asset " + assetName, e); + } + } + + gad.folder = folder; + gad.timestamp = System.currentTimeMillis(); + + // Determine storage method based on size + if (useGCSforGlobalAsset(content.length)) { + // Store in GCS + gad.isGCS = true; + gad.gcsName = makeGCSfileNameForGlobalAsset(assetName, userId); + gad.content = null; // Clear content field when using GCS + + try { + GcsOutputChannel outputChannel = gcsService.createOrReplace( + new GcsFilename(getGcsBucketToUse(FileData.RoleEnum.SOURCE), gad.gcsName), + GcsFileOptions.getDefaultInstance()); + outputChannel.write(ByteBuffer.wrap(content)); + outputChannel.close(); + } catch (IOException e) { + throw new ObjectifyException(e); + } + } else { + // Store in datastore + gad.isGCS = false; + gad.gcsName = null; + gad.content = content; + } + + datastore.put(gad); + } + }, true); + } catch (ObjectifyException e) { + throw CrashReport.createAndLogError(LOG, null, collectUserErrorInfo(userId, assetName), e); + } + } + + private Key globalAssetKey(Key userKey, String fileName) { + return new Key(userKey, StoredData.GlobalAssetData.class, fileName); + } + + @Override + public byte[] downloadRawGlobalAsset(final String userId, final String fileName) { + validateGCS(); + final Result result = new Result(); + try { + runJobWithRetries(new JobRetryHelper() { + @Override + public void run(Objectify datastore) throws ObjectifyException { + StoredData.GlobalAssetData gad = datastore.find(globalAssetKey(userKey(userId), fileName)); + if (gad == null) { + result.t = null; + return; + } + + if (isTrue(gad.isGCS)) { + // Asset is stored in GCS + try { + int count; + boolean npfHappened = false; + boolean recovered = false; + for (count = 0; count < 5; count++) { + GcsFilename gcsFileName = new GcsFilename(getGcsBucketToUse(FileData.RoleEnum.SOURCE), gad.gcsName); + int bytesRead = 0; + int fileSize = 0; + ByteBuffer resultBuffer; + try { + fileSize = (int) gcsService.getMetadata(gcsFileName).getLength(); + resultBuffer = ByteBuffer.allocate(fileSize); + GcsInputChannel readChannel = gcsService.openReadChannel(gcsFileName, 0); + while (bytesRead < fileSize) { + bytesRead += readChannel.read(resultBuffer); + if (DEBUG) { + LOG.log(Level.INFO, "readChannel: bytesRead = " + bytesRead + " fileSize = " + fileSize); + } + } + readChannel.close(); + result.t = resultBuffer.array(); + if (npfHappened) { + recovered = true; + } + break; // Exit the loop + } catch (java.nio.channels.ClosedByInterruptException e) { + // We may be running on a F1 instance which got pre-empted + // Let's try a few more times. + continue; + } catch (java.nio.file.NoSuchFileException e) { + // Handle the case where object is not found in GCS + LOG.log(Level.WARNING, "downloadRawGlobalAsset: NPF recorded for " + gad.gcsName); + npfHappened = true; + resultBuffer = ByteBuffer.allocate(0); + result.t = resultBuffer.array(); + break; + } + } + + // Report on how things went above + if (npfHappened) { + if (recovered) { + LOG.log(Level.WARNING, "recovered from NPF in downloadRawGlobalAsset filename = " + gad.gcsName + " count = " + count); + } else { + LOG.log(Level.WARNING, "FATAL NPF in downloadRawGlobalAsset filename = " + gad.gcsName); + } + } + } catch (IOException e) { + throw new ObjectifyException(e); + } + } else { + // Asset is stored in datastore + result.t = gad.content; + } + } + }, true); + } catch (ObjectifyException e) { + throw CrashReport.createAndLogError(LOG, null, collectUserErrorInfo(userId, fileName), e); + } + return result.t; + } + @Override public String downloadUserFile(final String userId, final String fileName, final String encoding) { @@ -1185,6 +1341,180 @@ public void run(Objectify datastore) { } } + @Override + public List getGlobalAssets(final String userId) { + final List assetList = new ArrayList(); + try { + runJobWithRetries(new JobRetryHelper() { + @Override + public void run(Objectify datastore) { + Key userKey = userKey(userId); + Query query = datastore.query(StoredData.GlobalAssetData.class).ancestor(userKey); + for (StoredData.GlobalAssetData gad : query) { + assetList.add(gad); + } + } + }, false); + } catch (ObjectifyException e) { + throw CrashReport.createAndLogError(LOG, null, collectUserErrorInfo(userId), e); + } + return assetList; + } + + @Override + public void deleteGlobalAsset(final String userId, final String fileName) { + validateGCS(); + final Result gcsName = new Result(); + try { + runJobWithRetries(new JobRetryHelper() { + @Override + public void run(Objectify datastore) { + Key gadKey = globalAssetKey(userKey(userId), fileName); + StoredData.GlobalAssetData gad = datastore.find(gadKey); + if (gad != null) { + // Store GCS name if present for cleanup + if (isTrue(gad.isGCS) && gad.gcsName != null) { + gcsName.t = gad.gcsName; + } + datastore.delete(gadKey); + } + } + }, true); + + // Clean up GCS file if it exists + if (gcsName.t != null) { + try { + gcsService.delete(new GcsFilename(getGcsBucketToUse(FileData.RoleEnum.SOURCE), gcsName.t)); + } catch (IOException e) { + LOG.log(Level.WARNING, "Unable to delete GCS file " + gcsName.t + " for global asset " + fileName, e); + } + } + } catch (ObjectifyException e) { + throw CrashReport.createAndLogError(LOG, null, collectUserErrorInfo(userId, fileName), e); + } + } + + @Override + public void updateGlobalAssetFolder(final String userId, final String assetId, final String folder) { + try { + runJobWithRetries(new JobRetryHelper() { + @Override + public void run(Objectify datastore) { + StoredData.GlobalAssetData gad = datastore.find(globalAssetKey(userKey(userId), assetId)); + if (gad != null) { + gad.folder = folder; + datastore.put(gad); + } + } + }, true); + } catch (ObjectifyException e) { + throw CrashReport.createAndLogError(LOG, null, collectUserErrorInfo(userId, assetId), e); + } + } + + @Override + public StoredData.GlobalAssetData getGlobalAsset(final String userId, final String fileName) { + final Result result = new Result(); + try { + runJobWithRetries(new JobRetryHelper() { + @Override + public void run(Objectify datastore) { + result.t = datastore.find(globalAssetKey(userKey(userId), fileName)); + } + }, false); + } catch (ObjectifyException e) { + throw CrashReport.createAndLogError(LOG, null, collectUserErrorInfo(userId, fileName), e); + } + return result.t; + } + + @Override + public StoredData.GlobalAssetData getGlobalAssetByFileName(final String userId, final String fileName) { + final Result result = new Result(); + try { + runJobWithRetries(new JobRetryHelper() { + @Override + public void run(Objectify datastore) { + Key userKey = userKey(userId); + Query query = datastore.query(StoredData.GlobalAssetData.class).ancestor(userKey); + for (StoredData.GlobalAssetData asset : query) { + if (asset.fileName.equals(fileName)) { + result.t = asset; + return; + } + } + } + }, false); + } catch (ObjectifyException e) { + throw CrashReport.createAndLogError(LOG, null, collectUserErrorInfo(userId, fileName), e); + } + return result.t; + } + + + @Override + public List getProjectGlobalAssets(final String userId, final long projectId) { + final List assetList = new ArrayList(); + try { + runJobWithRetries(new JobRetryHelper() { + @Override + public void run(Objectify datastore) { + Key projectKey = projectKey(projectId); + Query query = datastore.query(StoredData.ProjectGlobalAsset.class).ancestor(projectKey); + for (StoredData.ProjectGlobalAsset pga : query) { + assetList.add(pga); + } + } + }, false); + } catch (ObjectifyException e) { + throw CrashReport.createAndLogError(LOG, null, collectUserProjectErrorInfo(userId, projectId), e); + } + return assetList; + } + + @Override + public void addProjectGlobalAsset(final String userId, final long projectId, final String globalAssetFileName, final long timestamp) { + try { + runJobWithRetries(new JobRetryHelper() { + @Override + public void run(Objectify datastore) { + Key projectKey = projectKey(projectId); + StoredData.ProjectGlobalAsset pga = datastore.find(projectGlobalAssetKey(projectKey, globalAssetFileName)); + if (pga == null) { + pga = new StoredData.ProjectGlobalAsset(); + pga.globalAssetFileName = globalAssetFileName; + pga.projectKey = projectKey; + } + pga.timestamp = timestamp; + datastore.put(pga); + } + }, true); + } catch (ObjectifyException e) { + throw CrashReport.createAndLogError(LOG, null, collectUserProjectErrorInfo(userId, projectId), e); + } + } + + @Override + public void deleteProjectGlobalAsset(final String userId, final long projectId, final String globalAssetFileName) { + try { + runJobWithRetries(new JobRetryHelper() { + @Override + public void run(Objectify datastore) { + Key pgaKey = projectGlobalAssetKey(projectKey(projectId), globalAssetFileName); + if (datastore.find(pgaKey) != null) { + datastore.delete(pgaKey); + } + } + }, true); + } catch (ObjectifyException e) { + throw CrashReport.createAndLogError(LOG, null, collectUserProjectErrorInfo(userId, projectId), e); + } + } + + private Key projectGlobalAssetKey(Key projectKey, String globalAssetFileName) { + return new Key(projectKey, StoredData.ProjectGlobalAsset.class, globalAssetFileName); + } + @Override public int getMaxJobSizeBytes() { // TODO(user): what should this mean? @@ -1564,6 +1894,16 @@ String makeGCSfileName(String fileName, long projectId) { return (projectId + "/" + fileName); } + // Determine if a global asset should use GCS - always true to match normal asset behavior + private boolean useGCSforGlobalAsset(int contentLength) { + return true; // Always use GCS for global assets, just like normal assets + } + + // Make a GCS file name for global assets + private String makeGCSfileNameForGlobalAsset(String fileName, String userId) { + return ("global_assets/" + userId + "/" + fileName); + } + @Override public long deleteFile(final String userId, final long projectId, final String fileName) { validateGCS(); @@ -2832,6 +3172,8 @@ public void run(Objectify datastore) { return result.t; } + + /* * Determine which GCS Bucket to use based on filename. In particular * APK files go in a bucket with a short TTL, because they are really @@ -2845,4 +3187,164 @@ private static final String getGcsBucketToUse(FileData.RoleEnum role) { } } + @Override + public void updateGlobalAssetReferencedBy(final String userId, final String assetId, final List referencedBy) { + try { + runJobWithRetries(new JobRetryHelper() { + @Override + public void run(Objectify datastore) { + StoredData.GlobalAssetData gad = datastore.find(globalAssetKey(userKey(userId), assetId)); + if (gad != null) { + gad.referencedBy = referencedBy; + datastore.put(gad); + } + } + }, true); + } catch (ObjectifyException e) { + throw CrashReport.createAndLogError(LOG, null, collectUserErrorInfo(userId, assetId), e); + } + } + + // New efficient relationship-based methods for ProjectGlobalAssetData + @Override + public void addProjectGlobalAssetRelation(final long projectId, final String globalAssetFileName, + final String globalAssetUserId, final boolean trackUsage, + final String localAssetPath) { + try { + runJobWithRetries(new JobRetryHelper() { + @Override + public void run(Objectify datastore) { + Key projectKey = projectKey(projectId); + StoredData.ProjectGlobalAssetData relation = new StoredData.ProjectGlobalAssetData( + projectKey, globalAssetFileName, globalAssetUserId, trackUsage, localAssetPath); + datastore.put(relation); + } + }, true); + } catch (ObjectifyException e) { + throw CrashReport.createAndLogError(LOG, null, + "projectId=" + projectId + ", assetFileName=" + globalAssetFileName, e); + } + } + + @Override + public void removeProjectGlobalAssetRelation(final long projectId, final String globalAssetFileName, + final String globalAssetUserId) { + try { + runJobWithRetries(new JobRetryHelper() { + @Override + public void run(Objectify datastore) { + Key projectKey = projectKey(projectId); + Query query = datastore.query(StoredData.ProjectGlobalAssetData.class) + .ancestor(projectKey) + .filter("globalAssetFileName", globalAssetFileName) + .filter("globalAssetUserId", globalAssetUserId); + + StoredData.ProjectGlobalAssetData relation = query.get(); + if (relation != null) { + datastore.delete(relation); + } + } + }, true); + } catch (ObjectifyException e) { + throw CrashReport.createAndLogError(LOG, null, + "projectId=" + projectId + ", assetFileName=" + globalAssetFileName, e); + } + } + + @Override + public StoredData.ProjectGlobalAssetData getProjectGlobalAssetRelation(final long projectId, + final String globalAssetFileName, + final String globalAssetUserId) { + final StoredData.ProjectGlobalAssetData[] result = new StoredData.ProjectGlobalAssetData[1]; + try { + runJobWithRetries(new JobRetryHelper() { + @Override + public void run(Objectify datastore) { + Key projectKey = projectKey(projectId); + Query query = datastore.query(StoredData.ProjectGlobalAssetData.class) + .ancestor(projectKey) + .filter("globalAssetFileName", globalAssetFileName) + .filter("globalAssetUserId", globalAssetUserId); + + result[0] = query.get(); + } + }, false); + return result[0]; + } catch (ObjectifyException e) { + throw CrashReport.createAndLogError(LOG, null, + "projectId=" + projectId + ", assetFileName=" + globalAssetFileName, e); + } + } + + @Override + public List getProjectGlobalAssetRelations(final long projectId) { + final List assetList = new ArrayList(); + try { + runJobWithRetries(new JobRetryHelper() { + @Override + public void run(Objectify datastore) { + Key projectKey = projectKey(projectId); + Query query = datastore.query(StoredData.ProjectGlobalAssetData.class) + .ancestor(projectKey); + + for (StoredData.ProjectGlobalAssetData relation : query) { + assetList.add(relation); + } + } + }, false); + return assetList; + } catch (ObjectifyException e) { + throw CrashReport.createAndLogError(LOG, null, "projectId=" + projectId, e); + } + } + + @Override + public List getProjectsUsingGlobalAsset(final String globalAssetFileName, final String globalAssetUserId) { + final List projectIds = new ArrayList(); + try { + runJobWithRetries(new JobRetryHelper() { + @Override + public void run(Objectify datastore) { + Query query = datastore.query(StoredData.ProjectGlobalAssetData.class) + .filter("globalAssetFileName", globalAssetFileName) + .filter("globalAssetUserId", globalAssetUserId); + + for (StoredData.ProjectGlobalAssetData relation : query) { + projectIds.add(relation.projectKey.getId()); + } + } + }, false); + return projectIds; + } catch (ObjectifyException e) { + throw CrashReport.createAndLogError(LOG, null, + "assetFileName=" + globalAssetFileName + ", userId=" + globalAssetUserId, e); + } + } + + @Override + public void updateProjectGlobalAssetSyncTimestamp(final long projectId, final String globalAssetFileName, + final String globalAssetUserId, final long syncTimestamp) { + try { + runJobWithRetries(new JobRetryHelper() { + @Override + public void run(Objectify datastore) { + Key projectKey = projectKey(projectId); + Query query = datastore.query(StoredData.ProjectGlobalAssetData.class) + .ancestor(projectKey) + .filter("globalAssetFileName", globalAssetFileName) + .filter("globalAssetUserId", globalAssetUserId); + + StoredData.ProjectGlobalAssetData relation = query.get(); + if (relation != null) { + relation.syncedTimestamp = syncTimestamp; + datastore.put(relation); + } + } + }, true); + } catch (ObjectifyException e) { + throw CrashReport.createAndLogError(LOG, null, + "projectId=" + projectId + ", assetFileName=" + globalAssetFileName, e); + } + } + } diff --git a/appinventor/appengine/src/com/google/appinventor/server/storage/StorageIo.java b/appinventor/appengine/src/com/google/appinventor/server/storage/StorageIo.java index 08536b77336..5013f8b1c23 100644 --- a/appinventor/appengine/src/com/google/appinventor/server/storage/StorageIo.java +++ b/appinventor/appengine/src/com/google/appinventor/server/storage/StorageIo.java @@ -6,6 +6,7 @@ package com.google.appinventor.server.storage; +import com.google.appinventor.server.storage.StoredData; import com.google.appinventor.shared.rpc.BlocksTruncatedException; import com.google.appinventor.shared.rpc.Nonce; import com.google.appinventor.shared.rpc.admin.AdminUser; @@ -246,6 +247,15 @@ public interface StorageIo { */ String getProjectHistory(String userId, long projectId); + /** + * Returns a list of all global assets linked to a project. + * + * @param userId a user Id + * @param projectId project ID + * @return list of ProjectGlobalAsset objects + */ + List getProjectGlobalAssets(String userId, long projectId); + // JIS XXX /** * Returns the date the project was created. @@ -274,13 +284,23 @@ public interface StorageIo { void addFilesToUser(String userId, String... fileIds); /** - * Returns a list of non-project-specific files for a user. + * Returns a list of all non-project-specific files for a user. * * @param userId a user Id - * @return list of source file ID + * @return list of all user file names (full paths) */ List getUserFiles(String userId); + /** + * Returns a list of non-project-specific files for a user. + * + * @param userId a user Id + * @param pathPrefixFilter if not null or empty, only returns files whose names + * start with this prefix. If null or empty, returns all user files. + * @return list of user file names (full paths) + */ + List getUserFiles(String userId, @Nullable String pathPrefixFilter); + /** * Uploads a non-project-specific file. * @@ -300,6 +320,16 @@ public interface StorageIo { */ void uploadRawUserFile(String userId, String fileName, byte[] content); + /** + * Uploads a global asset. + * + * @param userId user ID + * @param folder folder name for the asset + * @param assetName asset name + * @param content asset content + */ + void uploadGlobalAsset(String userId, String folder, String assetName, byte[] content); + /** * Downloads text user file data. * @@ -321,6 +351,16 @@ public interface StorageIo { */ byte[] downloadRawUserFile(String userId, String fileName); + /** + * Downloads raw global asset file data for a specific user. + * + * @param userId a user Id + * @param fileName asset file name + * + * @return file content + */ + byte[] downloadRawGlobalAsset(String userId, String fileName); + /** * Deletes a user file. * @param userId a user Id (the request is made on behalf of this user) @@ -328,7 +368,108 @@ public interface StorageIo { */ void deleteUserFile(String userId, String fileId); - // File management + /** + * Returns a list of all global assets for a user. + * + * @param userId a user Id + * @return list of all global assets + */ + List getGlobalAssets(String userId); + + /** + * Deletes a global asset. + * + * @param userId user ID + * @param fileName the file name of the global asset to delete + */ + void deleteGlobalAsset(String userId, String fileName); + + /** + * Updates the folder of a global asset. + * + * @param userId user ID + * @param assetId the file name of the global asset + * @param folder the new folder for the asset + */ + void updateGlobalAssetFolder(String userId, String assetId, String folder); + + /** + * Updates the referencedBy list of a global asset. + * + * @param userId user ID + * @param assetId the file name of the global asset + * @param referencedBy the updated list of project IDs that reference this global asset + */ + void updateGlobalAssetReferencedBy(String userId, String assetId, List referencedBy); + + /** + * Returns a global asset by its file name. + * + * @param userId user ID + * @param fileName the file name of the global asset + * @return the global asset data + */ + StoredData.GlobalAssetData getGlobalAsset(String userId, String fileName); + + /** + * Returns a global asset by its file name. + * + * @param userId user ID + * @param fileName the file name of the global asset + * @return the global asset data + */ + StoredData.GlobalAssetData getGlobalAssetByFileName(String userId, String fileName); + + /** + * Adds a global asset reference to a project. + * + * @param userId user ID + * @param projectId project ID + * @param globalAssetFileName the file name of the global asset + * @param timestamp the timestamp of the global asset at the time of linking + */ + void addProjectGlobalAsset(String userId, long projectId, String globalAssetFileName, long timestamp); + + /** + * Deletes a global asset reference from a project. + * + * @param userId user ID + * @param projectId project ID + * @param globalAssetFileName the file name of the global asset to delete + */ + void deleteProjectGlobalAsset(String userId, long projectId, String globalAssetFileName); + + // New efficient relationship-based methods for ProjectGlobalAssetData + /** + * Creates a project-global asset relationship + */ + void addProjectGlobalAssetRelation(long projectId, String globalAssetFileName, String globalAssetUserId, + boolean trackUsage, String localAssetPath); + + /** + * Removes a project-global asset relationship + */ + void removeProjectGlobalAssetRelation(long projectId, String globalAssetFileName, String globalAssetUserId); + + /** + * Gets a specific project-global asset relationship + */ + StoredData.ProjectGlobalAssetData getProjectGlobalAssetRelation(long projectId, String globalAssetFileName, String globalAssetUserId); + + /** + * Gets all project-global asset relationships for a project + */ + List getProjectGlobalAssetRelations(long projectId); + + /** + * Gets all projects that use a specific global asset + */ + List getProjectsUsingGlobalAsset(String globalAssetFileName, String globalAssetUserId); + + /** + * Updates sync timestamp for a project-global asset relationship + */ + void updateProjectGlobalAssetSyncTimestamp(long projectId, String globalAssetFileName, String globalAssetUserId, long syncTimestamp); /** * Returns the maximum allowed job size in bytes. diff --git a/appinventor/appengine/src/com/google/appinventor/server/storage/StoredData.java b/appinventor/appengine/src/com/google/appinventor/server/storage/StoredData.java index 30a17fd7fc1..4f47f9aefbc 100644 --- a/appinventor/appengine/src/com/google/appinventor/server/storage/StoredData.java +++ b/appinventor/appengine/src/com/google/appinventor/server/storage/StoredData.java @@ -15,6 +15,7 @@ import java.io.IOException; import java.io.Serializable; import java.util.Date; +import java.util.List; import javax.persistence.Id; @@ -146,6 +147,73 @@ static final class UserFileData { String settings; } + // Global assets (tied to user) + @Unindexed + @Cached + public static final class GlobalAssetData { + // The file name + @Id public String fileName; + + // The user (parent's) key + @Parent Key userKey; + + // The folder name + @Indexed public String folder; + + // Timestamp of last modification + public long timestamp; + + // File content, these are raw bytes. Note that if isGCS is true, the content field should be ignored and the data + // should be retrieved from GCS. + public byte[] content; + + // Is this file stored in the Google Cloud Store (GCS). If it is the gcsName will contain the + // GCS file name (sans bucket). + public Boolean isGCS = false; + + // The GCS filename, sans bucket name + public String gcsName; + + // MIME type of the asset + public String mimeType; + public List referencedBy; // List of project IDs that reference this global asset + } + + // Project-Global Asset relationship tracking + @Unindexed + @Cached + public static final class ProjectGlobalAssetData { + // Auto-generated ID + @Id Long id; + + // Project key (parent) + @Parent Key projectKey; + + // Global asset reference + @Indexed public String globalAssetFileName; + @Indexed public String globalAssetUserId; + + // Tracking metadata per project + @Indexed public boolean trackUsage; // Track vs Copy option + public long syncedTimestamp; // When last synced from global + public String localAssetPath; // Path in project assets (e.g., "assets/_global_/folder/file.png") + public long addedTimestamp; // When added to project + + // Default constructor for Objectify + public ProjectGlobalAssetData() {} + + public ProjectGlobalAssetData(Key projectKey, String globalAssetFileName, + String globalAssetUserId, boolean trackUsage, String localAssetPath) { + this.projectKey = projectKey; + this.globalAssetFileName = globalAssetFileName; + this.globalAssetUserId = globalAssetUserId; + this.trackUsage = trackUsage; + this.localAssetPath = localAssetPath; + this.addedTimestamp = System.currentTimeMillis(); + this.syncedTimestamp = System.currentTimeMillis(); + } + } + // Project files // Note: FileData has to be Serializable so we can put it into // memcache. @@ -202,6 +270,19 @@ enum RoleEnum { // it yet } + // Project-specific global asset references + @Unindexed + public static final class ProjectGlobalAsset { + // The global asset file name + @Id public String globalAssetFileName; + + // Key of the project (parent) to which this global asset is linked + @Parent Key projectKey; + + // Timestamp of when this global asset was linked to the project + public long timestamp; + } + // MOTD data. @Unindexed static final class MotdData { diff --git a/appinventor/appengine/src/com/google/appinventor/server/util/JsonpUtil.java b/appinventor/appengine/src/com/google/appinventor/server/util/JsonpUtil.java new file mode 100644 index 00000000000..ecb58e96b94 --- /dev/null +++ b/appinventor/appengine/src/com/google/appinventor/server/util/JsonpUtil.java @@ -0,0 +1,18 @@ +package com.google.appinventor.server.util; + +import com.google.gson.Gson; + +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.List; + +public class JsonpUtil { + + public static void writeJsonResponse(HttpServletResponse resp, List data) throws IOException { + resp.setContentType("application/json"); + resp.setCharacterEncoding("UTF-8"); + Gson gson = new Gson(); + String json = gson.toJson(data); + resp.getWriter().write(json); + } +} diff --git a/appinventor/appengine/src/com/google/appinventor/shared/rpc/ServerLayout.java b/appinventor/appengine/src/com/google/appinventor/shared/rpc/ServerLayout.java index 7e717f4ad79..ff4d33f4e92 100644 --- a/appinventor/appengine/src/com/google/appinventor/shared/rpc/ServerLayout.java +++ b/appinventor/appengine/src/com/google/appinventor/shared/rpc/ServerLayout.java @@ -130,6 +130,12 @@ public class ServerLayout { public static final String DOWNLOAD_CSR = "certificate-request"; + /** + * Relative path within {@link com.google.appinventor.server.DownloadServlet} + * for downloading a global asset + */ + public static final String DOWNLOAD_GLOBAL_ASSET = "globalasset"; + /** * Relative path of the {@link com.google.appinventor.server.UploadServlet} * within the ODE GWT module. @@ -161,6 +167,20 @@ public class ServerLayout { public static final String UPLOAD_USERFILE = "userfile"; /** +<<<<<<< HEAD +======= + * URI path element for global asset uploads. + */ + public static final String UPLOAD_GLOBAL_ASSET = "globalasset"; + + /** + * Relative path of the {@link com.google.appinventor.server.GetMotdServiceImpl} + * within the ODE GWT module. + */ + public static final String GET_MOTD_SERVICE = "getmotd"; + + /** +>>>>>>> asset-library * Relative path of the {@link com.google.appinventor.server.components.FirebaseAuthServiceImpl} * within the ODE GWT module. */ @@ -198,6 +218,11 @@ public class ServerLayout { */ public static final String UPLOAD_USERFILE_FORM_ELEMENT = "uploadUserFile"; + /** + * Form element name used for global asset file uploads. + */ + public static final String UPLOAD_GLOBAL_ASSET_FORM_ELEMENT = "globalAssetFile"; + /** * Relative path of the * {@link com.google.appinventor.shared.rpc.cloudDB.TokenAuthService} within the diff --git a/appinventor/appengine/src/com/google/appinventor/shared/rpc/globalasset/AssetConflictInfo.java b/appinventor/appengine/src/com/google/appinventor/shared/rpc/globalasset/AssetConflictInfo.java new file mode 100644 index 00000000000..b8b85dda421 --- /dev/null +++ b/appinventor/appengine/src/com/google/appinventor/shared/rpc/globalasset/AssetConflictInfo.java @@ -0,0 +1,120 @@ +package com.google.appinventor.shared.rpc.globalasset; + +import com.google.appinventor.shared.rpc.project.GlobalAsset; +import com.google.gwt.user.client.rpc.IsSerializable; +import java.util.List; + +/** + * Data transfer object containing information about asset conflicts + * and their potential impact on existing projects. + */ +public class AssetConflictInfo implements IsSerializable { + + private GlobalAsset existingAsset; + private List affectedProjects; + private int totalProjectCount; + private long lastModifiedTime; + + // Required for GWT serialization + public AssetConflictInfo() {} + + public AssetConflictInfo(GlobalAsset existingAsset, List affectedProjects, + int totalProjectCount, long lastModifiedTime) { + this.existingAsset = existingAsset; + this.affectedProjects = affectedProjects; + this.totalProjectCount = totalProjectCount; + this.lastModifiedTime = lastModifiedTime; + } + + public GlobalAsset getExistingAsset() { + return existingAsset; + } + + public void setExistingAsset(GlobalAsset existingAsset) { + this.existingAsset = existingAsset; + } + + public List getAffectedProjects() { + return affectedProjects; + } + + public void setAffectedProjects(List affectedProjects) { + this.affectedProjects = affectedProjects; + } + + public int getTotalProjectCount() { + return totalProjectCount; + } + + public void setTotalProjectCount(int totalProjectCount) { + this.totalProjectCount = totalProjectCount; + } + + public long getLastModifiedTime() { + return lastModifiedTime; + } + + public void setLastModifiedTime(long lastModifiedTime) { + this.lastModifiedTime = lastModifiedTime; + } + + public boolean hasAffectedProjects() { + return affectedProjects != null && !affectedProjects.isEmpty(); + } + + /** + * Information about a project that uses the conflicting asset. + */ + public static class ProjectInfo implements IsSerializable { + private long projectId; + private String projectName; + private boolean isTracked; + private long lastSyncTime; + + // Required for GWT serialization + public ProjectInfo() {} + + public ProjectInfo(long projectId, String projectName, boolean isTracked, long lastSyncTime) { + this.projectId = projectId; + this.projectName = projectName; + this.isTracked = isTracked; + this.lastSyncTime = lastSyncTime; + } + + public long getProjectId() { + return projectId; + } + + public void setProjectId(long projectId) { + this.projectId = projectId; + } + + public String getProjectName() { + return projectName; + } + + public void setProjectName(String projectName) { + this.projectName = projectName; + } + + public boolean isTracked() { + return isTracked; + } + + public void setTracked(boolean tracked) { + isTracked = tracked; + } + + public long getLastSyncTime() { + return lastSyncTime; + } + + public void setLastSyncTime(long lastSyncTime) { + this.lastSyncTime = lastSyncTime; + } + + public boolean willReceiveUpdate() { + return isTracked; + } + } +} \ No newline at end of file diff --git a/appinventor/appengine/src/com/google/appinventor/shared/rpc/globalasset/GlobalAssetService.java b/appinventor/appengine/src/com/google/appinventor/shared/rpc/globalasset/GlobalAssetService.java new file mode 100644 index 00000000000..ba4286f50cd --- /dev/null +++ b/appinventor/appengine/src/com/google/appinventor/shared/rpc/globalasset/GlobalAssetService.java @@ -0,0 +1,32 @@ +package com.google.appinventor.shared.rpc.globalasset; + +import com.google.appinventor.shared.rpc.project.GlobalAsset; +import com.google.gwt.user.client.rpc.RemoteService; +import com.google.gwt.user.client.rpc.RemoteServiceRelativePath; +import java.util.List; + +@RemoteServiceRelativePath("globalassets") +public interface GlobalAssetService extends RemoteService { + List getGlobalAssets(); + void deleteGlobalAsset(String fileName) throws RuntimeException; + void linkGlobalAssetToProject(long projectId, String globalAssetId, long timestamp); + boolean isGlobalAssetUpdated(String globalAssetId, long currentTimestamp); + GlobalAsset getGlobalAsset(String fileName); + void uploadGlobalAsset(String name, String type, byte[] content, List tags, String folder); + void updateGlobalAsset(String id, String name, List tags, String folder); + void updateGlobalAssetFolder(String assetId, String folder); + void importAssetIntoProject(String assetId, String projectId, boolean trackUsage); + boolean syncGlobalAsset(String assetId, String projectId); + + // New efficient relationship-based methods + void addAssetToProject(String assetFileName, long projectId, boolean trackUsage); + void removeAssetFromProject(String assetFileName, long projectId); + List getProjectGlobalAssets(long projectId); + List getProjectsUsingAsset(String assetFileName); + boolean syncProjectGlobalAsset(String assetFileName, long projectId); + void bulkAddAssetsToProject(List assetFileNames, long projectId, boolean trackUsage); + + // Asset conflict detection and impact analysis + boolean assetExists(String fileName); + AssetConflictInfo getAssetConflictInfo(String fileName); +} diff --git a/appinventor/appengine/src/com/google/appinventor/shared/rpc/globalasset/GlobalAssetServiceAsync.java b/appinventor/appengine/src/com/google/appinventor/shared/rpc/globalasset/GlobalAssetServiceAsync.java new file mode 100644 index 00000000000..5f244c87225 --- /dev/null +++ b/appinventor/appengine/src/com/google/appinventor/shared/rpc/globalasset/GlobalAssetServiceAsync.java @@ -0,0 +1,30 @@ +package com.google.appinventor.shared.rpc.globalasset; + +import com.google.appinventor.shared.rpc.project.GlobalAsset; +import com.google.gwt.user.client.rpc.AsyncCallback; +import java.util.List; + +public interface GlobalAssetServiceAsync { + void getGlobalAssets(AsyncCallback> callback); + void deleteGlobalAsset(String fileName, AsyncCallback callback); + void linkGlobalAssetToProject(long projectId, String globalAssetId, long timestamp, AsyncCallback callback); + void isGlobalAssetUpdated(String globalAssetId, long currentTimestamp, AsyncCallback callback); + void getGlobalAsset(String fileName, AsyncCallback callback); + void uploadGlobalAsset(String name, String type, byte[] content, List tags, String folder, AsyncCallback callback); + void updateGlobalAsset(String id, String name, List tags, String folder, AsyncCallback callback); + void updateGlobalAssetFolder(String assetId, String folder, AsyncCallback callback); + void importAssetIntoProject(String assetId, String projectId, boolean trackUsage, AsyncCallback callback); + void syncGlobalAsset(String assetId, String projectId, AsyncCallback callback); + + // New efficient relationship-based methods + void addAssetToProject(String assetFileName, long projectId, boolean trackUsage, AsyncCallback callback); + void removeAssetFromProject(String assetFileName, long projectId, AsyncCallback callback); + void getProjectGlobalAssets(long projectId, AsyncCallback> callback); + void getProjectsUsingAsset(String assetFileName, AsyncCallback> callback); + void syncProjectGlobalAsset(String assetFileName, long projectId, AsyncCallback callback); + void bulkAddAssetsToProject(List assetFileNames, long projectId, boolean trackUsage, AsyncCallback callback); + + // Asset conflict detection and impact analysis + void assetExists(String fileName, AsyncCallback callback); + void getAssetConflictInfo(String fileName, AsyncCallback callback); +} diff --git a/appinventor/appengine/src/com/google/appinventor/shared/rpc/project/GlobalAsset.java b/appinventor/appengine/src/com/google/appinventor/shared/rpc/project/GlobalAsset.java new file mode 100644 index 00000000000..15e8ce08941 --- /dev/null +++ b/appinventor/appengine/src/com/google/appinventor/shared/rpc/project/GlobalAsset.java @@ -0,0 +1,55 @@ +// -*- mode: java; c-basic-offset: 2; -*- +// Copyright 2009-2011 Google, All Rights reserved +// Copyright 2011-2012 MIT, All rights reserved +// Released under the Apache License, Version 2.0 +// http://www.apache.org/licenses/LICENSE-2.0 + +package com.google.appinventor.shared.rpc.project; + +import java.util.List; + +import com.google.gwt.user.client.rpc.IsSerializable; + +/** + * Data for a global asset. + * + */ +public class GlobalAsset implements IsSerializable { + private String userId; + private String fileName; + private String folder; + private long timestamp; + private List tags; + + // For GWT RPC + @SuppressWarnings("unused") + private GlobalAsset() {} + + public GlobalAsset(String userId, String fileName, String folder, long timestamp, List tags) { + this.userId = userId; + this.fileName = fileName; + this.folder = folder; + this.timestamp = timestamp; + this.tags = tags; + } + + public String getUserId() { + return userId; + } + + public String getFileName() { + return fileName; + } + + public String getFolder() { + return folder; + } + + public long getTimestamp() { + return timestamp; + } + + public List getTags() { + return tags; + } +} diff --git a/appinventor/appengine/src/com/google/appinventor/shared/rpc/project/GlobalAssetProjectNode.java b/appinventor/appengine/src/com/google/appinventor/shared/rpc/project/GlobalAssetProjectNode.java new file mode 100644 index 00000000000..32d518ba538 --- /dev/null +++ b/appinventor/appengine/src/com/google/appinventor/shared/rpc/project/GlobalAssetProjectNode.java @@ -0,0 +1,20 @@ + +package com.google.appinventor.shared.rpc.project; + +public class GlobalAssetProjectNode extends ProjectNode { + private static final long serialVersionUID = -1213141414L; + + public GlobalAssetProjectNode(String name, String fileId) { + super(name, fileId); + } + + @Override + public long getProjectId() { + return 0; + } + + @Override + public ProjectRootNode getProjectRoot() { + return null; + } +} diff --git a/appinventor/appengine/src/com/google/appinventor/shared/storage/StorageUtil.java b/appinventor/appengine/src/com/google/appinventor/shared/storage/StorageUtil.java index fcd09468a35..d097ed08f0d 100644 --- a/appinventor/appengine/src/com/google/appinventor/shared/storage/StorageUtil.java +++ b/appinventor/appengine/src/com/google/appinventor/shared/storage/StorageUtil.java @@ -37,6 +37,9 @@ private StorageUtil() {} * @return path, with any leading directory elements removed */ public static String basename(String path) { + if (path == null) { + return ""; + } if (path.length() == 0) { return path; } diff --git a/appinventor/appengine/tests/com/google/appinventor/server/storage/ObjectifyStorageIoTest.java b/appinventor/appengine/tests/com/google/appinventor/server/storage/ObjectifyStorageIoTest.java index 292fc169630..af0d740ae44 100644 --- a/appinventor/appengine/tests/com/google/appinventor/server/storage/ObjectifyStorageIoTest.java +++ b/appinventor/appengine/tests/com/google/appinventor/server/storage/ObjectifyStorageIoTest.java @@ -7,9 +7,16 @@ package com.google.appinventor.server.storage; import com.google.appengine.api.blobstore.BlobKey; -import com.google.appinventor.server.LocalDatastoreTestCase; -import com.google.appinventor.server.storage.StoredData.ProjectData; +import com.google.appengine.tools.development.testing.LocalDatastoreServiceTestConfig; +import com.google.appengine.tools.development.testing.LocalGcsServiceTestConfig; +import com.google.appengine.tools.development.testing.LocalMemcacheServiceTestConfig; +import com.google.appengine.tools.development.testing.LocalServiceTestHelper; +import com.google.appinventor.server.storage.StoredData.*; import com.google.appinventor.shared.rpc.BlocksTruncatedException; +import com.googlecode.objectify.Key; +import com.googlecode.objectify.ObjectifyService; +import static com.googlecode.objectify.ObjectifyService.ofy; +import com.google.appinventor.shared.rpc.component.Component; import com.google.appinventor.shared.rpc.project.Project; import com.google.appinventor.shared.rpc.project.RawFile; import com.google.appinventor.shared.rpc.project.TextFile; @@ -22,19 +29,37 @@ import com.google.common.base.Charsets; import java.io.BufferedReader; +import java.io.FileNotFoundException; +import java.io.ByteArrayInputStream; import java.io.IOException; +import java.io.InputStream; import java.io.InputStreamReader; import java.io.UnsupportedEncodingException; - +import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.UUID; +import org.json.JSONObject; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import static org.junit.Assert.*; /** * Tests for {@link ObjectifyStorageIo}. * * @author sharon@google.com (Sharon Perl) */ -public class ObjectifyStorageIoTest extends LocalDatastoreTestCase { +public class ObjectifyStorageIoTest { + + // Not extending LocalDatastoreTestCase anymore to manage LocalServiceTestHelper locally for GCS + // private final LocalServiceTestHelper helper = new LocalServiceTestHelper( + // new LocalDatastoreServiceTestConfig(), + // new LocalMemcacheServiceTestConfig() // Add other services if needed by ObjectifyStorageIo + // ); + // Switching to direct helper management for GCS config + private LocalServiceTestHelper helper; + private static final String SETTINGS = "{settings: \"none\"}"; private static final String FAKE_PROJECT_TYPE = "FakeProjectType"; @@ -73,13 +98,43 @@ public class ObjectifyStorageIoTest extends LocalDatastoreTestCase { private static final String YAIL_FILE_NAME2 = "src/File2.yail"; private ObjectifyStorageIo storage; - private Project project; + private Project project; // Keep if other tests use it, or remove if only new tests are added + + @Before + public void setUp() throws Exception { + // Initialize the helper with Datastore and GCS configurations + helper = new LocalServiceTestHelper( + new LocalDatastoreServiceTestConfig().setDefaultHighRepJobPolicyUnappliedJobPercentage(0.01f), // Example policy + new LocalGcsServiceTestConfig(), + new LocalMemcacheServiceTestConfig() + ); + helper.setUp(); + ObjectifyService.init(); // Initialize Objectify + // Register entities. This might be done globally in a TestRunner or AppEngineRule, + // but if not, register them here. Copied from ObjectifyStorageIo static block. + ObjectifyService.register(UserData.class); + ObjectifyService.register(ProjectData.class); + ObjectifyService.register(UserProjectData.class); + ObjectifyService.register(FileData.class); + ObjectifyService.register(UserFileData.class); + ObjectifyService.register(MotdData.class); + ObjectifyService.register(RendezvousData.class); + ObjectifyService.register(WhiteListData.class); + ObjectifyService.register(FeedbackData.class); + ObjectifyService.register(NonceData.class); + ObjectifyService.register(CorruptionRecord.class); + ObjectifyService.register(PWData.class); + ObjectifyService.register(SplashData.class); + ObjectifyService.register(Backpack.class); + ObjectifyService.register(AllowedTutorialUrls.class); + ObjectifyService.register(AllowedIosExtensions.class); + ObjectifyService.register(UserGlobalAssetData.class); // Changed + ObjectifyService.register(LibraryFileData.class); // Keep deprecated one for compatibility + - @Override - protected void setUp() throws Exception { - super.setUp(); storage = new ObjectifyStorageIo(); + // Keep project initialization if other tests depend on it project = new Project(PROJECT_NAME); project.setProjectType(FAKE_PROJECT_TYPE); project.addTextFile(new TextFile(FILE_NAME1, FILE_CONTENT1)); @@ -88,6 +143,13 @@ protected void setUp() throws Exception { project.addRawFile(new RawFile(RAW_FILE_NAME2, RAW_FILE_CONTENT2)); } + @After + public void tearDown() throws Exception { + ObjectifyService.reset(); // Clears registered entities if using ObjectifyService.init() per test + helper.tearDown(); + } + + private void createUserFiles(String userId, String userEmail, ObjectifyStorageIo storage) throws UnsupportedEncodingException { // remove files in case they were already created @@ -339,20 +401,20 @@ public void testAddRemoveUserFile() { storage.addFilesToUser(USER_ID, FILE_NAME_OUTPUT); storage.uploadRawUserFile(USER_ID, FILE_NAME_OUTPUT, FILE_CONTENT_OUTPUT); - assertTrue(storage.getUserFiles(USER_ID).contains(FILE_NAME1)); - assertTrue(storage.getUserFiles(USER_ID).contains(FILE_NAME_OUTPUT)); + assertTrue(storage.getUserFiles(USER_ID, null).contains(FILE_NAME1)); + assertTrue(storage.getUserFiles(USER_ID, null).contains(FILE_NAME_OUTPUT)); assertEquals(FILE_CONTENT1, storage.downloadUserFile(USER_ID, FILE_NAME1, StorageUtil.DEFAULT_CHARSET)); assertEquals(new String(FILE_CONTENT_OUTPUT), new String(storage.downloadRawUserFile(USER_ID, FILE_NAME_OUTPUT))); storage.deleteUserFile(USER_ID, FILE_NAME1); - assertFalse(storage.getUserFiles(USER_ID).contains(FILE_NAME1)); - assertTrue(storage.getUserFiles(USER_ID).contains(FILE_NAME_OUTPUT)); + assertFalse(storage.getUserFiles(USER_ID, null).contains(FILE_NAME1)); + assertTrue(storage.getUserFiles(USER_ID, null).contains(FILE_NAME_OUTPUT)); storage.deleteUserFile(USER_ID, FILE_NAME_OUTPUT); - assertFalse(storage.getUserFiles(USER_ID).contains(FILE_NAME1)); - assertFalse(storage.getUserFiles(USER_ID).contains(FILE_NAME_OUTPUT)); + assertFalse(storage.getUserFiles(USER_ID, null).contains(FILE_NAME1)); + assertFalse(storage.getUserFiles(USER_ID, null).contains(FILE_NAME_OUTPUT)); } public void testUnsupportedEncoding() throws BlocksTruncatedException { @@ -618,4 +680,85 @@ private long createProject(String userId, String name, String type, String fileN project.addTextFile(new TextFile(fileName, "")); return storageIo.createProject(userId, project, SETTINGS); } + + @Test + public void testUploadGlobalAsset_success() throws IOException { + final String userId = "testUser123"; + final String assetName = "myCoolAsset.png"; + final String assetType = "image/png"; // Example MIME type + final String assetFolder = "myAssets"; + final byte[] contentBytes = "This is a test asset file content.".getBytes(Charsets.UTF_8); + InputStream contentStream = new ByteArrayInputStream(contentBytes); + + // Ensure user exists for parent key + ofy().save().entity(new UserData(){{ id = userId; email = "test@example.com"; } }).now(); + + UserGlobalAssetData globalAssetData = storage.uploadGlobalAsset(userId, assetName, assetType, assetFolder, contentStream); + + assertNotNull("Returned UserGlobalAssetData should not be null", globalAssetData); + assertNotNull("ID should be generated for UserGlobalAssetData", globalAssetData.id); + assertEquals("User ID in userKey does not match", userId, globalAssetData.userKey.getName()); + assertEquals("Asset name does not match", assetName, globalAssetData.name); + assertEquals("Asset type does not match", assetType, globalAssetData.type); + assertEquals("Asset folder does not match", assetFolder, globalAssetData.folder); + assertTrue("Upload date should be positive", globalAssetData.uploadDate > 0); + assertNotNull("GCS path should not be null", globalAssetData.gcsPath); + assertTrue("GCS path should contain user ID and new path structure", globalAssetData.gcsPath.startsWith(userId + "/globalassets/")); + assertTrue("GCS path should contain asset name", globalAssetData.gcsPath.endsWith("_" + assetName)); + + // Verify from Datastore + Key userKey = Key.create(UserData.class, userId); + UserGlobalAssetData retrieved = ofy().load().key(Key.create(userKey, UserGlobalAssetData.class, globalAssetData.id)).now(); + + assertNotNull("Retrieved UserGlobalAssetData should not be null from Datastore", retrieved); + assertEquals("Retrieved name does not match", assetName, retrieved.name); + assertEquals("Retrieved type does not match", assetType, retrieved.type); + assertEquals("Retrieved folder does not match", assetFolder, retrieved.folder); + assertEquals("Retrieved uploadDate does not match", globalAssetData.uploadDate, retrieved.uploadDate); + assertEquals("Retrieved gcsPath does not match", globalAssetData.gcsPath, retrieved.gcsPath); + assertEquals("Retrieved userKey does not match", userKey, retrieved.userKey); + + // Optionally, verify GCS content if LocalGcsServiceTestConfig allows easy checks (may be complex) + // For now, confirming gcsPath pattern and datastore persistence is the primary goal. + } + + @Test + public void testUploadGlobalAsset_nullFolder() throws IOException { + final String userId = "testUser456"; + final String assetName = "anotherAsset.dat"; + final String assetType = "application/octet-stream"; + final String assetFolder = null; // Test with null folder + final byte[] contentBytes = "Another asset content.".getBytes(Charsets.UTF_8); + InputStream contentStream = new ByteArrayInputStream(contentBytes); + + // Ensure user exists for parent key + ofy().save().entity(new UserData(){{ id = userId; email = "test456@example.com"; } }).now(); + + UserGlobalAssetData globalAssetData = storage.uploadGlobalAsset(userId, assetName, assetType, assetFolder, contentStream); + + assertNotNull("Returned UserGlobalAssetData should not be null", globalAssetData); + assertNotNull("ID should be generated for UserGlobalAssetData", globalAssetData.id); + assertEquals("User ID in userKey does not match", userId, globalAssetData.userKey.getName()); + assertEquals("Asset name does not match", assetName, globalAssetData.name); + assertEquals("Asset type does not match", assetType, globalAssetData.type); + assertNull("Asset folder should be null", globalAssetData.folder); // Key assertion for this test + assertTrue("Upload date should be positive", globalAssetData.uploadDate > 0); + assertNotNull("GCS path should not be null", globalAssetData.gcsPath); + assertTrue("GCS path should contain user ID and new path structure", globalAssetData.gcsPath.startsWith(userId + "/globalassets/")); + assertTrue("GCS path should contain asset name", globalAssetData.gcsPath.endsWith("_" + assetName)); + + + // Verify from Datastore + Key userKey = Key.create(UserData.class, userId); + UserGlobalAssetData retrieved = ofy().load().key(Key.create(userKey, UserGlobalAssetData.class, globalAssetData.id)).now(); + + assertNotNull("Retrieved UserGlobalAssetData should not be null from Datastore", retrieved); + assertEquals("Retrieved name does not match", assetName, retrieved.name); + assertEquals("Retrieved type does not match", assetType, retrieved.type); + assertNull("Retrieved folder should be null", retrieved.folder); + assertEquals("Retrieved uploadDate does not match", globalAssetData.uploadDate, retrieved.uploadDate); + assertEquals("Retrieved gcsPath does not match", globalAssetData.gcsPath, retrieved.gcsPath); + assertEquals("Retrieved userKey does not match", userKey, retrieved.userKey); + } + } diff --git a/appinventor/appengine/war/WEB-INF/appengine-web.xml b/appinventor/appengine/war/WEB-INF/appengine-web.xml index e2714c5438b..a652599c006 100644 --- a/appinventor/appengine/war/WEB-INF/appengine-web.xml +++ b/appinventor/appengine/war/WEB-INF/appengine-web.xml @@ -6,6 +6,7 @@ + diff --git a/appinventor/appengine/war/WEB-INF/test.html b/appinventor/appengine/war/WEB-INF/test.html new file mode 100644 index 00000000000..4e6b2ff4104 --- /dev/null +++ b/appinventor/appengine/war/WEB-INF/test.html @@ -0,0 +1,93 @@ + + + + + Global Asset Upload Tester + + + +

Global Asset Upload Tester

+ +

+ ⚠️ Important: Edit the form action URL in this file before each upload.
+ The upload will go to the path: +

+ http://localhost:8888/ode/upload/globalasset/_global_/your_folder/your_file.ext +

+ + +
+ +
+
+ +
+ + +
+ +

+ This sends a POST request with the file field named uploadUserFile as required by UploadServlet. +

+ +

+ Currently uploading to path:
+ (loading...) +

+ + + + diff --git a/appinventor/appengine/war/WEB-INF/web.xml b/appinventor/appengine/war/WEB-INF/web.xml index 8a5f6942b92..5db1e94b1aa 100644 --- a/appinventor/appengine/war/WEB-INF/web.xml +++ b/appinventor/appengine/war/WEB-INF/web.xml @@ -245,4 +245,30 @@ componentService + + GlobalAssetService + com.google.appinventor.server.GlobalAssetServiceImpl + + + GlobalAssetService + /ode/globalassets + + + odeAuthFilter + GlobalAssetService + + + + GlobalAssetDownloadServlet + com.google.appinventor.server.GlobalAssetServiceImpl + + + GlobalAssetDownloadServlet + /download/globalasset/* + + + odeAuthFilter + GlobalAssetDownloadServlet + + diff --git a/appinventor/appengine/war/test.html b/appinventor/appengine/war/test.html new file mode 100644 index 00000000000..b37d56cd61c --- /dev/null +++ b/appinventor/appengine/war/test.html @@ -0,0 +1,121 @@ + + + + Global Asset Upload + + + +

Upload Global Asset

+
+
+ + +
+ +
+ + +
+
+ + +
+ +
+ + + + diff --git a/appinventor/components/src/com/google/appinventor/components/runtime/util/AppInvHTTPD.java b/appinventor/components/src/com/google/appinventor/components/runtime/util/AppInvHTTPD.java index 4df2a730e98..f935d6251df 100644 --- a/appinventor/components/src/com/google/appinventor/components/runtime/util/AppInvHTTPD.java +++ b/appinventor/components/src/com/google/appinventor/components/runtime/util/AppInvHTTPD.java @@ -317,7 +317,21 @@ public void run() { } } - return serveFile( uri, header, rootDir, true ); + // Since rootDir already ends with "/assets/", we need to strip "assets/" from the beginning of URI + String adjustedUri = uri; + Log.d(LOG_TAG, "=== ASSET DEBUG === Original URI: " + uri); + Log.d(LOG_TAG, "Root directory: " + rootDir.getAbsolutePath()); + + if (adjustedUri.startsWith("/assets/")) { + adjustedUri = adjustedUri.substring(8); // Remove "/assets/" (8 characters) + Log.d(LOG_TAG, "Stripped /assets/, adjusted URI: " + adjustedUri); + } else if (adjustedUri.startsWith("assets/")) { + adjustedUri = adjustedUri.substring(7); // Remove "assets/" (7 characters) + Log.d(LOG_TAG, "Stripped assets/, adjusted URI: " + adjustedUri); + } + + Log.d(LOG_TAG, "Final adjusted URI: " + adjustedUri + " ==="); + return serveFile( adjustedUri, header, rootDir, true ); } private boolean copyFile(File infile, File outfile) { diff --git a/appinventor/components/src/com/google/appinventor/components/runtime/util/AssetFetcher.java b/appinventor/components/src/com/google/appinventor/components/runtime/util/AssetFetcher.java index 6609f34c4b5..4aecdc683a3 100644 --- a/appinventor/components/src/com/google/appinventor/components/runtime/util/AssetFetcher.java +++ b/appinventor/components/src/com/google/appinventor/components/runtime/util/AssetFetcher.java @@ -283,6 +283,8 @@ public void run() { * @return The destination file for the asset */ private static File getDestinationFile(Form form, String asset) { + Log.d(LOG_TAG, "*** ASSET FETCHER DEBUG *** asset: " + asset); + if (asset.contains("/external_comps/") && Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { File dest = new File(form.getCacheDir(), asset.substring("assets/".length())); @@ -300,9 +302,19 @@ private static File getDestinationFile(Form form, String asset) { String[] parts = asset.split("/"); filename = parts[parts.length - 1]; } + Log.d(LOG_TAG, "External comps path: " + new File(parent, filename).getAbsolutePath()); return new File(parent, filename); } - return new File(QUtil.getReplAssetPath(form, true), asset.substring("assets/".length())); + + String adjustedAsset = asset.substring("assets/".length()); + String replAssetPath = QUtil.getReplAssetPath(form, true); + File result = new File(replAssetPath, adjustedAsset); + + Log.d(LOG_TAG, "Adjusted asset (after removing assets/): " + adjustedAsset); + Log.d(LOG_TAG, "ReplAssetPath: " + replAssetPath); + Log.d(LOG_TAG, "Final destination file: " + result.getAbsolutePath() + " ***"); + + return result; } private static String byteArray2Hex(final byte[] hash) { diff --git a/appinventor/components/src/com/google/appinventor/components/runtime/util/MediaUtil.java b/appinventor/components/src/com/google/appinventor/components/runtime/util/MediaUtil.java index 343fcf1fee3..1385ec46c76 100644 --- a/appinventor/components/src/com/google/appinventor/components/runtime/util/MediaUtil.java +++ b/appinventor/components/src/com/google/appinventor/components/runtime/util/MediaUtil.java @@ -108,16 +108,23 @@ static String fileUrlToFilePath(String mediaPath) throws IOException { */ @SuppressLint("SdCardPath") private static MediaSource determineMediaSource(Form form, String mediaPath) { + Log.d(LOG_TAG, "*** MEDIA SOURCE DEBUG *** mediaPath: " + mediaPath); + Log.d(LOG_TAG, "Form type: " + form.getClass().getSimpleName()); + if (mediaPath.startsWith(QUtil.getExternalStoragePath(form)) || mediaPath.startsWith("/sdcard/")) { + Log.d(LOG_TAG, "Determined source: SDCARD"); return MediaSource.SDCARD; } else if (mediaPath.startsWith("content://contacts/")) { + Log.d(LOG_TAG, "Determined source: CONTACT_URI"); return MediaSource.CONTACT_URI; } else if (mediaPath.startsWith("content://")) { + Log.d(LOG_TAG, "Determined source: CONTENT_URI"); return MediaSource.CONTENT_URI; } else if (mediaPath.startsWith("/data/")) { + Log.d(LOG_TAG, "Determined source: PRIVATE_DATA"); return MediaSource.PRIVATE_DATA; } @@ -126,11 +133,14 @@ private static MediaSource determineMediaSource(Form form, String mediaPath) { // It's a well formed URL. if (mediaPath.startsWith("file:")) { if (url.getPath().startsWith("/android_asset/")) { + Log.d(LOG_TAG, "Determined source: ASSET"); return MediaSource.ASSET; } + Log.d(LOG_TAG, "Determined source: FILE_URL"); return MediaSource.FILE_URL; } + Log.d(LOG_TAG, "Determined source: URL"); return MediaSource.URL; } catch (MalformedURLException e) { @@ -138,12 +148,16 @@ private static MediaSource determineMediaSource(Form form, String mediaPath) { } if (form instanceof ReplForm) { - if (((ReplForm)form).isAssetsLoaded()) + if (((ReplForm)form).isAssetsLoaded()) { + Log.d(LOG_TAG, "Determined source: REPL_ASSET (assets loaded) ***"); return MediaSource.REPL_ASSET; - else + } else { + Log.d(LOG_TAG, "Determined source: ASSET (assets not loaded) ***"); return MediaSource.ASSET; + } } + Log.d(LOG_TAG, "Determined source: ASSET (default) ***"); return MediaSource.ASSET; } @@ -295,7 +309,13 @@ private static InputStream openMedia(Form form, String mediaPath, MediaSource me form.assertPermission(READ_EXTERNAL_STORAGE); } try { - return new FileInputStream(new java.io.File(URI.create(form.getAssetPath(mediaPath)))); + // Strip "assets/" prefix if present, since getAssetPath() will add the appropriate path + String assetName = mediaPath; + if (mediaPath.startsWith("assets/")) { + assetName = mediaPath.substring("assets/".length()); + Log.d(LOG_TAG, "Stripped assets/ prefix from " + mediaPath + " -> " + assetName); + } + return new FileInputStream(new java.io.File(URI.create(form.getAssetPath(assetName)))); } catch (Exception e) { // URI.create can throw IllegalArgumentException under certain cirumstances // on certain platforms. This crashes the Companion, which makes our crash @@ -728,7 +748,12 @@ public static int loadSoundPool(SoundPool soundPool, Form form, String mediaPath if (RUtil.needsFilePermission(form, mediaPath, null)) { form.assertPermission(READ_EXTERNAL_STORAGE); } - return soundPool.load(fileUrlToFilePath(form.getAssetPath(mediaPath)), 1); + // Strip "assets/" prefix if present, since getAssetPath() will add the appropriate path + String assetName = mediaPath; + if (mediaPath.startsWith("assets/")) { + assetName = mediaPath.substring("assets/".length()); + } + return soundPool.load(fileUrlToFilePath(form.getAssetPath(assetName)), 1); case SDCARD: if (RUtil.needsFilePermission(form, mediaPath, null)) { @@ -788,7 +813,12 @@ public static void loadMediaPlayer(MediaPlayer mediaPlayer, Form form, String me if (RUtil.needsFilePermission(form, mediaPath, null)) { form.assertPermission(READ_EXTERNAL_STORAGE); } - mediaPlayer.setDataSource(fileUrlToFilePath(form.getAssetPath(mediaPath))); + // Strip "assets/" prefix if present, since getAssetPath() will add the appropriate path + String assetName = mediaPath; + if (mediaPath.startsWith("assets/")) { + assetName = mediaPath.substring("assets/".length()); + } + mediaPlayer.setDataSource(fileUrlToFilePath(form.getAssetPath(assetName))); return; case SDCARD: @@ -852,7 +882,12 @@ public static void loadVideoView(VideoView videoView, Form form, String mediaPat if (RUtil.needsFilePermission(form, mediaPath, null)) { form.assertPermission(READ_EXTERNAL_STORAGE); } - videoView.setVideoPath(fileUrlToFilePath(form.getAssetPath(mediaPath))); + // Strip "assets/" prefix if present, since getAssetPath() will add the appropriate path + String assetName = mediaPath; + if (mediaPath.startsWith("assets/")) { + assetName = mediaPath.substring("assets/".length()); + } + videoView.setVideoPath(fileUrlToFilePath(form.getAssetPath(assetName))); return; case SDCARD: