feature: move docs

This commit is contained in:
2026-07-27 11:52:13 -04:00
parent ae0939177c
commit 03c06c0dd9
20 changed files with 607 additions and 35 deletions

View File

@@ -54,6 +54,7 @@ type documentData struct {
type documentEditData struct {
Browser browserData
ID string
Title string
Path string
Tags []string
@@ -209,6 +210,24 @@ func (s *Server) handleDocument(w http.ResponseWriter, r *http.Request) {
s.renderDocumentPage(w, r, pagePath)
}
func (s *Server) handleDocumentByID(w http.ResponseWriter, r *http.Request) {
record, err := s.repository.GetDocumentByID(r.Context(), chi.URLParam(r, "id"))
if err != nil {
s.renderError(w, r, http.StatusNotFound, "document not found")
return
}
canRead, err := s.canReadDocumentRecord(r, *record)
if err != nil {
s.renderError(w, r, http.StatusInternalServerError, err.Error())
return
}
if !canRead {
s.renderError(w, r, http.StatusNotFound, "document not found")
return
}
http.Redirect(w, r, documentPageURL(record.Path), http.StatusFound)
}
func (s *Server) handleContentAsset(w http.ResponseWriter, r *http.Request, assetPath string) {
cleanPath := filepath.Clean(filepath.FromSlash(strings.TrimPrefix(assetPath, "/")))
if cleanPath == "." || strings.HasPrefix(cleanPath, ".."+string(filepath.Separator)) || filepath.IsAbs(cleanPath) {
@@ -291,6 +310,10 @@ func (s *Server) renderDocumentEditor(w http.ResponseWriter, r *http.Request, pa
s.renderError(w, r, http.StatusNotFound, "document not found")
return
}
if !requestPathMatchesDocument(pagePath, page.Path) {
http.Redirect(w, r, documentPageURL(page.Path)+"/edit", http.StatusFound)
return
}
items, err := s.documents.ListDocuments(r.Context())
if err != nil {
@@ -311,6 +334,7 @@ func (s *Server) renderDocumentEditor(w http.ResponseWriter, r *http.Request, pa
BodyTemplate: "document_edit_content",
Data: documentEditData{
Browser: browser,
ID: record.ID,
Title: page.Title,
Path: page.Path,
Tags: page.Tags,
@@ -346,6 +370,10 @@ func (s *Server) renderDocumentPage(w http.ResponseWriter, r *http.Request, page
s.renderError(w, r, http.StatusNotFound, "document not found")
return
}
if !requestPathMatchesDocument(pagePath, page.Path) {
http.Redirect(w, r, documentPageURL(page.Path), http.StatusFound)
return
}
canWrite, err := s.canWriteDocumentRecord(r, *record)
if err != nil {
s.renderError(w, r, http.StatusInternalServerError, err.Error())
@@ -539,7 +567,7 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
s.renderError(w, r, http.StatusInternalServerError, "inspect existing documents")
return
}
existing, err := s.repository.GetDocumentByPath(r.Context(), path)
existing, err := s.repository.GetDocumentByPathOrAlias(r.Context(), path)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
s.renderError(w, r, http.StatusInternalServerError, "inspect existing document")
return
@@ -611,7 +639,7 @@ func (s *Server) availableDocumentPath(ctx context.Context, path string) (string
base := strings.TrimSuffix(path, extension)
for suffix := 2; ; suffix++ {
candidate := fmt.Sprintf("%s-%d%s", base, suffix, extension)
existing, err := s.repository.GetDocumentByPath(ctx, candidate)
existing, err := s.repository.GetDocumentByPathOrAlias(ctx, candidate)
if errors.Is(err, sql.ErrNoRows) {
return candidate, nil
}
@@ -827,6 +855,7 @@ func (s *Server) handleDocuments(w http.ResponseWriter, r *http.Request) {
results := make([]map[string]any, 0, len(records))
for _, record := range records {
results = append(results, map[string]any{
"id": record.ID,
"path": record.Path,
"title": record.Title,
"hash": record.CurrentHash,
@@ -844,6 +873,8 @@ func (s *Server) handleDocumentMutation(w http.ResponseWriter, r *http.Request)
return
}
switch {
case strings.HasSuffix(pagePath, "/move"):
s.handleDocumentMovePath(w, r, strings.TrimSuffix(pagePath, "/move"))
case strings.HasSuffix(pagePath, "/archive"):
s.handleDocumentArchivePath(w, r, strings.TrimSuffix(pagePath, "/archive"))
case strings.HasSuffix(pagePath, "/restore"):
@@ -853,6 +884,64 @@ func (s *Server) handleDocumentMutation(w http.ResponseWriter, r *http.Request)
}
}
func (s *Server) handleDocumentMovePath(w http.ResponseWriter, r *http.Request, pagePath string) {
if pagePath == "" {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "document path is required"})
return
}
var req struct {
Path string `json:"path"`
BaseHash string `json:"baseHash"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
destination, err := docs.NormalizeDocumentPath(req.Path)
if err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
record, err := s.documentRecordForRequestPath(r, pagePath)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
return
}
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
canWrite, err := s.canWriteDocumentRecord(r, *record)
if err != nil {
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
if !canWrite || !s.canWriteFolder(r, documentFolder(destination)) {
writeJSONWithStatus(w, http.StatusForbidden, map[string]string{"error": "permission denied"})
return
}
moved, err := s.documents.MoveDocument(r.Context(), record.Path, destination, req.BaseHash)
if err != nil {
var conflict *docs.DocumentMoveConflictError
switch {
case errors.As(err, &conflict):
writeJSONWithStatus(w, http.StatusConflict, map[string]string{"error": conflict.Error()})
case errors.Is(err, sql.ErrNoRows):
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
default:
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return
}
writeJSONWithStatus(w, http.StatusOK, map[string]any{
"status": "moved",
"id": moved.ID,
"path": moved.Path,
"url": documentPageURL(moved.Path),
})
}
func unescapeDocumentRoutePath(path string) (string, error) {
if path == "" {
return "", nil
@@ -1011,6 +1100,17 @@ func trimEditSuffix(path string) (string, bool) {
return strings.TrimSuffix(path, "/edit"), true
}
func requestPathMatchesDocument(requestPath, documentPath string) bool {
requestPath = strings.Trim(requestPath, "/")
if requestPath == "" {
return documentPath == "index.md" || documentPath == "getting-started.md"
}
if strings.HasSuffix(requestPath, ".md") {
return requestPath == documentPath
}
return requestPath+".md" == documentPath || requestPath+"/index.md" == documentPath
}
func isContentAssetPath(path string) bool {
extension := strings.ToLower(filepath.Ext(path))
return extension != "" && extension != ".md"