Allow merging when moving pages

This commit is contained in:
2026-06-16 10:00:41 +02:00
parent d719b53404
commit 404ce088f3
4 changed files with 178 additions and 14 deletions
+106 -10
View File
@@ -14,7 +14,14 @@ import (
// updateLinks is true it also rewrites every [[...]] wiki link across the
// tree that targets the old path or any descendant; rewritten files are held
// in memory for rollback.
func (h *handler) handleMove(w http.ResponseWriter, r *http.Request, srcURL, srcFsPath, dstURL string, updateLinks bool) {
//
// If the destination folder already exists it is normally a hard conflict.
// The one exception is a merge: when the source carries a page (index.md) and
// the destination folder has none, the source's contents can fill the empty
// container without clobbering anything. That merge only proceeds when merge
// is true; otherwise the client is told a merge is available so it can ask the
// user to confirm.
func (h *handler) handleMove(w http.ResponseWriter, r *http.Request, srcURL, srcFsPath, dstURL string, updateLinks, merge bool) {
oldPath := normalizeMovePath(srcURL)
if oldPath == "/" {
http.Error(w, "cannot move wiki root", http.StatusBadRequest)
@@ -40,9 +47,31 @@ func (h *handler) handleMove(w http.ResponseWriter, r *http.Request, srcURL, src
return
}
dstFsPath := filepath.Join(h.root, filepath.FromSlash(strings.TrimPrefix(newPath, "/")))
if _, err := os.Stat(dstFsPath); err == nil {
http.Error(w, "destination already exists", http.StatusConflict)
return
// Decide between a plain rename and a merge into an existing destination.
merging := false
if dstInfo, err := os.Stat(dstFsPath); err == nil {
if !canMergeMove(srcFsPath, dstFsPath, dstInfo) {
http.Error(w, "destination already exists", http.StatusConflict)
return
}
if !merge {
// Ask the client to confirm the merge. The header lets it tell
// this apart from an ordinary conflict.
w.Header().Set("X-Merge-Available", "1")
http.Error(w, "destination already exists — merge folders?", http.StatusConflict)
return
}
conflict, err := firstMergeConflict(srcFsPath, dstFsPath)
if err != nil {
http.Error(w, "merge check failed: "+err.Error(), http.StatusInternalServerError)
return
}
if conflict != "" {
http.Error(w, "cannot merge: "+conflict+" exists in both folders", http.StatusConflict)
return
}
merging = true
}
// Phase 1: optionally walk the tree and rewrite every index.md that
@@ -88,17 +117,84 @@ func (h *handler) handleMove(w http.ResponseWriter, r *http.Request, srcURL, src
}
}
// Phase 3: rename the source folder into place.
if err := os.Rename(srcFsPath, dstFsPath); err != nil {
rollbackRewrites(rewritten)
http.Error(w, "rename failed: "+err.Error(), http.StatusInternalServerError)
return
// Phase 3: move the source into place. A plain move renames the whole
// folder; a merge moves the source's entries into the existing
// destination and drops the emptied source.
if merging {
if err := mergeFolder(srcFsPath, dstFsPath); err != nil {
rollbackRewrites(rewritten)
http.Error(w, "merge failed: "+err.Error(), http.StatusInternalServerError)
return
}
folderIndexMergeSubtree(strings.TrimPrefix(oldPath, "/"), strings.TrimPrefix(newPath, "/"))
} else {
if err := os.Rename(srcFsPath, dstFsPath); err != nil {
rollbackRewrites(rewritten)
http.Error(w, "rename failed: "+err.Error(), http.StatusInternalServerError)
return
}
folderIndexRenameSubtree(strings.TrimPrefix(oldPath, "/"), strings.TrimPrefix(newPath, "/"))
}
folderIndexRenameSubtree(strings.TrimPrefix(oldPath, "/"), strings.TrimPrefix(newPath, "/"))
http.Redirect(w, r, wikiTargetHref(newPath), http.StatusSeeOther)
}
// canMergeMove reports whether moving onto an existing destination should
// merge rather than conflict. Merging is allowed only when the destination is
// a folder with no page of its own (no index.md) and the source has one — so
// the source's page fills the empty destination without overwriting content.
func canMergeMove(srcFsPath, dstFsPath string, dstInfo os.FileInfo) bool {
if !dstInfo.IsDir() {
return false
}
return hasIndexFile(srcFsPath) && !hasIndexFile(dstFsPath)
}
// hasIndexFile reports whether dir contains a regular index.md.
func hasIndexFile(dir string) bool {
info, err := os.Stat(filepath.Join(dir, "index.md"))
return err == nil && info.Mode().IsRegular()
}
// firstMergeConflict returns the name of the first source entry that already
// exists in the destination, or "" when the merge can proceed without
// overwriting anything. index.md cannot collide here: canMergeMove already
// established the destination has none.
func firstMergeConflict(srcFsPath, dstFsPath string) (string, error) {
entries, err := os.ReadDir(srcFsPath)
if err != nil {
return "", err
}
for _, e := range entries {
_, err := os.Lstat(filepath.Join(dstFsPath, e.Name()))
if err == nil {
return e.Name(), nil
}
if !os.IsNotExist(err) {
return "", err
}
}
return "", nil
}
// mergeFolder moves every entry from srcFsPath into dstFsPath, then removes
// the now-empty source. Callers must run firstMergeConflict beforehand so no
// rename overwrites an existing destination entry.
func mergeFolder(srcFsPath, dstFsPath string) error {
entries, err := os.ReadDir(srcFsPath)
if err != nil {
return err
}
for _, e := range entries {
from := filepath.Join(srcFsPath, e.Name())
to := filepath.Join(dstFsPath, e.Name())
if err := os.Rename(from, to); err != nil {
return fmt.Errorf("move %s: %w", e.Name(), err)
}
}
return os.Remove(srcFsPath)
}
// handleDelete removes the folder at fsPath (URL urlPath) and redirects to
// the parent. Refuses to touch the wiki root.
func (h *handler) handleDelete(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) {