feat(workspace): bring an extracted note back into the bullet
A bullet that is only a link to a note (as left by /extract) gets a
"Notiz zurückholen" button. POST /documents/{id}/inline returns the
linked note's blocks with fresh ids and trashes the note; the editor
puts the title back as text and inserts the blocks as an own edit, so
Strg+Z restores the link and the note. Only own notes that no other
note links to. Release 0.52.0.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
75aea4a7d4
commit
39a04082fc
15 files changed
+293
-11
No files matched your search
@@ -24,14 +24,16 @@ func (s *Server) restructureActor(r *http.Request, clientID string) collab.Actor
|
||||
}
|
||||
|
||||
func restructureStatus(err error) int {
|
||||
var stillLinked *storage.StillLinkedError
|
||||
switch {
|
||||
case errors.Is(err, storage.ErrBranchLocked):
|
||||
case errors.Is(err, storage.ErrBranchLocked), errors.As(err, &stillLinked):
|
||||
return http.StatusConflict
|
||||
case errors.Is(err, storage.ErrBlockNotFound), errors.Is(err, storage.ErrDocNotFound):
|
||||
case errors.Is(err, storage.ErrBlockNotFound), errors.Is(err, storage.ErrDocNotFound), errors.Is(err, storage.ErrLinkedMissing):
|
||||
return http.StatusNotFound
|
||||
case errors.Is(err, storage.ErrMergeSelf), errors.Is(err, storage.ErrDocInTrash), errors.Is(err, storage.ErrAlreadyNote):
|
||||
case errors.Is(err, storage.ErrMergeSelf), errors.Is(err, storage.ErrDocInTrash), errors.Is(err, storage.ErrAlreadyNote),
|
||||
errors.Is(err, storage.ErrNotNoteLink):
|
||||
return http.StatusBadRequest
|
||||
case errors.Is(err, storage.ErrMergeNotOwner):
|
||||
case errors.Is(err, storage.ErrMergeNotOwner), errors.Is(err, storage.ErrInlineOwner):
|
||||
return http.StatusForbidden
|
||||
}
|
||||
return http.StatusInternalServerError
|
||||
@@ -80,6 +82,34 @@ func (s *Server) handleUndoExtract(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"undone": true})
|
||||
}
|
||||
|
||||
// handleInlineNote: POST /documents/{id}/inline {blockId} – the way back from extracting: returns the
|
||||
// linked note's blocks for the bullet and puts that note into the trash (undo: POST /documents/{note}/restore).
|
||||
func (s *Server) handleInlineNote(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
docID := chi.URLParam(r, "id")
|
||||
if !storage.CanEditDoc(s.store.DocRole(userID, docID)) {
|
||||
writeError(w, http.StatusForbidden, "Keine Schreibrechte für diese Notiz")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
BlockID string `json:"blockId"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.BlockID == "" {
|
||||
writeError(w, http.StatusBadRequest, "blockId fehlt")
|
||||
return
|
||||
}
|
||||
res, err := s.store.InlineNote(userID, docID, req.BlockID)
|
||||
if err != nil {
|
||||
writeError(w, restructureStatus(err), err.Error())
|
||||
return
|
||||
}
|
||||
users, _ := s.store.GetAuthorizedDocUsers(res.SourceID)
|
||||
for _, uid := range users {
|
||||
s.wsHub.BroadcastToUser(uid, "DOC_DELETED", map[string]string{"id": res.SourceID})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, res)
|
||||
}
|
||||
|
||||
// handleMergeDocuments: POST /documents/{id}/merge {sourceId, clientId} appends the source to this note.
|
||||
func (s *Server) handleMergeDocuments(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
|
||||
@@ -186,3 +186,79 @@ func TestMergeDocumentsAndUndo(t *testing.T) {
|
||||
t.Fatalf("foreign source: want 403, got %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// The way back from extracting: the link bullet gets the note's blocks, the note goes to the trash.
|
||||
func TestInlineNoteBringsBranchBack(t *testing.T) {
|
||||
srv, store, token := setupTestServer(t)
|
||||
router := srv.Router()
|
||||
claims, _ := auth.ValidateToken(testJWTSecret, token)
|
||||
uid := claims.UserID
|
||||
note, _ := store.CreateDocument(uid, "", "Konzept: Garten", "", "", []domain.DocumentBlock{
|
||||
{ID: "blk_b", Type: "bullet", Content: "Beete", Level: 0, Position: 1},
|
||||
{ID: "blk_c", Type: "todo", Content: "Erde kaufen", Level: 1, Position: 2, Checked: true},
|
||||
{ID: "blk_d", Type: "bullet", Content: "Zaun", Level: 0, Position: 3},
|
||||
}, nil, false)
|
||||
src, _ := store.CreateDocument(uid, "", "Projekt", "", "", []domain.DocumentBlock{
|
||||
{ID: "blk_l", Type: "bullet", Content: "[[konzept: garten]] #draußen", Level: 0, Position: 1},
|
||||
{ID: "blk_t", Type: "bullet", Content: "Siehe [[Konzept: Garten]]", Level: 0, Position: 2},
|
||||
{ID: "blk_x", Type: "bullet", Content: "[[Gibt es nicht]]", Level: 0, Position: 3},
|
||||
}, nil, false)
|
||||
inline := func(docID, blockID, tok string) (int, storage.InlineResult) {
|
||||
rec := sessionCall(router, "POST", "/api/v1/documents/"+docID+"/inline", tok, `{"blockId":"`+blockID+`"}`, "")
|
||||
var out storage.InlineResult
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &out)
|
||||
return rec.Code, out
|
||||
}
|
||||
if code, _ := inline(src.ID, "blk_t", token); code != http.StatusBadRequest {
|
||||
t.Fatalf("text with a link is no link bullet: %d", code)
|
||||
}
|
||||
if code, _ := inline(src.ID, "blk_x", token); code != http.StatusNotFound {
|
||||
t.Fatalf("missing note: %d", code)
|
||||
}
|
||||
|
||||
// Linked from another note as well: refused, nothing changes
|
||||
other, _ := store.CreateDocument(uid, "", "Anderswo", "", "", []domain.DocumentBlock{
|
||||
{ID: "blk_o", Type: "bullet", Content: "auch [[Konzept: Garten]]", Level: 0, Position: 1},
|
||||
}, nil, false)
|
||||
if code, _ := inline(src.ID, "blk_l", token); code != http.StatusConflict {
|
||||
t.Fatalf("note linked elsewhere must be refused: %d", code)
|
||||
}
|
||||
if d, _ := store.GetDocumentByID(uid, note.ID); d.IsArchived {
|
||||
t.Fatal("refused inline must not trash the note")
|
||||
}
|
||||
if rec := sessionCall(router, "DELETE", "/api/v1/documents/"+other.ID, token, "", ""); rec.Code >= 300 {
|
||||
t.Fatalf("delete other: %d", rec.Code)
|
||||
}
|
||||
|
||||
// Another person's note cannot be pulled in, even when shared with write access
|
||||
stranger, strangerToken := newTestUser(t, store, "fremd3@example.com")
|
||||
openSharing(t, store)
|
||||
if _, err := store.CreateDocShare(uid, src.ID, stranger.Email, "write"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if code, _ := inline(src.ID, "blk_l", strangerToken); code != http.StatusForbidden && code != http.StatusNotFound {
|
||||
t.Fatalf("stranger must not pull in someone else's note: %d", code)
|
||||
}
|
||||
|
||||
code, res := inline(src.ID, "blk_l", token)
|
||||
if code != http.StatusOK || res.SourceID != note.ID || res.Content != "Konzept: Garten #draußen" {
|
||||
t.Fatalf("inline: %d %+v", code, res)
|
||||
}
|
||||
var got []string
|
||||
for _, b := range res.Blocks {
|
||||
got = append(got, strings.Repeat(">", b.Level)+b.Content)
|
||||
if b.ID == "blk_b" || b.ID == "blk_c" {
|
||||
t.Fatal("blocks need fresh ids, the old ones live on in the trashed note")
|
||||
}
|
||||
}
|
||||
if strings.Join(got, "|") != "Beete|>Erde kaufen|Zaun" || !res.Blocks[1].Checked || res.Blocks[1].Type != "todo" {
|
||||
t.Fatalf("blocks: %v %+v", got, res.Blocks)
|
||||
}
|
||||
if d, _ := store.GetDocumentByID(uid, note.ID); d == nil || !d.IsArchived {
|
||||
t.Fatal("brought-back note must be in the trash")
|
||||
}
|
||||
// Undo uses the ordinary restore
|
||||
if rec := sessionCall(router, "POST", "/api/v1/documents/"+note.ID+"/restore", token, "", ""); rec.Code != http.StatusOK {
|
||||
t.Fatalf("restore: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -173,6 +173,7 @@ func (s *Server) Router() http.Handler {
|
||||
r.Post("/api/v1/tasks/{id}/watch", s.handleWatchTask)
|
||||
r.Delete("/api/v1/tasks/{id}/watch", s.handleWatchTask)
|
||||
r.Post("/api/v1/documents/{id}/extract/undo", s.handleUndoExtract)
|
||||
r.Post("/api/v1/documents/{id}/inline", s.handleInlineNote)
|
||||
r.Post("/api/v1/documents/{id}/merge", s.handleMergeDocuments)
|
||||
r.Post("/api/v1/documents/{id}/merge/undo", s.handleUndoMerge)
|
||||
r.Get("/api/v1/link-preview", s.notInDemo(s.handleLinkPreview))
|
||||
|
||||
@@ -22,6 +22,9 @@ var (
|
||||
ErrMergeSelf = errors.New("Eine Notiz kann nicht mit sich selbst zusammengeführt werden")
|
||||
ErrAlreadyNote = errors.New("Dieser Punkt verweist schon auf eine eigene Notiz")
|
||||
ErrMergeNotOwner = errors.New("Zusammenführen geht nur mit eigenen Notizen als Quelle")
|
||||
ErrNotNoteLink = errors.New("Dieser Punkt ist kein Link auf eine Notiz")
|
||||
ErrLinkedMissing = errors.New("Die verlinkte Notiz gibt es nicht (mehr)")
|
||||
ErrInlineOwner = errors.New("Zurückholen geht nur mit eigenen Notizen")
|
||||
)
|
||||
|
||||
// readCollabDoc loads the current state of a note outside of a change.
|
||||
@@ -161,6 +164,100 @@ func (s *Store) UndoExtract(userID, docID string) error {
|
||||
return s.trashDocument(userID, docID)
|
||||
}
|
||||
|
||||
// StillLinkedError: the note to bring back is linked from other notes as well
|
||||
type StillLinkedError struct {
|
||||
Title string
|
||||
Count int
|
||||
}
|
||||
|
||||
func (e *StillLinkedError) Error() string {
|
||||
return fmt.Sprintf("„%s“ ist noch in %d weiteren Notiz(en) verlinkt; diese Links würden ins Leere zeigen. Bitte dort zuerst entfernen.", e.Title, e.Count)
|
||||
}
|
||||
|
||||
// InlineResult: what the editor puts back in place of the link bullet
|
||||
type InlineResult struct {
|
||||
SourceID string `json:"sourceId"`
|
||||
// Text of the bullet: the note's title, hashtags of the link kept
|
||||
Content string `json:"content"`
|
||||
// The note's blocks with fresh ids; level 0 = directly below the bullet
|
||||
Blocks []domain.DocumentBlock `json:"blocks"`
|
||||
}
|
||||
|
||||
// InlineNote is the way back from ExtractBranch: for a bullet that is only a link to an own note, it
|
||||
// returns that note's blocks and puts the note into the trash. Like ExtractBranch it does not change
|
||||
// the note with the bullet; the editor inserts the blocks itself as an own edit (undo works). Refused
|
||||
// while other notes link to the note as well, since those links would point to nothing.
|
||||
func (s *Store) InlineNote(userID, docID, blockID string) (*InlineResult, error) {
|
||||
state, err := s.readCollabDoc(docID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var content string
|
||||
found := false
|
||||
for _, b := range state.Flatten() {
|
||||
if b.ID == blockID {
|
||||
content, found = b.Content, true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return nil, ErrBlockNotFound
|
||||
}
|
||||
m := wikiLinkRe.FindStringSubmatch(content)
|
||||
if !noteLinkOnlyRe.MatchString(content) || m == nil {
|
||||
return nil, ErrNotNoteLink
|
||||
}
|
||||
title := strings.TrimSpace(m[1])
|
||||
docs, err := s.GetDocuments(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var linked *domain.Document
|
||||
for i := range docs {
|
||||
d := &docs[i]
|
||||
if d.ID == docID || !strings.EqualFold(strings.TrimSpace(d.Title), title) {
|
||||
continue
|
||||
}
|
||||
// An own note wins over one shared with the user under the same title
|
||||
if linked == nil || (d.IsOwner && !linked.IsOwner) {
|
||||
linked = d
|
||||
}
|
||||
}
|
||||
if linked == nil {
|
||||
return nil, ErrLinkedMissing
|
||||
}
|
||||
if !linked.IsOwner {
|
||||
return nil, ErrInlineOwner
|
||||
}
|
||||
backlinks, err := s.GetBacklinks(userID, linked.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
others := 0
|
||||
for _, bl := range backlinks {
|
||||
if bl.DocID != docID {
|
||||
others++
|
||||
}
|
||||
}
|
||||
if others > 0 {
|
||||
return nil, &StillLinkedError{Title: linked.Title, Count: others}
|
||||
}
|
||||
source, err := s.readCollabDoc(linked.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
blocks := []domain.DocumentBlock{}
|
||||
for _, b := range source.Flatten() {
|
||||
blocks = append(blocks, toDocumentBlock(b, b.Level, len(blocks)))
|
||||
}
|
||||
rest := strings.TrimSpace(wikiLinkRe.ReplaceAllString(content, ""))
|
||||
text := strings.TrimSpace(linked.Title + " " + rest)
|
||||
if err := s.trashDocument(userID, linked.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &InlineResult{SourceID: linked.ID, Content: text, Blocks: blocks}, nil
|
||||
}
|
||||
|
||||
// copyShares gives the new note the direct and team shares of the source; when someone else owns the
|
||||
// source, that owner may edit the new note as well (the branch was theirs to read before).
|
||||
func (s *Store) copyShares(userID string, source *domain.Document, newDocID string) {
|
||||
|
||||
Reference in new issue
Block a user