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,
|
// 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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") {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user