230 lines
5 KiB
Go
230 lines
5 KiB
Go
|
|
package obsimport
|
||
|
|
|
||
|
|
import (
|
||
|
|
"archive/zip"
|
||
|
|
"bufio"
|
||
|
|
"context"
|
||
|
|
"fmt"
|
||
|
|
"io"
|
||
|
|
"log"
|
||
|
|
"mime"
|
||
|
|
"os"
|
||
|
|
"path/filepath"
|
||
|
|
"strings"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"lmika.dev/lmika/weiro/models"
|
||
|
|
"lmika.dev/lmika/weiro/providers/db"
|
||
|
|
"lmika.dev/lmika/weiro/providers/uploadfiles"
|
||
|
|
"lmika.dev/lmika/weiro/services/publisher"
|
||
|
|
)
|
||
|
|
|
||
|
|
type Service struct {
|
||
|
|
db *db.Provider
|
||
|
|
up *uploadfiles.Provider
|
||
|
|
publisher *publisher.Queue
|
||
|
|
scratchDir string
|
||
|
|
}
|
||
|
|
|
||
|
|
func New(db *db.Provider, up *uploadfiles.Provider, publisher *publisher.Queue, scratchDir string) *Service {
|
||
|
|
return &Service{
|
||
|
|
db: db,
|
||
|
|
up: up,
|
||
|
|
publisher: publisher,
|
||
|
|
scratchDir: scratchDir,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
type ImportResult struct {
|
||
|
|
PostsImported int
|
||
|
|
UploadsImported int
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Service) ImportZip(ctx context.Context, zipPath string) (ImportResult, error) {
|
||
|
|
site, ok := models.GetSite(ctx)
|
||
|
|
if !ok {
|
||
|
|
return ImportResult{}, models.SiteRequiredError
|
||
|
|
}
|
||
|
|
|
||
|
|
zr, err := zip.OpenReader(zipPath)
|
||
|
|
if err != nil {
|
||
|
|
return ImportResult{}, fmt.Errorf("open zip: %w", err)
|
||
|
|
}
|
||
|
|
defer zr.Close()
|
||
|
|
|
||
|
|
var result ImportResult
|
||
|
|
|
||
|
|
for _, f := range zr.File {
|
||
|
|
if f.FileInfo().IsDir() {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
|
||
|
|
ext := strings.ToLower(filepath.Ext(f.Name))
|
||
|
|
if ext == ".md" || ext == ".markdown" {
|
||
|
|
if err := s.importNote(ctx, site, f); err != nil {
|
||
|
|
log.Printf("warn: skipping note %s: %v", f.Name, err)
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
result.PostsImported++
|
||
|
|
} else if isAttachment(ext) {
|
||
|
|
if err := s.importAttachment(ctx, site, f); err != nil {
|
||
|
|
log.Printf("warn: skipping attachment %s: %v", f.Name, err)
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
result.UploadsImported++
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
s.publisher.Queue(site)
|
||
|
|
|
||
|
|
return result, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Service) importNote(ctx context.Context, site models.Site, f *zip.File) error {
|
||
|
|
rc, err := f.Open()
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
defer rc.Close()
|
||
|
|
|
||
|
|
data, err := io.ReadAll(rc)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
body := stripFrontMatter(string(data))
|
||
|
|
title := strings.TrimSuffix(filepath.Base(f.Name), filepath.Ext(f.Name))
|
||
|
|
publishedAt := f.Modified
|
||
|
|
if publishedAt.IsZero() {
|
||
|
|
publishedAt = time.Now()
|
||
|
|
}
|
||
|
|
|
||
|
|
renderTZ, err := time.LoadLocation(site.Timezone)
|
||
|
|
if err != nil {
|
||
|
|
renderTZ = time.UTC
|
||
|
|
}
|
||
|
|
publishedAt = publishedAt.In(renderTZ)
|
||
|
|
|
||
|
|
post := &models.Post{
|
||
|
|
SiteID: site.ID,
|
||
|
|
GUID: models.NewNanoID(),
|
||
|
|
State: models.StatePublished,
|
||
|
|
Title: title,
|
||
|
|
Body: body,
|
||
|
|
CreatedAt: time.Now(),
|
||
|
|
UpdatedAt: time.Now(),
|
||
|
|
PublishedAt: publishedAt,
|
||
|
|
}
|
||
|
|
post.Slug = post.BestSlug()
|
||
|
|
|
||
|
|
return s.db.SavePost(ctx, post)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Service) importAttachment(ctx context.Context, site models.Site, f *zip.File) error {
|
||
|
|
rc, err := f.Open()
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
defer rc.Close()
|
||
|
|
|
||
|
|
// Write to a temp file in scratch dir
|
||
|
|
if err := os.MkdirAll(s.scratchDir, 0755); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
tmpFile, err := os.CreateTemp(s.scratchDir, "obsimport-*"+filepath.Ext(f.Name))
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
tmpPath := tmpFile.Name()
|
||
|
|
|
||
|
|
if _, err := io.Copy(tmpFile, rc); err != nil {
|
||
|
|
tmpFile.Close()
|
||
|
|
os.Remove(tmpPath)
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
tmpFile.Close()
|
||
|
|
|
||
|
|
filename := filepath.Base(f.Name)
|
||
|
|
mimeType := mime.TypeByExtension(filepath.Ext(filename))
|
||
|
|
if mimeType == "" {
|
||
|
|
mimeType = "application/octet-stream"
|
||
|
|
}
|
||
|
|
|
||
|
|
stat, err := os.Stat(tmpPath)
|
||
|
|
if err != nil {
|
||
|
|
os.Remove(tmpPath)
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
newUploadGUID := models.NewNanoID()
|
||
|
|
newTime := time.Now().UTC()
|
||
|
|
newSlug := filepath.Join(
|
||
|
|
fmt.Sprintf("%04d", newTime.Year()),
|
||
|
|
fmt.Sprintf("%02d", newTime.Month()),
|
||
|
|
newUploadGUID+filepath.Ext(filename),
|
||
|
|
)
|
||
|
|
|
||
|
|
newUpload := models.Upload{
|
||
|
|
SiteID: site.ID,
|
||
|
|
GUID: models.NewNanoID(),
|
||
|
|
FileSize: stat.Size(),
|
||
|
|
MIMEType: mimeType,
|
||
|
|
Filename: filename,
|
||
|
|
CreatedAt: newTime,
|
||
|
|
Slug: newSlug,
|
||
|
|
}
|
||
|
|
if err := s.db.SaveUpload(ctx, &newUpload); err != nil {
|
||
|
|
os.Remove(tmpPath)
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
if err := s.up.AdoptFile(site, newUpload, tmpPath); err != nil {
|
||
|
|
os.Remove(tmpPath)
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// stripFrontMatter removes YAML front matter (delimited by ---) from markdown content.
|
||
|
|
func stripFrontMatter(content string) string {
|
||
|
|
scanner := bufio.NewScanner(strings.NewReader(content))
|
||
|
|
|
||
|
|
// Check if the first line is a front matter delimiter
|
||
|
|
if !scanner.Scan() {
|
||
|
|
return content
|
||
|
|
}
|
||
|
|
firstLine := strings.TrimSpace(scanner.Text())
|
||
|
|
if firstLine != "---" {
|
||
|
|
return content
|
||
|
|
}
|
||
|
|
|
||
|
|
// Skip until the closing ---
|
||
|
|
for scanner.Scan() {
|
||
|
|
if strings.TrimSpace(scanner.Text()) == "---" {
|
||
|
|
// Return everything after the closing delimiter
|
||
|
|
var rest strings.Builder
|
||
|
|
for scanner.Scan() {
|
||
|
|
rest.WriteString(scanner.Text())
|
||
|
|
rest.WriteString("\n")
|
||
|
|
}
|
||
|
|
return strings.TrimLeft(rest.String(), "\n")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// No closing delimiter found, return original content
|
||
|
|
return content
|
||
|
|
}
|
||
|
|
|
||
|
|
var attachmentExts = map[string]bool{
|
||
|
|
".png": true, ".jpg": true, ".jpeg": true, ".gif": true, ".svg": true, ".webp": true,
|
||
|
|
".bmp": true, ".ico": true, ".tiff": true, ".tif": true,
|
||
|
|
".mp3": true, ".mp4": true, ".wav": true, ".ogg": true, ".webm": true,
|
||
|
|
".pdf": true, ".doc": true, ".docx": true, ".xls": true, ".xlsx": true,
|
||
|
|
}
|
||
|
|
|
||
|
|
func isAttachment(ext string) bool {
|
||
|
|
return attachmentExts[ext]
|
||
|
|
}
|