Wire all web-side writes to depend on the interfaces (Server.Writes for writes, Server.Items for the write-pre-flight reads) instead of the concrete *Store, so PROJAX_BACKEND will flip them with the reader: - handleDetailWrite / handleReparent / handleNewSubmit: Update / Reparent / Create now go through s.Writes; ValidateAgainstStore now reads s.Items (was s.Store) so cycle + collision detection runs against the live backend, not stale projax.items. - dashboard_pin: SetPinned via s.Writes. - links: AddLinkDated / DeleteLink via s.Writes. linkBelongsToItem now resolves ownership through s.Items.LinksByType — a direct projax.item_links query would reject every delete under the mBrian backend. Dropped the now-dead isNoRows + errors import. - caldav: all four AddLink + the unlink DeleteLink via s.Writes. - bulk applyBulk: replaced the raw single-tx multi-row UPDATE with interface calls — make_public/private map to SetPublic; the field mutations (tags/mgmt/status/timeline-exclude) are read-modify-write via Update. Cross-row tx atomicity is dropped (mBrian's HTTP write API has no multi-node tx); acceptable at m's bulk-edit scale, one write path across both backends. Added updateInputFromItem + appendUnique/removeValue. - itemwrite: slug uniqueness is now per-user-global (Q6=a, matching mBrian's idx_nodes_slug) instead of per-parent. Strictly tighter, so still correct on the legacy backend. Test updated to assert the new rule. Build green. Web suite: only the 8 pre-existing failures remain (4 project_filter + TestTimelineKindMultiValueSurvives + 3 timeline_filter, all /timeline-301 / seeding issues on main, unrelated to slice C). No new failures from the rewiring.
139 lines
4.2 KiB
Go
139 lines
4.2 KiB
Go
package web
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/m/projax/store"
|
|
)
|
|
|
|
// handleLinksAdd processes POST /i/{path}/links/add. Accepts ref_type, ref_id,
|
|
// note, event_date (YYYY-MM-DD). Anti-forgery isn't a concern at v1 since the
|
|
// trust model is Tailscale-only + cookie auth.
|
|
func (s *Server) handleLinksAdd(w http.ResponseWriter, r *http.Request, path string) {
|
|
it, err := s.Items.GetByPath(r.Context(), path)
|
|
if err != nil {
|
|
s.fail(w, r, err)
|
|
return
|
|
}
|
|
if err := r.ParseForm(); err != nil {
|
|
s.fail(w, r, err)
|
|
return
|
|
}
|
|
refType := strings.TrimSpace(r.FormValue("ref_type"))
|
|
refID := strings.TrimSpace(r.FormValue("ref_id"))
|
|
noteVal := strings.TrimSpace(r.FormValue("note"))
|
|
dateStr := strings.TrimSpace(r.FormValue("event_date"))
|
|
banner := ""
|
|
if refType == "" || refID == "" {
|
|
banner = "ref_type and ref_id are required."
|
|
}
|
|
var date *time.Time
|
|
if banner == "" && dateStr != "" {
|
|
t, err := time.Parse("2006-01-02", dateStr)
|
|
if err != nil {
|
|
banner = "event_date must be YYYY-MM-DD."
|
|
} else {
|
|
date = &t
|
|
}
|
|
}
|
|
if banner == "" {
|
|
var notePtr *string
|
|
if noteVal != "" {
|
|
notePtr = ¬eVal
|
|
}
|
|
if _, err := s.Writes.AddLinkDated(r.Context(), it.ID, refType, refID, "", notePtr, date, nil); err != nil {
|
|
banner = fmt.Sprintf("Could not add link: %v", err)
|
|
}
|
|
}
|
|
// New dated link → bust the timeline cache so the row surfaces on next view.
|
|
s.timeline.InvalidateAll()
|
|
s.renderDocumentsSection(w, r, it, nil, banner)
|
|
}
|
|
|
|
// handleLinksRemove processes POST /i/{path}/links/remove.
|
|
func (s *Server) handleLinksRemove(w http.ResponseWriter, r *http.Request, path string) {
|
|
it, err := s.Items.GetByPath(r.Context(), path)
|
|
if err != nil {
|
|
s.fail(w, r, err)
|
|
return
|
|
}
|
|
if err := r.ParseForm(); err != nil {
|
|
s.fail(w, r, err)
|
|
return
|
|
}
|
|
linkID := strings.TrimSpace(r.FormValue("link_id"))
|
|
banner := ""
|
|
if linkID == "" {
|
|
banner = "link_id required"
|
|
} else {
|
|
// Belt-and-braces: ensure the link belongs to this item before
|
|
// deleting, so a crafted form can't snipe an unrelated row.
|
|
owns, err := s.linkBelongsToItem(r.Context(), linkID, it.ID)
|
|
if err != nil {
|
|
s.fail(w, r, err)
|
|
return
|
|
}
|
|
if !owns {
|
|
banner = "Link does not belong to this item."
|
|
} else if err := s.Writes.DeleteLink(r.Context(), linkID); err != nil {
|
|
banner = fmt.Sprintf("Could not remove link: %v", err)
|
|
}
|
|
}
|
|
// Bust the dashboard + timeline caches: a removed dated link should
|
|
// disappear from both surfaces on next render.
|
|
s.dashboard.InvalidateAll()
|
|
s.timeline.InvalidateAll()
|
|
// When the delete came from the timeline (HX-Target = timeline-section),
|
|
// re-render the timeline so the row vanishes in place instead of trying to
|
|
// swap a Documents fragment into it.
|
|
if r.Header.Get("HX-Target") == "timeline-section" {
|
|
s.handleTimeline(w, r)
|
|
return
|
|
}
|
|
s.renderDocumentsSection(w, r, it, nil, banner)
|
|
}
|
|
|
|
// renderDocumentsSection re-pulls dated links, computes PERs, and renders the
|
|
// Documents fragment for HTMX swaps. Non-HTMX requests fall back to a full
|
|
// detail-page redirect.
|
|
func (s *Server) renderDocumentsSection(w http.ResponseWriter, r *http.Request, it *store.Item, highlight *time.Time, banner string) {
|
|
if r.Header.Get("HX-Request") != "true" {
|
|
http.Redirect(w, r, "/i/"+it.PrimaryPath()+"#documents-section", http.StatusSeeOther)
|
|
return
|
|
}
|
|
docs, err := s.Items.DatedLinks(r.Context(), it.ID)
|
|
if err != nil {
|
|
s.fail(w, r, err)
|
|
return
|
|
}
|
|
documents := computePERs(it.PrimaryPath(), docs)
|
|
s.render(w, r, "documents_section", map[string]any{
|
|
"Item": it,
|
|
"Documents": documents,
|
|
"HighlightDate": highlight,
|
|
"DocBanner": banner,
|
|
})
|
|
}
|
|
|
|
// linkBelongsToItem returns true when the given link id is one of the
|
|
// item's own links. Used as an anti-forgery check before delete. Reads
|
|
// through the adapter (s.Items) so it resolves against whichever backend
|
|
// is live — a direct projax.item_links query would miss mBrian-backed
|
|
// links and reject every delete under PROJAX_BACKEND=mbrian.
|
|
func (s *Server) linkBelongsToItem(ctx context.Context, linkID, itemID string) (bool, error) {
|
|
links, err := s.Items.LinksByType(ctx, itemID, "") // "" → every ref_type
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
for _, l := range links {
|
|
if l.ID == linkID {
|
|
return true, nil
|
|
}
|
|
}
|
|
return false, nil
|
|
}
|