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
+39 -3
View File
@@ -13,6 +13,11 @@ function encodePickedPath(p) {
// re-fetching, so a server-side mutation wouldn't be reflected. Instead, // re-fetching, so a server-side mutation wouldn't be reflected. Instead,
// rewrite the current entry's URL via history.replaceState, then reload — the // rewrite the current entry's URL via history.replaceState, then reload — the
// reload always re-fetches and preserves the (new) URL including its fragment. // reload always re-fetches and preserves the (new) URL including its fragment.
function navigateReplace(target) {
window.history.replaceState(null, '', target);
window.location.reload();
}
function postReplace(action, body, target) { function postReplace(action, body, target) {
var init = { method: 'POST', redirect: 'manual' }; var init = { method: 'POST', redirect: 'manual' };
if (body) { if (body) {
@@ -21,8 +26,7 @@ function postReplace(action, body, target) {
} }
fetch(action, init).then(function (res) { fetch(action, init).then(function (res) {
if (res.type === 'opaqueredirect' || res.ok) { if (res.type === 'opaqueredirect' || res.ok) {
window.history.replaceState(null, '', target); navigateReplace(target);
window.location.reload();
return; return;
} }
return res.text().then(function (msg) { return res.text().then(function (msg) {
@@ -71,6 +75,38 @@ function newPage() {
}); });
} }
// submitMove POSTs a move and navigates on success. When the server reports
// the destination folder already exists but can be merged (the source carries
// a page and the destination has none), it asks the user to confirm and
// retries the same move with &merge=1.
function submitMove(action, target) {
fetch(action, { method: 'POST', redirect: 'manual' }).then(function (res) {
if (res.type === 'opaqueredirect' || res.ok) {
navigateReplace(target);
return;
}
if (res.status === 409 && res.headers.get('X-Merge-Available') === '1') {
openModal({
title: 'Merge folders?',
body: 'The destination folder already exists but has no page of its own. Merge this page and its contents into it?',
confirm: {
label: 'MERGE',
onConfirm: function () {
closeModal();
submitMove(action + '&merge=1', target);
}
}
});
return;
}
return res.text().then(function (msg) {
alert(msg || ('Request failed (' + res.status + ')'));
});
}).catch(function () {
alert('Network error');
});
}
function movePage() { function movePage() {
var current = decodeURIComponent(window.location.pathname).replace(/\/+$/, ''); var current = decodeURIComponent(window.location.pathname).replace(/\/+$/, '');
if (!current) return; if (!current) return;
@@ -121,7 +157,7 @@ function movePage() {
if (linksCheckbox.checked) action += '&links=1'; if (linksCheckbox.checked) action += '&links=1';
var target = encodePickedPath(dest) + '/'; var target = encodePickedPath(dest) + '/';
closeModal(); closeModal();
postReplace(action, null, target); submitMove(action, target);
} }
} }
}); });
+1 -1
View File
@@ -360,7 +360,7 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs
return return
} }
if _, ok := query["move"]; ok { if _, ok := query["move"]; ok {
h.handleMove(w, r, urlPath, fsPath, query.Get("move"), query.Has("links")) h.handleMove(w, r, urlPath, fsPath, query.Get("move"), query.Has("links"), query.Has("merge"))
return return
} }
if query.Has("toggle") { if query.Has("toggle") {
+106 -10
View File
@@ -14,7 +14,14 @@ import (
// updateLinks is true it also rewrites every [[...]] wiki link across the // updateLinks is true it also rewrites every [[...]] wiki link across the
// tree that targets the old path or any descendant; rewritten files are held // tree that targets the old path or any descendant; rewritten files are held
// in memory for rollback. // 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) oldPath := normalizeMovePath(srcURL)
if oldPath == "/" { if oldPath == "/" {
http.Error(w, "cannot move wiki root", http.StatusBadRequest) 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 return
} }
dstFsPath := filepath.Join(h.root, filepath.FromSlash(strings.TrimPrefix(newPath, "/"))) 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) // Decide between a plain rename and a merge into an existing destination.
return 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 // 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. // Phase 3: move the source into place. A plain move renames the whole
if err := os.Rename(srcFsPath, dstFsPath); err != nil { // folder; a merge moves the source's entries into the existing
rollbackRewrites(rewritten) // destination and drops the emptied source.
http.Error(w, "rename failed: "+err.Error(), http.StatusInternalServerError) if merging {
return 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) 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 // handleDelete removes the folder at fsPath (URL urlPath) and redirects to
// the parent. Refuses to touch the wiki root. // the parent. Refuses to touch the wiki root.
func (h *handler) handleDelete(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) { func (h *handler) handleDelete(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) {
+32
View File
@@ -335,6 +335,38 @@ func folderIndexRenameSubtree(oldRel, newRel string) {
folderIndex.entries = out folderIndex.entries = out
} }
// folderIndexMergeSubtree updates the index after a merge move: it drops the
// source root entry (that folder is gone) and rewrites every descendant's
// prefix to live under newRel. Unlike folderIndexRenameSubtree it does not add
// a newRel entry, since the destination folder already exists in the index.
func folderIndexMergeSubtree(oldRel, newRel string) {
oldRel = strings.Trim(oldRel, "/")
newRel = strings.Trim(newRel, "/")
if oldRel == "" || newRel == "" {
return
}
oldPrefix := oldRel + "/"
folderIndex.Lock()
defer folderIndex.Unlock()
old := folderIndex.entries
out := make([]folderEntry, 0, len(old))
for _, e := range old {
switch {
case e.Path == oldRel:
continue
case strings.HasPrefix(e.Path, oldPrefix):
out = append(out, folderEntry{
Path: newRel + "/" + strings.TrimPrefix(e.Path, oldPrefix),
NameLower: e.NameLower,
NameTokens: e.NameTokens,
})
default:
out = append(out, e)
}
}
folderIndex.entries = out
}
// resolveWalkRoot resolves symlinks so WalkDir descends into the real tree // resolveWalkRoot resolves symlinks so WalkDir descends into the real tree
// even when the configured wiki root is itself a symlink (as on the NAS). // even when the configured wiki root is itself a symlink (as on the NAS).
func resolveWalkRoot(root string) string { func resolveWalkRoot(root string) string {