Compare commits
No commits in common. "main" and "feature/image-edit" have entirely different histories.
main
...
feature/im
|
|
@ -60,16 +60,6 @@ export default class PosteditController extends Controller {
|
||||||
try {
|
try {
|
||||||
const formData = new FormData(this.element);
|
const formData = new FormData(this.element);
|
||||||
let data = Object.fromEntries(formData.entries());
|
let data = Object.fromEntries(formData.entries());
|
||||||
|
|
||||||
// Special handling for categories
|
|
||||||
let categoryIDs = [];
|
|
||||||
for (let i of formData.entries()) {
|
|
||||||
if (i[0] === "category_ids") {
|
|
||||||
categoryIDs.push(parseInt(i[1]))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data["category_ids"] = categoryIDs;
|
|
||||||
data = {...data, action: action || 'save'};
|
data = {...data, action: action || 'save'};
|
||||||
|
|
||||||
const response = await fetch(this.element.getAttribute("action"), {
|
const response = await fetch(this.element.getAttribute("action"), {
|
||||||
|
|
|
||||||
|
|
@ -115,7 +115,6 @@ Starting weiro without any arguments will start the server.
|
||||||
ssh := handlers.SiteSettingsHandler{SiteService: svcs.Sites}
|
ssh := handlers.SiteSettingsHandler{SiteService: svcs.Sites}
|
||||||
ch := handlers.CategoriesHandler{CategoryService: svcs.Categories}
|
ch := handlers.CategoriesHandler{CategoryService: svcs.Categories}
|
||||||
pgh := handlers.PagesHandler{PageService: svcs.Pages}
|
pgh := handlers.PagesHandler{PageService: svcs.Pages}
|
||||||
oih := handlers.ObsImportHandler{ObsImportService: svcs.ObsImport, ScratchDir: cfg.ScratchDir}
|
|
||||||
|
|
||||||
app.Get("/login", lh.Login)
|
app.Get("/login", lh.Login)
|
||||||
app.Post("/login", lh.DoLogin)
|
app.Post("/login", lh.DoLogin)
|
||||||
|
|
@ -163,9 +162,6 @@ Starting weiro without any arguments will start the server.
|
||||||
siteGroup.Get("/settings", ssh.General)
|
siteGroup.Get("/settings", ssh.General)
|
||||||
siteGroup.Post("/settings", ssh.UpdateGeneral)
|
siteGroup.Post("/settings", ssh.UpdateGeneral)
|
||||||
|
|
||||||
siteGroup.Get("/import/obsidian", oih.Form)
|
|
||||||
siteGroup.Post("/import/obsidian", oih.Upload)
|
|
||||||
|
|
||||||
siteGroup.Get("/categories", ch.Index)
|
siteGroup.Get("/categories", ch.Index)
|
||||||
siteGroup.Get("/categories/new", ch.New)
|
siteGroup.Get("/categories/new", ch.New)
|
||||||
siteGroup.Get("/categories/:categoryID", ch.Edit)
|
siteGroup.Get("/categories/:categoryID", ch.Edit)
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ func (lh *LoginHandler) DoLogin(c fiber.Ctx) error {
|
||||||
var req struct {
|
var req struct {
|
||||||
Username string `form:"username"`
|
Username string `form:"username"`
|
||||||
Password string `form:"password"`
|
Password string `form:"password"`
|
||||||
|
LoginChallenge string `form:"_login_challenge"`
|
||||||
}
|
}
|
||||||
if err := c.Bind().Body(&req); err != nil {
|
if err := c.Bind().Body(&req); err != nil {
|
||||||
return c.Status(fiber.StatusBadRequest).SendString("Failed to parse request body")
|
return c.Status(fiber.StatusBadRequest).SendString("Failed to parse request body")
|
||||||
|
|
@ -50,6 +51,11 @@ func (lh *LoginHandler) DoLogin(c fiber.Ctx) error {
|
||||||
|
|
||||||
sess := session.FromContext(c)
|
sess := session.FromContext(c)
|
||||||
|
|
||||||
|
challenge, _ := sess.Get("_login_challenge").(string)
|
||||||
|
if challenge != req.LoginChallenge {
|
||||||
|
return c.Redirect().To("/login")
|
||||||
|
}
|
||||||
|
|
||||||
user, err := lh.AuthService.Login(c.Context(), req.Username, req.Password)
|
user, err := lh.AuthService.Login(c.Context(), req.Username, req.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Status(fiber.StatusInternalServerError).SendString("Failed to login")
|
return c.Status(fiber.StatusInternalServerError).SendString("Failed to login")
|
||||||
|
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v3"
|
|
||||||
"lmika.dev/lmika/weiro/models"
|
|
||||||
"lmika.dev/lmika/weiro/services/obsimport"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ObsImportHandler struct {
|
|
||||||
ObsImportService *obsimport.Service
|
|
||||||
ScratchDir string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h ObsImportHandler) Form(c fiber.Ctx) error {
|
|
||||||
return c.Render("obsimport/form", fiber.Map{})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h ObsImportHandler) Upload(c fiber.Ctx) error {
|
|
||||||
site := c.Locals("site").(models.Site)
|
|
||||||
|
|
||||||
fileHeader, err := c.FormFile("zipfile")
|
|
||||||
if err != nil {
|
|
||||||
return fiber.NewError(fiber.StatusBadRequest, "no file provided")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save uploaded file to scratch dir
|
|
||||||
if err := os.MkdirAll(h.ScratchDir, 0755); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
dstPath := filepath.Join(h.ScratchDir, models.NewNanoID()+".zip")
|
|
||||||
if err := c.SaveFile(fileHeader, dstPath); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer os.Remove(dstPath)
|
|
||||||
|
|
||||||
result, err := h.ObsImportService.ImportZip(c.Context(), dstPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.Render("obsimport/result", fiber.Map{
|
|
||||||
"result": result,
|
|
||||||
"siteURL": fmt.Sprintf("/sites/%v/posts", site.ID),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
@ -82,8 +82,8 @@ func (db *Provider) SelectCategoriesOfPost(ctx context.Context, postID int64) ([
|
||||||
return cats, nil
|
return cats, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db *Provider) SelectPublishedPostsOfCategory(ctx context.Context, categoryID int64, pp PagingParams) ([]*models.Post, error) {
|
func (db *Provider) SelectPostsOfCategory(ctx context.Context, categoryID int64, pp PagingParams) ([]*models.Post, error) {
|
||||||
rows, err := db.queries.SelectPublishedPostsOfCategory(ctx, sqlgen.SelectPublishedPostsOfCategoryParams{
|
rows, err := db.queries.SelectPostsOfCategory(ctx, sqlgen.SelectPostsOfCategoryParams{
|
||||||
CategoryID: categoryID,
|
CategoryID: categoryID,
|
||||||
Limit: pp.Limit,
|
Limit: pp.Limit,
|
||||||
Offset: pp.Offset,
|
Offset: pp.Offset,
|
||||||
|
|
|
||||||
|
|
@ -227,7 +227,7 @@ func (q *Queries) SelectCategoryBySlugAndSite(ctx context.Context, arg SelectCat
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectPublishedPostsOfCategory = `-- name: SelectPublishedPostsOfCategory :many
|
const selectPostsOfCategory = `-- name: SelectPostsOfCategory :many
|
||||||
SELECT p.id, p.site_id, p.state, p.guid, p.title, p.body, p.slug, p.created_at, p.updated_at, p.published_at, p.deleted_at FROM posts p
|
SELECT p.id, p.site_id, p.state, p.guid, p.title, p.body, p.slug, p.created_at, p.updated_at, p.published_at, p.deleted_at FROM posts p
|
||||||
INNER JOIN post_categories pc ON pc.post_id = p.id
|
INNER JOIN post_categories pc ON pc.post_id = p.id
|
||||||
WHERE pc.category_id = ? AND p.state = 0 AND p.deleted_at = 0
|
WHERE pc.category_id = ? AND p.state = 0 AND p.deleted_at = 0
|
||||||
|
|
@ -235,14 +235,14 @@ ORDER BY p.published_at DESC
|
||||||
LIMIT ? OFFSET ?
|
LIMIT ? OFFSET ?
|
||||||
`
|
`
|
||||||
|
|
||||||
type SelectPublishedPostsOfCategoryParams struct {
|
type SelectPostsOfCategoryParams struct {
|
||||||
CategoryID int64
|
CategoryID int64
|
||||||
Limit int64
|
Limit int64
|
||||||
Offset int64
|
Offset int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) SelectPublishedPostsOfCategory(ctx context.Context, arg SelectPublishedPostsOfCategoryParams) ([]Post, error) {
|
func (q *Queries) SelectPostsOfCategory(ctx context.Context, arg SelectPostsOfCategoryParams) ([]Post, error) {
|
||||||
rows, err := q.db.QueryContext(ctx, selectPublishedPostsOfCategory, arg.CategoryID, arg.Limit, arg.Offset)
|
rows, err := q.db.QueryContext(ctx, selectPostsOfCategory, arg.CategoryID, arg.Limit, arg.Offset)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -200,54 +200,6 @@ func (q *Queries) SelectPostsOfSite(ctx context.Context, arg SelectPostsOfSitePa
|
||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectPublishedPostsOfSite = `-- name: SelectPublishedPostsOfSite :many
|
|
||||||
SELECT id, site_id, state, guid, title, body, slug, created_at, updated_at, published_at, deleted_at
|
|
||||||
FROM posts
|
|
||||||
WHERE site_id = ?1 AND state = 0 AND deleted_at = 0
|
|
||||||
ORDER BY published_at DESC LIMIT ?3 OFFSET ?2
|
|
||||||
`
|
|
||||||
|
|
||||||
type SelectPublishedPostsOfSiteParams struct {
|
|
||||||
SiteID int64
|
|
||||||
Offset int64
|
|
||||||
Limit int64
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *Queries) SelectPublishedPostsOfSite(ctx context.Context, arg SelectPublishedPostsOfSiteParams) ([]Post, error) {
|
|
||||||
rows, err := q.db.QueryContext(ctx, selectPublishedPostsOfSite, arg.SiteID, arg.Offset, arg.Limit)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
var items []Post
|
|
||||||
for rows.Next() {
|
|
||||||
var i Post
|
|
||||||
if err := rows.Scan(
|
|
||||||
&i.ID,
|
|
||||||
&i.SiteID,
|
|
||||||
&i.State,
|
|
||||||
&i.Guid,
|
|
||||||
&i.Title,
|
|
||||||
&i.Body,
|
|
||||||
&i.Slug,
|
|
||||||
&i.CreatedAt,
|
|
||||||
&i.UpdatedAt,
|
|
||||||
&i.PublishedAt,
|
|
||||||
&i.DeletedAt,
|
|
||||||
); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
items = append(items, i)
|
|
||||||
}
|
|
||||||
if err := rows.Close(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return items, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
const softDeletePost = `-- name: SoftDeletePost :exec
|
const softDeletePost = `-- name: SoftDeletePost :exec
|
||||||
UPDATE posts SET deleted_at = ? WHERE id = ?
|
UPDATE posts SET deleted_at = ? WHERE id = ?
|
||||||
`
|
`
|
||||||
|
|
|
||||||
|
|
@ -47,23 +47,6 @@ func (db *Provider) SelectPostsOfSite(ctx context.Context, siteID int64, showDel
|
||||||
return posts, nil
|
return posts, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db *Provider) SelectPublishedPostsOfSite(ctx context.Context, siteID int64, pp PagingParams) ([]*models.Post, error) {
|
|
||||||
rows, err := db.queries.SelectPublishedPostsOfSite(ctx, sqlgen.SelectPublishedPostsOfSiteParams{
|
|
||||||
SiteID: siteID,
|
|
||||||
Limit: pp.Limit,
|
|
||||||
Offset: pp.Offset,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
posts := make([]*models.Post, len(rows))
|
|
||||||
for i, row := range rows {
|
|
||||||
posts[i] = dbPostToPost(row)
|
|
||||||
}
|
|
||||||
return posts, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (db *Provider) SelectPost(ctx context.Context, postID int64) (*models.Post, error) {
|
func (db *Provider) SelectPost(ctx context.Context, postID int64) (*models.Post, error) {
|
||||||
row, err := db.queries.SelectPost(ctx, postID)
|
row, err := db.queries.SelectPost(ctx, postID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ type Renderer struct {
|
||||||
|
|
||||||
func NewRendererForUI() *Renderer {
|
func NewRendererForUI() *Renderer {
|
||||||
mdParser := goldmark.New(
|
mdParser := goldmark.New(
|
||||||
goldmark.WithExtensions(extension.GFM, extension.Footnote),
|
goldmark.WithExtensions(extension.GFM),
|
||||||
goldmark.WithRendererOptions(
|
goldmark.WithRendererOptions(
|
||||||
gm_html.WithUnsafe(),
|
gm_html.WithUnsafe(),
|
||||||
),
|
),
|
||||||
|
|
@ -48,7 +48,7 @@ func NewRendererForUI() *Renderer {
|
||||||
|
|
||||||
func NewRendererForSite() *Renderer {
|
func NewRendererForSite() *Renderer {
|
||||||
mdParser := goldmark.New(
|
mdParser := goldmark.New(
|
||||||
goldmark.WithExtensions(extension.GFM, extension.Footnote),
|
goldmark.WithExtensions(extension.GFM),
|
||||||
goldmark.WithParserOptions(
|
goldmark.WithParserOptions(
|
||||||
parser.WithAutoHeadingID(),
|
parser.WithAutoHeadingID(),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,6 @@ func New(site pubmodel.Site, opts Options) (*Builder, error) {
|
||||||
mdRenderer: markdown.NewRendererForSite(),
|
mdRenderer: markdown.NewRendererForSite(),
|
||||||
postMDProcessors: []postMDProcessor{
|
postMDProcessors: []postMDProcessor{
|
||||||
uploadAbsoluteURL,
|
uploadAbsoluteURL,
|
||||||
removeFootnoteHRs,
|
|
||||||
},
|
},
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,8 +35,3 @@ func uploadAbsoluteURL(site pubmodel.Site, dom *goquery.Document) error {
|
||||||
})
|
})
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func removeFootnoteHRs(site pubmodel.Site, dom *goquery.Document) error {
|
|
||||||
dom.Find("div.footnotes > hr").Remove()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,229 +0,0 @@
|
||||||
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]
|
|
||||||
}
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
package obsimport
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
func TestStripFrontMatter(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
input string
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "no front matter",
|
|
||||||
input: "Hello world\nThis is a note",
|
|
||||||
want: "Hello world\nThis is a note",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "with front matter",
|
|
||||||
input: "---\ntitle: Test\ntags: [a, b]\n---\nHello world\nThis is a note\n",
|
|
||||||
want: "Hello world\nThis is a note\n",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "only front matter",
|
|
||||||
input: "---\ntitle: Test\n---\n",
|
|
||||||
want: "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "unclosed front matter",
|
|
||||||
input: "---\ntitle: Test\nno closing delimiter",
|
|
||||||
want: "---\ntitle: Test\nno closing delimiter",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "empty string",
|
|
||||||
input: "",
|
|
||||||
want: "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "front matter with leading newlines stripped",
|
|
||||||
input: "---\nkey: val\n---\n\n\nBody here\n",
|
|
||||||
want: "Body here\n",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
got := stripFrontMatter(tt.input)
|
|
||||||
if got != tt.want {
|
|
||||||
t.Errorf("stripFrontMatter() = %q, want %q", got, tt.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -9,10 +9,10 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
// postIter returns a post iterator which returns posts in reverse chronological order.
|
// postIter returns a post iterator which returns posts in reverse chronological order.
|
||||||
func (s *Publisher) publishedPostIter(ctx context.Context, site int64) iter.Seq[models.Maybe[*models.Post]] {
|
func (s *Publisher) postIter(ctx context.Context, site int64) iter.Seq[models.Maybe[*models.Post]] {
|
||||||
return func(yield func(models.Maybe[*models.Post]) bool) {
|
return func(yield func(models.Maybe[*models.Post]) bool) {
|
||||||
paging := db.PagingParams{Offset: 0, Limit: 50}
|
paging := db.PagingParams{Offset: 0, Limit: 50}
|
||||||
page, err := s.db.SelectPublishedPostsOfSite(ctx, site, paging)
|
page, err := s.db.SelectPostsOfSite(ctx, site, false, paging)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
yield(models.Maybe[*models.Post]{Err: err})
|
yield(models.Maybe[*models.Post]{Err: err})
|
||||||
return
|
return
|
||||||
|
|
@ -45,7 +45,7 @@ func (s *Publisher) postIterByCategory(ctx context.Context, categoryID int64) it
|
||||||
return func(yield func(models.Maybe[*models.Post]) bool) {
|
return func(yield func(models.Maybe[*models.Post]) bool) {
|
||||||
paging := db.PagingParams{Offset: 0, Limit: 50}
|
paging := db.PagingParams{Offset: 0, Limit: 50}
|
||||||
for {
|
for {
|
||||||
page, err := s.db.SelectPublishedPostsOfCategory(ctx, categoryID, paging)
|
page, err := s.db.SelectPostsOfCategory(ctx, categoryID, paging)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
yield(models.Maybe[*models.Post]{Err: err})
|
yield(models.Maybe[*models.Post]{Err: err})
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,7 @@ func (p *Publisher) Publish(ctx context.Context, site models.Site) error {
|
||||||
pubSite := pubmodel.Site{
|
pubSite := pubmodel.Site{
|
||||||
Site: site,
|
Site: site,
|
||||||
PostIter: func(ctx context.Context) iter.Seq[models.Maybe[*models.Post]] {
|
PostIter: func(ctx context.Context) iter.Seq[models.Maybe[*models.Post]] {
|
||||||
return p.publishedPostIter(ctx, site.ID)
|
return p.postIter(ctx, site.ID)
|
||||||
},
|
},
|
||||||
BaseURL: target.BaseURL,
|
BaseURL: target.BaseURL,
|
||||||
Uploads: uploads,
|
Uploads: uploads,
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ import (
|
||||||
"lmika.dev/lmika/weiro/services/auth"
|
"lmika.dev/lmika/weiro/services/auth"
|
||||||
"lmika.dev/lmika/weiro/services/categories"
|
"lmika.dev/lmika/weiro/services/categories"
|
||||||
"lmika.dev/lmika/weiro/services/imgedit"
|
"lmika.dev/lmika/weiro/services/imgedit"
|
||||||
"lmika.dev/lmika/weiro/services/obsimport"
|
|
||||||
"lmika.dev/lmika/weiro/services/pages"
|
"lmika.dev/lmika/weiro/services/pages"
|
||||||
"lmika.dev/lmika/weiro/services/posts"
|
"lmika.dev/lmika/weiro/services/posts"
|
||||||
"lmika.dev/lmika/weiro/services/publisher"
|
"lmika.dev/lmika/weiro/services/publisher"
|
||||||
|
|
@ -28,7 +27,6 @@ type Services struct {
|
||||||
ImageEdit *imgedit.Service
|
ImageEdit *imgedit.Service
|
||||||
Categories *categories.Service
|
Categories *categories.Service
|
||||||
Pages *pages.Service
|
Pages *pages.Service
|
||||||
ObsImport *obsimport.Service
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(cfg config.Config) (*Services, error) {
|
func New(cfg config.Config) (*Services, error) {
|
||||||
|
|
@ -48,7 +46,6 @@ func New(cfg config.Config) (*Services, error) {
|
||||||
imageEditService := imgedit.New(uploadService, filepath.Join(cfg.ScratchDir, "imageedit"))
|
imageEditService := imgedit.New(uploadService, filepath.Join(cfg.ScratchDir, "imageedit"))
|
||||||
categoriesService := categories.New(dbp, publisherQueue)
|
categoriesService := categories.New(dbp, publisherQueue)
|
||||||
pagesService := pages.New(dbp, publisherQueue)
|
pagesService := pages.New(dbp, publisherQueue)
|
||||||
obsImportService := obsimport.New(dbp, ufp, publisherQueue, filepath.Join(cfg.ScratchDir, "obsimport"))
|
|
||||||
|
|
||||||
return &Services{
|
return &Services{
|
||||||
DB: dbp,
|
DB: dbp,
|
||||||
|
|
@ -61,7 +58,6 @@ func New(cfg config.Config) (*Services, error) {
|
||||||
ImageEdit: imageEditService,
|
ImageEdit: imageEditService,
|
||||||
Categories: categoriesService,
|
Categories: categoriesService,
|
||||||
Pages: pagesService,
|
Pages: pagesService,
|
||||||
ObsImport: obsImportService,
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ INNER JOIN post_categories pc ON pc.category_id = c.id
|
||||||
WHERE pc.post_id = ?
|
WHERE pc.post_id = ?
|
||||||
ORDER BY c.name ASC;
|
ORDER BY c.name ASC;
|
||||||
|
|
||||||
-- name: SelectPublishedPostsOfCategory :many
|
-- name: SelectPostsOfCategory :many
|
||||||
SELECT p.* FROM posts p
|
SELECT p.* FROM posts p
|
||||||
INNER JOIN post_categories pc ON pc.post_id = p.id
|
INNER JOIN post_categories pc ON pc.post_id = p.id
|
||||||
WHERE pc.category_id = ? AND p.state = 0 AND p.deleted_at = 0
|
WHERE pc.category_id = ? AND p.state = 0 AND p.deleted_at = 0
|
||||||
|
|
|
||||||
|
|
@ -17,12 +17,6 @@ WHERE site_id = sqlc.arg(site_id) AND (
|
||||||
END
|
END
|
||||||
) ORDER BY created_at DESC LIMIT sqlc.arg(limit) OFFSET sqlc.arg(offset);
|
) ORDER BY created_at DESC LIMIT sqlc.arg(limit) OFFSET sqlc.arg(offset);
|
||||||
|
|
||||||
-- name: SelectPublishedPostsOfSite :many
|
|
||||||
SELECT *
|
|
||||||
FROM posts
|
|
||||||
WHERE site_id = sqlc.arg(site_id) AND state = 0 AND deleted_at = 0
|
|
||||||
ORDER BY published_at DESC LIMIT sqlc.arg(limit) OFFSET sqlc.arg(offset);
|
|
||||||
|
|
||||||
-- name: SelectPost :one
|
-- name: SelectPost :one
|
||||||
SELECT * FROM posts WHERE id = ? LIMIT 1;
|
SELECT * FROM posts WHERE id = ? LIMIT 1;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
<main class="container">
|
|
||||||
<div>
|
|
||||||
<h5 class="my-4">Import from Obsidian</h5>
|
|
||||||
<p>Select an Obsidian vault exported as a Zip file. All Markdown notes will be imported as posts, and any images or attachments will be imported as uploads.</p>
|
|
||||||
<form method="post" action="/sites/{{ .site.ID }}/import/obsidian" enctype="multipart/form-data">
|
|
||||||
<div class="row mb-3">
|
|
||||||
<label for="zipfile" class="col-sm-3 col-form-label text-end">Zip File</label>
|
|
||||||
<div class="col-sm-6">
|
|
||||||
<input type="file" class="form-control" id="zipfile" name="zipfile" accept=".zip">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row mb-3">
|
|
||||||
<div class="col-sm-3"></div>
|
|
||||||
<div class="col-sm-9">
|
|
||||||
<button type="submit" class="btn btn-primary">Import</button>
|
|
||||||
<a href="/sites/{{ .site.ID }}/settings" class="btn btn-secondary ms-2">Cancel</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
<main class="container">
|
|
||||||
<div>
|
|
||||||
<h5 class="my-4">Import Complete</h5>
|
|
||||||
<div class="alert alert-success">
|
|
||||||
<p class="mb-1">Successfully imported <strong>{{ .result.PostsImported }}</strong> post(s) and <strong>{{ .result.UploadsImported }}</strong> upload(s).</p>
|
|
||||||
</div>
|
|
||||||
<a href="{{ .siteURL }}" class="btn btn-primary">Go to Posts</a>
|
|
||||||
<a href="/sites/{{ .site.ID }}/settings" class="btn btn-secondary ms-2">Back to Settings</a>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
@ -66,12 +66,5 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<div class="row mb-3">
|
|
||||||
<div class="col-sm-3"></div>
|
|
||||||
<div class="col-sm-9">
|
|
||||||
<a href="/sites/{{ .site.ID }}/import/obsidian" class="btn btn-secondary">Import Obsidian</a>
|
|
||||||
<span class="form-text mx-3">Import posts and attachments from an Obsidian vault zip file.</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
Loading…
Reference in a new issue