Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,8 @@ private void initJobPackageConfiguration(Request request, ExtensionSession exten
configuration.setConflictAction(ConflictType.MERGE_FAILURE,
request.getProperty(ConflictQuestion.REQUEST_CONFLICT_DEFAULTANSWER_MERGE_FAILURE),
configuration.isInteractive() ? GlobalAction.ASK : GlobalAction.MERGED);
configuration.setForceOverwrite(
request.<Boolean>getProperty("extension.xar.packager.forceOverwrite", false));

// If user requested to be asked about conflict behavior
XarExtensionPlan xarExtensionPlan = getXARExtensionPlan();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,13 @@ public class DocumentMergeImporter
public void importDocument(String comment, XWikiDocument previousDocument, XWikiDocument currentDocument,
XWikiDocument nextDocument, PackageConfiguration configuration) throws Exception
{
if (configuration.isForceOverwrite()) {
// Reinstall: skip the 3-way merge and write the new version as-is, so that the fresh download
// is not silently discarded by a merge that sees no delta between previous and next.
saveDocument(nextDocument, comment, configuration);
return;
}

XarEntryType type = this.typeResolver.getDefault();
XWikiDocumentMerger merger = this.defaultMerger;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ public class PackageConfiguration implements Cloneable

private XarEntry xarEntry;

private boolean forceOverwrite;

public PackageConfiguration()
{
// Default behavior
Expand Down Expand Up @@ -200,4 +202,22 @@ public XarEntry getXarEntry()
{
return xarEntry;
}

/**
* @return true if the install should overwrite existing documents without 3-way merge (used for reinstall)
* @since 18.3.0RC1
*/
public boolean isForceOverwrite()
{
return this.forceOverwrite;
}

/**
* @param forceOverwrite true to skip the 3-way merge and overwrite existing documents with the new version
* @since 18.3.0RC1
*/
public void setForceOverwrite(boolean forceOverwrite)
{
this.forceOverwrite = forceOverwrite;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,11 @@
import javax.inject.Singleton;

import org.xwiki.component.annotation.Component;
import org.xwiki.extension.ExtensionId;
import org.xwiki.extension.LocalExtension;
import org.xwiki.extension.ResolveException;
import org.xwiki.extension.repository.LocalExtensionRepository;
import org.xwiki.security.authorization.Right;

/**
* Various script APIs related to installed extensions.
Expand Down Expand Up @@ -62,12 +65,43 @@ public LocalExtensionRepository getRepository()
/**
* Get a list of cached extensions from the local extension repository. This doesn't include core extensions, only
* custom extensions fetched or installed.
*
*
* @return a list of read-only handlers corresponding to the local extensions, an empty list if nothing is available
* in the local repository
*/
public Collection<LocalExtension> getLocalExtensions()
{
return safe(this.localExtensionRepository.getLocalExtensions());
}

/**
* Remove an extension from the local repository (cache). This forces a fresh download the next time the extension
* is installed, which is useful for reinstalling snapshot extensions that have been updated remotely.
*
* @param id the identifier of the extension to remove from the local cache
* @param version the version of the extension to remove from the local cache
* @return true if the extension was removed or not present, false on error
* @since 18.3.0RC1
*/
public boolean removeExtension(String id, String version)
Copy link
Copy Markdown
Member

@tmortagne tmortagne Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's IMO not a good idea to provide such a dangerous helper (if the passed extension is still installed, it will create quite a mess since the extension will still be found in the installed extension index in memory but not in the storage). If you really need that hack (but I don't think it's the right direction here), you can always use the already existing getRepository() script API.

{
setError(null);

if (!this.authorization.hasAccess(Right.PROGRAM)) {
setError(new Exception("You need Programming Rights to remove extensions from the local cache"));
return false;
}

try {
LocalExtension localExtension =
this.localExtensionRepository.getLocalExtension(new ExtensionId(id, version));
if (localExtension != null) {
this.localExtensionRepository.removeExtension(localExtension);
}
return true;
} catch (ResolveException e) {
setError(e);
return false;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3761,6 +3761,10 @@ extensions.actions.diffXAR=Compute changes
extensions.actions.diffXAR.hint=Compute the changes made to the extension pages
extensions.actions.repair=Repair
extensions.actions.repairGlobally=Repair on farm
extensions.actions.reinstall=Reinstall
extensions.actions.reinstall.hint=Reinstall this extension (forces re-download and reinstallation, useful for snapshot versions)
extensions.actions.reinstallGlobally=Reinstall on farm
extensions.actions.reinstallGlobally.hint=Reinstall this extension on the entire farm (forces re-download and reinstallation)
extensions.install.title=Installing {0}
extensions.install.error.installFailure=Failed to install extension with id {0} and version {1}:
extensions.install.error.prepareFailure=Can''t resolve extension with id {0} and version {1}:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,8 @@ ${escapetool.xml($name)}##
#getInstalledExtension($extension $extensionNamespace $installedExtension)
## If the installed extension might be invalid and need repairing
#extensionRepairButtons($installedExtension)
## This extension can be reinstalled (re-download and re-install)
#extensionReinstallButtons($installedExtension)
## This extension can be uninstalled
#extensionUninstallButtons($installedExtension)
## XAR specific buttons
Expand Down Expand Up @@ -308,6 +310,17 @@ ${escapetool.xml($name)}##
#end
#end

#macro(extensionReinstallButtons $installedExtension)
#if ($installedExtension.isInstalled($NULL))
## It's installed on root namespace, propose reinstall on root
#extensionActionGlobalButton('reinstall' true)
#end
#if (!$installedExtension.isInstalled($NULL) && $installedExtension.isInstalled($extensionNamespace))
## It's installed on provided namespace, propose reinstall on that namespace
#extensionActionButton('reinstall' true)
#end
#end

#macro(extensionUninstallButtons $installedExtension)
#if (!$installedExtension.isInstalled($NULL) && $installedExtension.isInstalled($extensionNamespace))
## It's installed on provided namespace
Expand Down Expand Up @@ -1161,6 +1174,10 @@ $namespace##
#computeInstallPlan($request.extensionId $request.extensionVersion $NULL)
#elseif ($request.extensionAction == 'upgradeGlobally' || $request.extensionAction == 'downgradeGlobally')
#computeUpgradePlan($request.extensionId $request.extensionVersion)
#elseif ($request.extensionAction == 'reinstall')
#computeReinstallPlan($request.extensionId $request.extensionVersion $extensionNamespace)
#elseif ($request.extensionAction == 'reinstallGlobally')
#computeReinstallPlan($request.extensionId $request.extensionVersion $NULL)
#elseif ($request.extensionAction == 'uninstall')
#computeUninstallPlan($request.extensionId $request.extensionVersion $extensionNamespace)
#elseif ($request.extensionAction == 'uninstallGlobally')
Expand Down Expand Up @@ -1221,6 +1238,31 @@ $namespace##
#handleExtensionJobStartFailure('extensions.install.error.prepareFailure')
#end

#macro (computeReinstallPlan $extensionId $extensionVersion $extensionNamespace)
## Remove from local cache to force a fresh download (useful for snapshot versions)
#set ($discard = $services.extension.local.removeExtension($extensionId, $extensionVersion))
Copy link
Copy Markdown
Member

@tmortagne tmortagne Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After this line the installed extension is essentially broken. So if you don't validate the proposed plan, you are left in a pretty bad state. Creating an installation plan should be a readonly thing. Also, any change like this should be part of a job IMO.

## Create install plan ignoring the fact that the extension is already installed
#set ($installPlanRequest = $extensionManager.createInstallPlanRequest($extensionId, $extensionVersion, $extensionNamespace))
#set ($discard = $installPlanRequest.setInstalledIgnored(true))
## Force overwrite: bypass the 3-way merge so the freshly-downloaded content is written as-is.
## Without this, the merge sees previous==next (both from the re-downloaded XAR) and silently keeps
## the current wiki content unchanged.
#set ($discard = $installPlanRequest.setProperty('extension.xar.packager.forceOverwrite', true))
#if ($extensionConfig.skipCheckRight)
#set ($discard = $installPlanRequest.removeProperty('checkrights'))
#end
#if ($extensionConfig.skipCurrentUser)
#set ($discard = $installPlanRequest.removeProperty('user.reference'))
#end
#if ($extensionConfig.installJAROnRoot)
#set ($discard = $installPlanRequest.rewriter.installExtensionTypeOnRootNamespace('jar'))
#set ($discard = $installPlanRequest.rewriter.installExtensionTypeOnRootNamespace('webjar'))
#set ($discard = $installPlanRequest.rewriter.installExtensionTypeOnRootNamespace('webjar-node'))
#end
#set ($discard = $extensionManager.createInstallPlan($installPlanRequest))
#handleExtensionJobStartFailure('extensions.install.error.prepareFailure')
#end

#macro (computeUpgradePlan $extensionId $extensionVersion)
## Upgrade all the namespaces were the specified extension is installed.
#set ($namespaces = [])
Expand Down