diff --git a/assets/page/actions.js b/assets/page/actions.js index 82a5849..bf3571b 100644 --- a/assets/page/actions.js +++ b/assets/page/actions.js @@ -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); } } }); diff --git a/main.go b/main.go index ff71b00..b1ef564 100644 --- a/main.go +++ b/main.go @@ -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") { diff --git a/moves.go b/moves.go index 4353346..5d9621f 100644 --- a/moves.go +++ b/moves.go @@ -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) { diff --git a/search.go b/search.go index e4d58d0..f74767a 100644 --- a/search.go +++ b/search.go @@ -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 {