Allow merging when moving pages
This commit is contained in:
+39
-3
@@ -13,6 +13,11 @@ function encodePickedPath(p) {
|
||||
// re-fetching, so a server-side mutation wouldn't be reflected. Instead,
|
||||
// rewrite the current entry's URL via history.replaceState, then reload — the
|
||||
// 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) {
|
||||
var init = { method: 'POST', redirect: 'manual' };
|
||||
if (body) {
|
||||
@@ -21,8 +26,7 @@ function postReplace(action, body, target) {
|
||||
}
|
||||
fetch(action, init).then(function (res) {
|
||||
if (res.type === 'opaqueredirect' || res.ok) {
|
||||
window.history.replaceState(null, '', target);
|
||||
window.location.reload();
|
||||
navigateReplace(target);
|
||||
return;
|
||||
}
|
||||
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() {
|
||||
var current = decodeURIComponent(window.location.pathname).replace(/\/+$/, '');
|
||||
if (!current) return;
|
||||
@@ -121,7 +157,7 @@ function movePage() {
|
||||
if (linksCheckbox.checked) action += '&links=1';
|
||||
var target = encodePickedPath(dest) + '/';
|
||||
closeModal();
|
||||
postReplace(action, null, target);
|
||||
submitMove(action, target);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -360,7 +360,7 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs
|
||||
return
|
||||
}
|
||||
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
|
||||
}
|
||||
if query.Has("toggle") {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -335,6 +335,38 @@ func folderIndexRenameSubtree(oldRel, newRel string) {
|
||||
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
|
||||
// even when the configured wiki root is itself a symlink (as on the NAS).
|
||||
func resolveWalkRoot(root string) string {
|
||||
|
||||
Reference in New Issue
Block a user