800 lines
25 KiB
Go
800 lines
25 KiB
Go
package documents
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
_ "embed"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"html"
|
|
"io"
|
|
"mime"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
"unicode"
|
|
|
|
"github.com/eidrisov/xlsReader/xls"
|
|
"github.com/signintech/gopdf"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/clause"
|
|
"senlinai-agent/backend/internal/models"
|
|
)
|
|
|
|
var (
|
|
ErrInvalidDocument = errors.New("invalid document")
|
|
ErrInvalidParent = errors.New("invalid document parent")
|
|
ErrNameConflict = errors.New("document name already exists")
|
|
ErrRevisionConflict = errors.New("document revision conflict")
|
|
ErrUnsupportedExport = errors.New("unsupported document export")
|
|
ErrShareNotFound = errors.New("document share not found")
|
|
ErrBlobNotFound = errors.New("document blob not found")
|
|
)
|
|
|
|
type Service struct {
|
|
db *gorm.DB
|
|
root string
|
|
now func() time.Time
|
|
}
|
|
|
|
type CreateInput struct {
|
|
Name string
|
|
ParentIdentity string
|
|
Markdown string
|
|
SourceInboxItemID *uint
|
|
}
|
|
|
|
type UpdateInput struct {
|
|
Name *string
|
|
ParentIdentity *string
|
|
Markdown *string
|
|
Revision *uint64
|
|
}
|
|
|
|
type StoredBlob struct {
|
|
StorageKey string
|
|
RelativePath string
|
|
AbsolutePath string
|
|
OriginalName string
|
|
MimeType string
|
|
Extension string
|
|
Size int64
|
|
Checksum string
|
|
}
|
|
|
|
type SharedDocument struct {
|
|
Document models.SaDocument
|
|
Content *models.SaDocumentContent
|
|
Blob *models.SaDocumentBlob
|
|
}
|
|
|
|
type SheetPreview struct {
|
|
SheetName string `json:"sheetName"`
|
|
Rows [][]string `json:"rows"`
|
|
Truncated bool `json:"truncated"`
|
|
}
|
|
|
|
//go:embed assets/NotoSansSC.ttf
|
|
var documentPDFFont []byte
|
|
|
|
func NewService(database *gorm.DB, root string) *Service {
|
|
return &Service{db: database, root: root, now: time.Now}
|
|
}
|
|
|
|
func (s *Service) database() *gorm.DB {
|
|
if s.db != nil {
|
|
return s.db
|
|
}
|
|
return models.DBService
|
|
}
|
|
|
|
func (s *Service) List(userID uint, projectIdentity string) ([]models.SaDocument, error) {
|
|
project, err := s.ownedProject(userID, projectIdentity)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var documents []models.SaDocument
|
|
err = s.database().Where("project_id = ?", project.ID).
|
|
Order("kind asc, normalized_name asc, id asc").Find(&documents).Error
|
|
return documents, err
|
|
}
|
|
|
|
func (s *Service) CreateFolder(userID uint, projectIdentity string, input CreateInput) (*models.SaDocument, error) {
|
|
return s.createNode(userID, projectIdentity, input, models.DocumentKindFolder, "", "", 0)
|
|
}
|
|
|
|
func (s *Service) CreateMarkdown(userID uint, projectIdentity string, input CreateInput) (*models.SaDocument, error) {
|
|
if strings.TrimSpace(filepath.Ext(input.Name)) == "" {
|
|
input.Name += ".md"
|
|
}
|
|
var created *models.SaDocument
|
|
err := s.database().Transaction(func(tx *gorm.DB) error {
|
|
service := NewService(tx, s.root)
|
|
document, err := service.createNode(userID, projectIdentity, input, models.DocumentKindFile, ".md", "text/markdown; charset=utf-8", int64(len([]byte(input.Markdown))))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Create(&models.SaDocumentContent{DocumentID: document.ID, Markdown: input.Markdown}).Error; err != nil {
|
|
return err
|
|
}
|
|
created = document
|
|
return nil
|
|
})
|
|
return created, err
|
|
}
|
|
|
|
func (s *Service) createNode(userID uint, projectIdentity string, input CreateInput, kind, extension, mimeType string, size int64) (*models.SaDocument, error) {
|
|
project, err := s.ownedProject(userID, projectIdentity)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
parentID, parentIdentity, err := s.resolveParent(project.ID, input.ParentIdentity)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
name := cleanDisplayName(input.Name)
|
|
if name == "" || name == "." || name == ".." || strings.ContainsAny(name, `/\`) {
|
|
return nil, ErrInvalidDocument
|
|
}
|
|
name, err = s.availableName(project.ID, parentID, name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if kind == models.DocumentKindFile {
|
|
extension = strings.ToLower(filepath.Ext(name))
|
|
}
|
|
document := &models.SaDocument{
|
|
ProjectID: project.ID, ProjectIdentity: project.Identity,
|
|
ParentID: parentID, ParentIdentity: parentIdentity,
|
|
ParentScope: identityValue(parentIdentity),
|
|
CreatedBy: userID, SourceInboxItemID: input.SourceInboxItemID,
|
|
Kind: kind, Name: name, NormalizedName: normalizeName(name),
|
|
Extension: extension, MimeType: mimeType, Size: size, Revision: 1,
|
|
ScanStatus: "not_scanned",
|
|
}
|
|
if err := s.database().Create(document).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
|
return nil, ErrNameConflict
|
|
}
|
|
return nil, err
|
|
}
|
|
return document, nil
|
|
}
|
|
|
|
func (s *Service) CreateUploaded(userID uint, projectIdentity, parentIdentity string, stored StoredBlob) (*models.SaDocument, error) {
|
|
var created *models.SaDocument
|
|
err := s.database().Transaction(func(tx *gorm.DB) error {
|
|
service := NewService(tx, s.root)
|
|
document, err := service.createNode(userID, projectIdentity, CreateInput{
|
|
Name: stored.OriginalName, ParentIdentity: parentIdentity,
|
|
}, models.DocumentKindFile, stored.Extension, stored.MimeType, stored.Size)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Create(&models.SaDocumentBlob{
|
|
DocumentID: document.ID, StorageKey: stored.StorageKey, RelativePath: stored.RelativePath,
|
|
OriginalName: stored.OriginalName, Checksum: stored.Checksum,
|
|
}).Error; err != nil {
|
|
return err
|
|
}
|
|
created = document
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
_ = os.Remove(stored.AbsolutePath)
|
|
}
|
|
return created, err
|
|
}
|
|
|
|
func (s *Service) Get(userID uint, projectIdentity, documentIdentity string) (*SharedDocument, error) {
|
|
project, err := s.ownedProject(userID, projectIdentity)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var document models.SaDocument
|
|
if err := s.database().Where("identity = ? AND project_id = ?", strings.TrimSpace(documentIdentity), project.ID).First(&document).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return s.loadDocument(document)
|
|
}
|
|
|
|
func (s *Service) Update(userID uint, projectIdentity, documentIdentity string, input UpdateInput) (*SharedDocument, error) {
|
|
var updated *SharedDocument
|
|
err := s.database().Transaction(func(tx *gorm.DB) error {
|
|
service := NewService(tx, s.root)
|
|
project, err := service.ownedProject(userID, projectIdentity)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var document models.SaDocument
|
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
|
Where("identity = ? AND project_id = ?", strings.TrimSpace(documentIdentity), project.ID).
|
|
First(&document).Error; err != nil {
|
|
return err
|
|
}
|
|
updates := map[string]any{}
|
|
if input.Name != nil {
|
|
name := cleanDisplayName(*input.Name)
|
|
if name == "" || strings.ContainsAny(name, `/\`) {
|
|
return ErrInvalidDocument
|
|
}
|
|
if normalizeName(name) != document.NormalizedName {
|
|
if service.nameExists(project.ID, document.ParentID, normalizeName(name), document.ID) {
|
|
return ErrNameConflict
|
|
}
|
|
updates["name"] = name
|
|
updates["normalized_name"] = normalizeName(name)
|
|
if document.Kind == models.DocumentKindFile {
|
|
updates["extension"] = strings.ToLower(filepath.Ext(name))
|
|
}
|
|
}
|
|
}
|
|
if input.ParentIdentity != nil {
|
|
parentID, parentIdentity, err := service.resolveParent(project.ID, *input.ParentIdentity)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if parentID != nil && *parentID == document.ID {
|
|
return ErrInvalidParent
|
|
}
|
|
if document.Kind == models.DocumentKindFolder && parentID != nil {
|
|
descendant, err := service.isDescendant(document.ID, *parentID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if descendant {
|
|
return ErrInvalidParent
|
|
}
|
|
}
|
|
nextName := document.NormalizedName
|
|
if value, ok := updates["normalized_name"].(string); ok {
|
|
nextName = value
|
|
}
|
|
if service.nameExists(project.ID, parentID, nextName, document.ID) {
|
|
return ErrNameConflict
|
|
}
|
|
updates["parent_id"] = parentID
|
|
updates["parent_identity"] = parentIdentity
|
|
updates["parent_scope"] = identityValue(parentIdentity)
|
|
}
|
|
if input.Markdown != nil {
|
|
if document.Kind != models.DocumentKindFile || document.Extension != ".md" || input.Revision == nil {
|
|
return ErrInvalidDocument
|
|
}
|
|
if document.Revision != *input.Revision {
|
|
return ErrRevisionConflict
|
|
}
|
|
var content models.SaDocumentContent
|
|
err := tx.Where("document_id = ?", document.ID).First(&content).Error
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
content = models.SaDocumentContent{DocumentID: document.ID, Markdown: *input.Markdown}
|
|
err = tx.Create(&content).Error
|
|
} else if err == nil {
|
|
err = tx.Model(&content).Update("markdown", *input.Markdown).Error
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
updates["size"] = int64(len([]byte(*input.Markdown)))
|
|
updates["revision"] = document.Revision + 1
|
|
}
|
|
if len(updates) > 0 {
|
|
if err := tx.Model(&document).Updates(updates).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
|
return ErrNameConflict
|
|
}
|
|
return err
|
|
}
|
|
}
|
|
if err := tx.Where("id = ?", document.ID).First(&document).Error; err != nil {
|
|
return err
|
|
}
|
|
updated, err = service.loadDocument(document)
|
|
return err
|
|
})
|
|
return updated, err
|
|
}
|
|
|
|
func (s *Service) Delete(userID uint, projectIdentity, documentIdentity string) error {
|
|
project, err := s.ownedProject(userID, projectIdentity)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var document models.SaDocument
|
|
if err := s.database().Where("identity = ? AND project_id = ?", documentIdentity, project.ID).First(&document).Error; err != nil {
|
|
return err
|
|
}
|
|
ids, err := s.subtreeIDs(project.ID, document.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.database().Where("id IN ?", ids).Delete(&models.SaDocument{}).Error
|
|
}
|
|
|
|
func (s *Service) Store(projectIdentity, originalName string, source io.Reader) (StoredBlob, error) {
|
|
projectSegment := filepath.Base(strings.TrimSpace(projectIdentity))
|
|
if projectSegment == "." || projectSegment == "" || projectSegment != strings.TrimSpace(projectIdentity) {
|
|
return StoredBlob{}, ErrInvalidDocument
|
|
}
|
|
name := cleanDisplayName(originalName)
|
|
if name == "" {
|
|
name = "upload.bin"
|
|
}
|
|
directoryRelative := filepath.ToSlash(filepath.Join("projects", projectSegment, "documents"))
|
|
directoryAbsolute, err := s.absolutePath(directoryRelative)
|
|
if err != nil {
|
|
return StoredBlob{}, err
|
|
}
|
|
if err := os.MkdirAll(directoryAbsolute, 0o755); err != nil {
|
|
return StoredBlob{}, err
|
|
}
|
|
temp, err := os.CreateTemp(directoryAbsolute, ".upload-*")
|
|
if err != nil {
|
|
return StoredBlob{}, err
|
|
}
|
|
tempName := temp.Name()
|
|
defer func() { _ = os.Remove(tempName) }()
|
|
hasher := sha256.New()
|
|
var sniff bytes.Buffer
|
|
writer := io.MultiWriter(temp, hasher, &limitedWriter{writer: &sniff, remaining: 512})
|
|
size, copyErr := io.Copy(writer, source)
|
|
closeErr := temp.Close()
|
|
if copyErr != nil {
|
|
return StoredBlob{}, copyErr
|
|
}
|
|
if closeErr != nil {
|
|
return StoredBlob{}, closeErr
|
|
}
|
|
keyBytes := make([]byte, 16)
|
|
if _, err := rand.Read(keyBytes); err != nil {
|
|
return StoredBlob{}, err
|
|
}
|
|
key := hex.EncodeToString(keyBytes)
|
|
relative := filepath.ToSlash(filepath.Join(directoryRelative, key))
|
|
absolute, err := s.absolutePath(relative)
|
|
if err != nil {
|
|
return StoredBlob{}, err
|
|
}
|
|
if err := os.Rename(tempName, absolute); err != nil {
|
|
return StoredBlob{}, err
|
|
}
|
|
detected := http.DetectContentType(sniff.Bytes())
|
|
extension := strings.ToLower(filepath.Ext(name))
|
|
if extensionType := mime.TypeByExtension(extension); extensionType != "" && detected == "application/octet-stream" {
|
|
detected = extensionType
|
|
}
|
|
return StoredBlob{
|
|
StorageKey: key, RelativePath: relative, AbsolutePath: absolute, OriginalName: name,
|
|
MimeType: detected, Extension: extension, Size: size, Checksum: hex.EncodeToString(hasher.Sum(nil)),
|
|
}, nil
|
|
}
|
|
|
|
func (s *Service) OpenBlob(blob models.SaDocumentBlob) (*os.File, error) {
|
|
absolute, err := s.absolutePath(blob.RelativePath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return os.Open(absolute)
|
|
}
|
|
|
|
func (s *Service) PreviewLegacySheet(loaded SharedDocument, maxRows, maxColumns int) (SheetPreview, error) {
|
|
if loaded.Document.Extension != ".xls" || loaded.Blob == nil {
|
|
return SheetPreview{}, ErrInvalidDocument
|
|
}
|
|
file, err := s.OpenBlob(*loaded.Blob)
|
|
if err != nil {
|
|
return SheetPreview{}, err
|
|
}
|
|
defer file.Close()
|
|
workbook, err := xls.OpenReader(file)
|
|
if err != nil {
|
|
return SheetPreview{}, ErrInvalidDocument
|
|
}
|
|
sheet, err := workbook.GetSheet(0)
|
|
if err != nil {
|
|
return SheetPreview{}, ErrInvalidDocument
|
|
}
|
|
sourceRows := sheet.GetRows()
|
|
preview := SheetPreview{SheetName: sheet.GetName(), Truncated: len(sourceRows) > maxRows}
|
|
if len(sourceRows) > maxRows {
|
|
sourceRows = sourceRows[:maxRows]
|
|
}
|
|
preview.Rows = make([][]string, 0, len(sourceRows))
|
|
for _, sourceRow := range sourceRows {
|
|
columns := sourceRow.GetCols()
|
|
if len(columns) > maxColumns {
|
|
columns = columns[:maxColumns]
|
|
preview.Truncated = true
|
|
}
|
|
row := make([]string, 0, len(columns))
|
|
for _, column := range columns {
|
|
row = append(row, column.GetString())
|
|
}
|
|
preview.Rows = append(preview.Rows, row)
|
|
}
|
|
return preview, nil
|
|
}
|
|
|
|
func (s *Service) CreateShare(userID uint, projectIdentity, documentIdentity string, duration time.Duration) (*models.SaDocumentShare, string, error) {
|
|
loaded, err := s.Get(userID, projectIdentity, documentIdentity)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
if loaded.Document.Kind != models.DocumentKindFile {
|
|
return nil, "", ErrInvalidDocument
|
|
}
|
|
if duration <= 0 || duration > 30*24*time.Hour {
|
|
return nil, "", ErrInvalidDocument
|
|
}
|
|
tokenBytes := make([]byte, 32)
|
|
if _, err := rand.Read(tokenBytes); err != nil {
|
|
return nil, "", err
|
|
}
|
|
token := hex.EncodeToString(tokenBytes)
|
|
hash := sha256.Sum256([]byte(token))
|
|
share := &models.SaDocumentShare{
|
|
DocumentID: loaded.Document.ID, CreatedBy: userID, TokenHash: hex.EncodeToString(hash[:]),
|
|
ExpiresAt: s.now().UTC().Add(duration),
|
|
}
|
|
return share, token, s.database().Create(share).Error
|
|
}
|
|
|
|
func (s *Service) ListShares(userID uint, projectIdentity, documentIdentity string) ([]models.SaDocumentShare, error) {
|
|
loaded, err := s.Get(userID, projectIdentity, documentIdentity)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var shares []models.SaDocumentShare
|
|
err = s.database().Where("document_id = ?", loaded.Document.ID).Order("created_at desc").Find(&shares).Error
|
|
return shares, err
|
|
}
|
|
|
|
func (s *Service) RevokeShare(userID uint, projectIdentity, documentIdentity, shareIdentity string) error {
|
|
loaded, err := s.Get(userID, projectIdentity, documentIdentity)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
now := s.now().UTC()
|
|
result := s.database().Model(&models.SaDocumentShare{}).
|
|
Where("identity = ? AND document_id = ?", shareIdentity, loaded.Document.ID).
|
|
Update("revoked_at", &now)
|
|
if result.Error != nil {
|
|
return result.Error
|
|
}
|
|
if result.RowsAffected == 0 {
|
|
return gorm.ErrRecordNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) ResolveShare(token string) (*SharedDocument, error) {
|
|
hash := sha256.Sum256([]byte(strings.TrimSpace(token)))
|
|
var share models.SaDocumentShare
|
|
if err := s.database().Where(
|
|
"token_hash = ? AND revoked_at IS NULL AND expires_at > ?",
|
|
hex.EncodeToString(hash[:]), s.now().UTC(),
|
|
).First(&share).Error; err != nil {
|
|
return nil, ErrShareNotFound
|
|
}
|
|
var document models.SaDocument
|
|
if err := s.database().Where("id = ?", share.DocumentID).First(&document).Error; err != nil {
|
|
return nil, ErrShareNotFound
|
|
}
|
|
return s.loadDocument(document)
|
|
}
|
|
|
|
func (s *Service) Export(document SharedDocument, format string) ([]byte, string, string, error) {
|
|
if document.Content == nil || document.Document.Extension != ".md" {
|
|
return nil, "", "", ErrUnsupportedExport
|
|
}
|
|
base := strings.TrimSuffix(document.Document.Name, filepath.Ext(document.Document.Name))
|
|
switch strings.ToLower(strings.TrimSpace(format)) {
|
|
case "md", "markdown":
|
|
return []byte(document.Content.Markdown), base + ".md", "text/markdown; charset=utf-8", nil
|
|
case "docx":
|
|
data, err := markdownDOCX(document.Content.Markdown)
|
|
return data, base + ".docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", err
|
|
case "pdf":
|
|
data, err := markdownPDF(document.Content.Markdown)
|
|
return data, base + ".pdf", "application/pdf", err
|
|
default:
|
|
return nil, "", "", ErrUnsupportedExport
|
|
}
|
|
}
|
|
|
|
func (s *Service) loadDocument(document models.SaDocument) (*SharedDocument, error) {
|
|
result := &SharedDocument{Document: document}
|
|
var content models.SaDocumentContent
|
|
if err := s.database().Where("document_id = ?", document.ID).First(&content).Error; err == nil {
|
|
result.Content = &content
|
|
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, err
|
|
}
|
|
var blob models.SaDocumentBlob
|
|
if err := s.database().Where("document_id = ?", document.ID).First(&blob).Error; err == nil {
|
|
result.Blob = &blob
|
|
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (s *Service) ownedProject(userID uint, identity string) (*models.SaProject, error) {
|
|
var project models.SaProject
|
|
err := s.database().Where("identity = ? AND owner_id = ?", strings.TrimSpace(identity), userID).First(&project).Error
|
|
return &project, err
|
|
}
|
|
|
|
func (s *Service) resolveParent(projectID uint, identity string) (*uint, *string, error) {
|
|
identity = strings.TrimSpace(identity)
|
|
if identity == "" {
|
|
return nil, nil, nil
|
|
}
|
|
var parent models.SaDocument
|
|
if err := s.database().Where("identity = ? AND project_id = ? AND kind = ?", identity, projectID, models.DocumentKindFolder).First(&parent).Error; err != nil {
|
|
return nil, nil, ErrInvalidParent
|
|
}
|
|
return &parent.ID, &parent.Identity, nil
|
|
}
|
|
|
|
func (s *Service) availableName(projectID uint, parentID *uint, name string) (string, error) {
|
|
extension := filepath.Ext(name)
|
|
base := strings.TrimSuffix(name, extension)
|
|
for attempt := 0; attempt < 1000; attempt++ {
|
|
candidate := name
|
|
if attempt > 0 {
|
|
candidate = fmt.Sprintf("%s (%d)%s", base, attempt, extension)
|
|
}
|
|
if !s.nameExists(projectID, parentID, normalizeName(candidate), 0) {
|
|
return candidate, nil
|
|
}
|
|
}
|
|
return "", ErrNameConflict
|
|
}
|
|
|
|
func (s *Service) nameExists(projectID uint, parentID *uint, normalized string, exceptID uint) bool {
|
|
query := s.database().Model(&models.SaDocument{}).Where("project_id = ? AND normalized_name = ?", projectID, normalized)
|
|
if parentID == nil {
|
|
query = query.Where("parent_id IS NULL")
|
|
} else {
|
|
query = query.Where("parent_id = ?", *parentID)
|
|
}
|
|
if exceptID > 0 {
|
|
query = query.Where("id <> ?", exceptID)
|
|
}
|
|
var count int64
|
|
return query.Count(&count).Error == nil && count > 0
|
|
}
|
|
|
|
func (s *Service) isDescendant(ancestorID, candidateID uint) (bool, error) {
|
|
current := candidateID
|
|
for current != 0 {
|
|
if current == ancestorID {
|
|
return true, nil
|
|
}
|
|
var document models.SaDocument
|
|
if err := s.database().Unscoped().Select("parent_id").Where("id = ?", current).First(&document).Error; err != nil {
|
|
return false, err
|
|
}
|
|
if document.ParentID == nil {
|
|
return false, nil
|
|
}
|
|
current = *document.ParentID
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
func (s *Service) subtreeIDs(projectID, rootID uint) ([]uint, error) {
|
|
result := []uint{rootID}
|
|
frontier := []uint{rootID}
|
|
for len(frontier) > 0 {
|
|
var children []models.SaDocument
|
|
if err := s.database().Select("id").Where("project_id = ? AND parent_id IN ?", projectID, frontier).Find(&children).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
frontier = frontier[:0]
|
|
for _, child := range children {
|
|
result = append(result, child.ID)
|
|
frontier = append(frontier, child.ID)
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (s *Service) absolutePath(relative string) (string, error) {
|
|
cleaned := filepath.Clean(filepath.FromSlash(strings.TrimSpace(relative)))
|
|
if cleaned == "." || filepath.IsAbs(cleaned) || cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(filepath.Separator)) {
|
|
return "", ErrInvalidDocument
|
|
}
|
|
root, err := filepath.Abs(s.root)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
absolute, err := filepath.Abs(filepath.Join(root, cleaned))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
relativeToRoot, err := filepath.Rel(root, absolute)
|
|
if err != nil || relativeToRoot == ".." || strings.HasPrefix(relativeToRoot, ".."+string(filepath.Separator)) {
|
|
return "", ErrInvalidDocument
|
|
}
|
|
return absolute, nil
|
|
}
|
|
|
|
func cleanDisplayName(value string) string {
|
|
return strings.TrimSpace(filepath.Base(strings.ReplaceAll(value, "\\", "/")))
|
|
}
|
|
|
|
func normalizeName(value string) string {
|
|
return strings.Map(func(r rune) rune {
|
|
if unicode.IsSpace(r) {
|
|
return ' '
|
|
}
|
|
return unicode.ToLower(r)
|
|
}, strings.TrimSpace(value))
|
|
}
|
|
|
|
func identityValue(value *string) string {
|
|
if value == nil {
|
|
return ""
|
|
}
|
|
return *value
|
|
}
|
|
|
|
type limitedWriter struct {
|
|
writer io.Writer
|
|
remaining int
|
|
}
|
|
|
|
func (w *limitedWriter) Write(p []byte) (int, error) {
|
|
if w.remaining <= 0 {
|
|
return len(p), nil
|
|
}
|
|
size := len(p)
|
|
if size > w.remaining {
|
|
size = w.remaining
|
|
}
|
|
_, _ = w.writer.Write(p[:size])
|
|
w.remaining -= size
|
|
return len(p), nil
|
|
}
|
|
|
|
var markdownPrefix = regexp.MustCompile(`(?m)^#{1,6}\s+|^\s*[-*+]\s+|^\s*\d+\.\s+|^\s*>\s?`)
|
|
|
|
func plainMarkdown(value string) string {
|
|
value = markdownPrefix.ReplaceAllString(value, "")
|
|
value = strings.NewReplacer("**", "", "__", "", "`", "", "\r", "").Replace(value)
|
|
return value
|
|
}
|
|
|
|
func markdownDOCX(markdown string) ([]byte, error) {
|
|
var buffer bytes.Buffer
|
|
archive := zip.NewWriter(&buffer)
|
|
files := map[string]string{
|
|
"[Content_Types].xml": `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>`,
|
|
"_rels/.rels": `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>`,
|
|
"word/document.xml": docxDocumentXML(markdown),
|
|
}
|
|
for name, body := range files {
|
|
writer, err := archive.Create(name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if _, err := io.WriteString(writer, body); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if err := archive.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
return buffer.Bytes(), nil
|
|
}
|
|
|
|
func docxDocumentXML(markdown string) string {
|
|
var paragraphs strings.Builder
|
|
for _, line := range strings.Split(plainMarkdown(markdown), "\n") {
|
|
paragraphs.WriteString(`<w:p><w:r><w:t xml:space="preserve">`)
|
|
paragraphs.WriteString(html.EscapeString(line))
|
|
paragraphs.WriteString(`</w:t></w:r></w:p>`)
|
|
}
|
|
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>` +
|
|
paragraphs.String() + `<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440"/></w:sectPr></w:body></w:document>`
|
|
}
|
|
|
|
func markdownPDF(markdown string) ([]byte, error) {
|
|
const (
|
|
pageWidth = 595.28
|
|
pageHeight = 841.89
|
|
marginX = 52.0
|
|
top = 56.0
|
|
bottom = 60.0
|
|
)
|
|
pdf := &gopdf.GoPdf{}
|
|
pdf.Start(gopdf.Config{PageSize: gopdf.Rect{W: pageWidth, H: pageHeight}})
|
|
if err := pdf.AddTTFFontData("NotoSansSC", documentPDFFont); err != nil {
|
|
return nil, fmt.Errorf("load document PDF font: %w", err)
|
|
}
|
|
pageNumber := 0
|
|
y := top
|
|
addPage := func() error {
|
|
if pageNumber > 0 {
|
|
if err := writePDFFooter(pdf, pageNumber, pageWidth, pageHeight); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
pdf.AddPage()
|
|
pageNumber++
|
|
y = top
|
|
return nil
|
|
}
|
|
if err := addPage(); err != nil {
|
|
return nil, err
|
|
}
|
|
for _, rawLine := range strings.Split(strings.ReplaceAll(markdown, "\r", ""), "\n") {
|
|
line := plainMarkdown(rawLine)
|
|
fontSize := 11.0
|
|
lineHeight := 18.0
|
|
switch {
|
|
case strings.HasPrefix(rawLine, "# "):
|
|
fontSize, lineHeight = 22, 32
|
|
case strings.HasPrefix(rawLine, "## "):
|
|
fontSize, lineHeight = 18, 27
|
|
case strings.HasPrefix(rawLine, "### "):
|
|
fontSize, lineHeight = 15, 23
|
|
case strings.TrimSpace(rawLine) == "":
|
|
y += 9
|
|
continue
|
|
}
|
|
if err := pdf.SetFont("NotoSansSC", "", fontSize); err != nil {
|
|
return nil, err
|
|
}
|
|
lines, err := pdf.SplitTextWithWordWrap(line, pageWidth-2*marginX)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(lines) == 0 {
|
|
lines = []string{""}
|
|
}
|
|
for _, wrapped := range lines {
|
|
if y+lineHeight > pageHeight-bottom {
|
|
if err := addPage(); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
pdf.SetX(marginX)
|
|
pdf.SetY(y)
|
|
if err := pdf.Cell(&gopdf.Rect{W: pageWidth - 2*marginX, H: lineHeight}, wrapped); err != nil {
|
|
return nil, err
|
|
}
|
|
y += lineHeight
|
|
}
|
|
y += 3
|
|
}
|
|
if err := writePDFFooter(pdf, pageNumber, pageWidth, pageHeight); err != nil {
|
|
return nil, err
|
|
}
|
|
var buffer bytes.Buffer
|
|
if err := pdf.Write(&buffer); err != nil {
|
|
return nil, err
|
|
}
|
|
return buffer.Bytes(), nil
|
|
}
|
|
|
|
func writePDFFooter(pdf *gopdf.GoPdf, pageNumber int, pageWidth, pageHeight float64) error {
|
|
if err := pdf.SetFont("NotoSansSC", "", 9); err != nil {
|
|
return err
|
|
}
|
|
pdf.SetTextColor(120, 120, 120)
|
|
pdf.SetX(0)
|
|
pdf.SetY(pageHeight - 34)
|
|
err := pdf.CellWithOption(&gopdf.Rect{W: pageWidth, H: 14}, fmt.Sprintf("第 %d 页", pageNumber), gopdf.CellOption{Align: gopdf.Center})
|
|
pdf.SetTextColor(30, 30, 30)
|
|
return err
|
|
}
|