mirror of
https://github.com/oxynote/oxynote.git
synced 2026-10-19 03:45:58 +02:00
329 lines
7.3 KiB
Go
329 lines
7.3 KiB
Go
package document
|
|
|
|
import (
|
|
"database/sql/driver"
|
|
"encoding/json"
|
|
"errors"
|
|
"maps"
|
|
|
|
"github.com/oxynote/oxynote/server/core/internal/document/searchgw"
|
|
"github.com/rs/xid"
|
|
)
|
|
|
|
const (
|
|
_nodeCommentIDAttr = "nodeCommentId"
|
|
_nodeUIDAttr = "uid"
|
|
_nodeCommentMarkType = "comment"
|
|
)
|
|
|
|
// RootBlock represents a block in a document.
|
|
type RootBlock struct {
|
|
// Type is the type of the root block (always BlockNodeText's
|
|
// containing doc — currently "doc" — for TipTap documents).
|
|
Type BlockNodeType `json:"type"`
|
|
|
|
// Content is the content of the root block.
|
|
Content []Block `json:"content,omitempty"`
|
|
}
|
|
|
|
// FindBlock searches for a block with the given ID in the root block.
|
|
func (rb RootBlock) FindBlock(blockID string) bool {
|
|
for _, b := range rb.Content {
|
|
if b.FindBlock(blockID) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// Search transforms RootBlock into a searchgw compatible type.
|
|
func (rb RootBlock) Search(organizationID string, documentID xid.ID) map[string]searchgw.Block {
|
|
res := make(map[string]searchgw.Block)
|
|
|
|
for _, b := range rb.Content {
|
|
content := b.Search(organizationID, documentID)
|
|
|
|
maps.Insert(res, maps.All(content))
|
|
}
|
|
|
|
return res
|
|
}
|
|
|
|
// Value transforms stopper type into a database entry.
|
|
func (rb RootBlock) Value() (driver.Value, error) {
|
|
return json.Marshal(rb)
|
|
}
|
|
|
|
// Scan transforms a database entry into a root block type.
|
|
func (rb *RootBlock) Scan(src any) error {
|
|
val, ok := src.([]byte)
|
|
if !ok {
|
|
return errors.New("invalid tiptac document type")
|
|
}
|
|
|
|
var nrb RootBlock
|
|
|
|
if err := json.Unmarshal(val, &nrb); err != nil {
|
|
return err
|
|
}
|
|
|
|
*rb = nrb
|
|
|
|
return nil
|
|
}
|
|
|
|
// Block represents a information block. It can be a paragraph, heading, or any other type of
|
|
// content in a document.
|
|
type Block struct {
|
|
// Type is the ProseMirror node-type tag. Compare against the
|
|
// BlockNode* constants; raw string comparisons won't compile.
|
|
Type BlockNodeType `json:"type"`
|
|
|
|
// Text is the text content of the block, if applicable.
|
|
Text string `json:"text,omitempty"`
|
|
|
|
// Content is the child blocks of this block, if applicable.
|
|
Content []Block `json:"content,omitempty"`
|
|
|
|
// Marks are the marks applied to this block, such as bold, italic, etc.
|
|
Marks []Mark `json:"marks,omitempty"`
|
|
|
|
// Attrs are additional attributes for the block, such as alignment, link, etc.
|
|
Attrs map[string]any `json:"attrs,omitempty"`
|
|
}
|
|
|
|
// UID returns the block's uid attribute and whether it is present.
|
|
// The bool is false when the attribute is missing or not a string;
|
|
// callers that need to distinguish "no uid" from "empty uid" should
|
|
// branch on it.
|
|
func (b Block) UID() (string, bool) {
|
|
id, ok := b.Attrs[_nodeUIDAttr].(string)
|
|
return id, ok
|
|
}
|
|
|
|
// Flatten returns the concatenated text of the block's entire
|
|
// subtree. Adjacent text fragments are separated by a single space.
|
|
// Used to derive a flat searchable representation from a structured
|
|
// ProseMirror tree.
|
|
func (b Block) Flatten() string {
|
|
var buf []byte
|
|
b.flattenInto(&buf)
|
|
|
|
return string(buf)
|
|
}
|
|
|
|
func (b Block) flattenInto(buf *[]byte) {
|
|
if b.Type == BlockNodeText {
|
|
if b.Text != "" {
|
|
if len(*buf) > 0 && (*buf)[len(*buf)-1] != ' ' {
|
|
*buf = append(*buf, ' ')
|
|
}
|
|
|
|
*buf = append(*buf, []byte(b.Text)...)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
for _, c := range b.Content {
|
|
c.flattenInto(buf)
|
|
}
|
|
}
|
|
|
|
// FindBlock recursively searches for a block with the given ID.
|
|
func (b Block) FindBlock(blockID string) bool {
|
|
if id, ok := b.UID(); ok && id == blockID {
|
|
return true
|
|
}
|
|
|
|
for _, cb := range b.Content {
|
|
if cb.FindBlock(blockID) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// Search transforms Block into a searchgw compatible type.
|
|
func (b Block) Search(organizationID string, documentID xid.ID) map[string]searchgw.Block {
|
|
res := make(map[string]searchgw.Block)
|
|
|
|
var text string
|
|
|
|
for _, cb := range b.Content {
|
|
// All parent nodes that contain text have a content element
|
|
// with the text type. This includes Headings, Paragraphs,
|
|
// CodeBlock, CodeBlockTitle, ListItem and so on.
|
|
if cb.Type == BlockNodeText {
|
|
text = cb.Text
|
|
continue
|
|
}
|
|
|
|
content := cb.Search(organizationID, documentID)
|
|
|
|
maps.Insert(res, maps.All(content))
|
|
}
|
|
|
|
if text != "" {
|
|
if id, ok := b.UID(); ok && id != "" {
|
|
res[id] = searchgw.Block{
|
|
ID: id,
|
|
OrganizationID: organizationID,
|
|
DocumentID: documentID,
|
|
Type: string(b.Type),
|
|
Text: text,
|
|
}
|
|
}
|
|
}
|
|
|
|
return res
|
|
}
|
|
|
|
// Mark represents a mark, such as bold or italic.
|
|
type Mark struct {
|
|
// Type is the type of the mark, such as "bold", "italic", etc.
|
|
Type string `json:"type"`
|
|
|
|
// Attrs are additional attributes for the mark, such as link URL.
|
|
Attrs map[string]any `json:"attrs,omitempty"`
|
|
}
|
|
|
|
// StripCommentMarks returns a copy of the RootBlock with all comment marks
|
|
// and nodeCommentId attributes removed. Unlike Duplicate, uid attributes are
|
|
// preserved as-is.
|
|
func (rb RootBlock) StripCommentMarks() RootBlock {
|
|
newContent := make([]Block, len(rb.Content))
|
|
|
|
for i, b := range rb.Content {
|
|
newContent[i] = b.stripCommentMarks()
|
|
}
|
|
|
|
return RootBlock{
|
|
Type: rb.Type,
|
|
Content: newContent,
|
|
}
|
|
}
|
|
|
|
// stripCommentMarks returns a copy of the Block with comment marks and
|
|
// nodeCommentId attributes removed. uid attributes are preserved.
|
|
func (b Block) stripCommentMarks() Block {
|
|
newBlock := Block{
|
|
Type: b.Type,
|
|
Text: b.Text,
|
|
}
|
|
|
|
if len(b.Content) > 0 {
|
|
newBlock.Content = make([]Block, len(b.Content))
|
|
|
|
for i, cb := range b.Content {
|
|
newBlock.Content[i] = cb.stripCommentMarks()
|
|
}
|
|
}
|
|
|
|
if len(b.Marks) > 0 {
|
|
newMarks := make([]Mark, 0, len(b.Marks))
|
|
|
|
for _, m := range b.Marks {
|
|
if m.Type != _nodeCommentMarkType {
|
|
newMarks = append(newMarks, m)
|
|
}
|
|
}
|
|
|
|
if len(newMarks) > 0 {
|
|
newBlock.Marks = newMarks
|
|
}
|
|
}
|
|
|
|
if len(b.Attrs) > 0 {
|
|
newAttrs := make(map[string]any)
|
|
|
|
for k, v := range b.Attrs {
|
|
if k == _nodeCommentIDAttr {
|
|
continue
|
|
}
|
|
|
|
newAttrs[k] = v
|
|
}
|
|
|
|
if len(newAttrs) > 0 {
|
|
newBlock.Attrs = newAttrs
|
|
}
|
|
}
|
|
|
|
return newBlock
|
|
}
|
|
|
|
// Duplicate creates a copy of the RootBlock with all comment marks
|
|
// removed, nodeCommentId attributes removed, and uid attributes regenerated.
|
|
func (rb RootBlock) Duplicate() RootBlock {
|
|
newContent := make([]Block, len(rb.Content))
|
|
|
|
for i, b := range rb.Content {
|
|
newContent[i] = b.duplicate()
|
|
}
|
|
|
|
return RootBlock{
|
|
Type: rb.Type,
|
|
Content: newContent,
|
|
}
|
|
}
|
|
|
|
// duplicate creates a copy of the Block with comment marks removed,
|
|
// nodeCommentId attributes removed, and uid attributes regenerated.
|
|
func (b Block) duplicate() Block {
|
|
newBlock := Block{
|
|
Type: b.Type,
|
|
Text: b.Text,
|
|
}
|
|
|
|
// Process child content recursively
|
|
if len(b.Content) > 0 {
|
|
newBlock.Content = make([]Block, len(b.Content))
|
|
|
|
for i, cb := range b.Content {
|
|
newBlock.Content[i] = cb.duplicate()
|
|
}
|
|
}
|
|
|
|
// Filter out comment marks
|
|
if len(b.Marks) > 0 {
|
|
newMarks := make([]Mark, 0, len(b.Marks))
|
|
|
|
for _, m := range b.Marks {
|
|
if m.Type != _nodeCommentMarkType {
|
|
newMarks = append(newMarks, m)
|
|
}
|
|
}
|
|
|
|
if len(newMarks) > 0 {
|
|
newBlock.Marks = newMarks
|
|
}
|
|
}
|
|
|
|
// Process attributes: remove nodeCommentId, regenerate uid
|
|
if len(b.Attrs) > 0 {
|
|
newAttrs := make(map[string]any)
|
|
|
|
for k, v := range b.Attrs {
|
|
if k == _nodeCommentIDAttr {
|
|
continue
|
|
}
|
|
|
|
if k == _nodeUIDAttr {
|
|
newAttrs[k] = generateNanoID()
|
|
continue
|
|
}
|
|
|
|
newAttrs[k] = v
|
|
}
|
|
|
|
if len(newAttrs) > 0 {
|
|
newBlock.Attrs = newAttrs
|
|
}
|
|
}
|
|
|
|
return newBlock
|
|
}
|