Allow merging when moving pages
This commit is contained in:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user