From 023574aac6a039e65329b52a283c002b426c2549 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sun, 29 Mar 2026 09:33:24 +1100 Subject: [PATCH 1/5] Removed the login challenge --- handlers/login.go | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/handlers/login.go b/handlers/login.go index 30ed0b4..34c1e96 100644 --- a/handlers/login.go +++ b/handlers/login.go @@ -37,9 +37,8 @@ func (lh *LoginHandler) Logout(c fiber.Ctx) error { func (lh *LoginHandler) DoLogin(c fiber.Ctx) error { var req struct { - Username string `form:"username"` - Password string `form:"password"` - LoginChallenge string `form:"_login_challenge"` + Username string `form:"username"` + Password string `form:"password"` } if err := c.Bind().Body(&req); err != nil { return c.Status(fiber.StatusBadRequest).SendString("Failed to parse request body") @@ -51,11 +50,6 @@ func (lh *LoginHandler) DoLogin(c fiber.Ctx) error { 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) if err != nil { return c.Status(fiber.StatusInternalServerError).SendString("Failed to login") From 9b20665d11b039d07f5142c393610e44cd25079b Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sun, 29 Mar 2026 10:45:45 +1100 Subject: [PATCH 2/5] Added support for footnotes and fixed category AJAX post --- assets/js/controllers/postedit.js | 10 ++++++++++ providers/markdown/renderer.go | 4 ++-- providers/sitebuilder/builder.go | 1 + providers/sitebuilder/processors.go | 5 +++++ 4 files changed, 18 insertions(+), 2 deletions(-) diff --git a/assets/js/controllers/postedit.js b/assets/js/controllers/postedit.js index 71328e3..f800c44 100644 --- a/assets/js/controllers/postedit.js +++ b/assets/js/controllers/postedit.js @@ -60,6 +60,16 @@ export default class PosteditController extends Controller { try { const formData = new FormData(this.element); 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'}; const response = await fetch(this.element.getAttribute("action"), { diff --git a/providers/markdown/renderer.go b/providers/markdown/renderer.go index aedd184..828ba96 100644 --- a/providers/markdown/renderer.go +++ b/providers/markdown/renderer.go @@ -22,7 +22,7 @@ type Renderer struct { func NewRendererForUI() *Renderer { mdParser := goldmark.New( - goldmark.WithExtensions(extension.GFM), + goldmark.WithExtensions(extension.GFM, extension.Footnote), goldmark.WithRendererOptions( gm_html.WithUnsafe(), ), @@ -48,7 +48,7 @@ func NewRendererForUI() *Renderer { func NewRendererForSite() *Renderer { mdParser := goldmark.New( - goldmark.WithExtensions(extension.GFM), + goldmark.WithExtensions(extension.GFM, extension.Footnote), goldmark.WithParserOptions( parser.WithAutoHeadingID(), ), diff --git a/providers/sitebuilder/builder.go b/providers/sitebuilder/builder.go index 71ce926..d0bf17b 100644 --- a/providers/sitebuilder/builder.go +++ b/providers/sitebuilder/builder.go @@ -49,6 +49,7 @@ func New(site pubmodel.Site, opts Options) (*Builder, error) { mdRenderer: markdown.NewRendererForSite(), postMDProcessors: []postMDProcessor{ uploadAbsoluteURL, + removeFootnoteHRs, }, }, nil } diff --git a/providers/sitebuilder/processors.go b/providers/sitebuilder/processors.go index c699160..605d077 100644 --- a/providers/sitebuilder/processors.go +++ b/providers/sitebuilder/processors.go @@ -35,3 +35,8 @@ func uploadAbsoluteURL(site pubmodel.Site, dom *goquery.Document) error { }) return nil } + +func removeFootnoteHRs(site pubmodel.Site, dom *goquery.Document) error { + dom.Find("div.footnotes > hr").Remove() + return nil +} From deca23b5995a3540b5d2bc489197641c2153c2b3 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sun, 29 Mar 2026 12:26:05 +1100 Subject: [PATCH 3/5] Fixed ordering of published posts --- providers/db/categories.go | 2 +- providers/db/gen/sqlgen/categories.sql.go | 8 ++-- providers/db/gen/sqlgen/posts.sql.go | 48 +++++++++++++++++++++++ providers/db/posts.go | 17 ++++++++ services/publisher/iter.go | 6 +-- services/publisher/service.go | 2 +- sql/queries/categories.sql | 2 +- sql/queries/posts.sql | 6 +++ 8 files changed, 81 insertions(+), 10 deletions(-) diff --git a/providers/db/categories.go b/providers/db/categories.go index 72fac94..f8db6d3 100644 --- a/providers/db/categories.go +++ b/providers/db/categories.go @@ -82,7 +82,7 @@ func (db *Provider) SelectCategoriesOfPost(ctx context.Context, postID int64) ([ return cats, nil } -func (db *Provider) SelectPostsOfCategory(ctx context.Context, categoryID int64, pp PagingParams) ([]*models.Post, error) { +func (db *Provider) SelectPublishedPostsOfCategory(ctx context.Context, categoryID int64, pp PagingParams) ([]*models.Post, error) { rows, err := db.queries.SelectPostsOfCategory(ctx, sqlgen.SelectPostsOfCategoryParams{ CategoryID: categoryID, Limit: pp.Limit, diff --git a/providers/db/gen/sqlgen/categories.sql.go b/providers/db/gen/sqlgen/categories.sql.go index 95a26e5..f6a291f 100644 --- a/providers/db/gen/sqlgen/categories.sql.go +++ b/providers/db/gen/sqlgen/categories.sql.go @@ -227,7 +227,7 @@ func (q *Queries) SelectCategoryBySlugAndSite(ctx context.Context, arg SelectCat return i, err } -const selectPostsOfCategory = `-- name: SelectPostsOfCategory :many +const selectPublishedPostsOfCategory = `-- name: SelectPublishedPostsOfCategory :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 INNER JOIN post_categories pc ON pc.post_id = p.id 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 ? ` -type SelectPostsOfCategoryParams struct { +type SelectPublishedPostsOfCategoryParams struct { CategoryID int64 Limit int64 Offset int64 } -func (q *Queries) SelectPostsOfCategory(ctx context.Context, arg SelectPostsOfCategoryParams) ([]Post, error) { - rows, err := q.db.QueryContext(ctx, selectPostsOfCategory, arg.CategoryID, arg.Limit, arg.Offset) +func (q *Queries) SelectPublishedPostsOfCategory(ctx context.Context, arg SelectPublishedPostsOfCategoryParams) ([]Post, error) { + rows, err := q.db.QueryContext(ctx, selectPublishedPostsOfCategory, arg.CategoryID, arg.Limit, arg.Offset) if err != nil { return nil, err } diff --git a/providers/db/gen/sqlgen/posts.sql.go b/providers/db/gen/sqlgen/posts.sql.go index 129a49a..b1d3afb 100644 --- a/providers/db/gen/sqlgen/posts.sql.go +++ b/providers/db/gen/sqlgen/posts.sql.go @@ -200,6 +200,54 @@ func (q *Queries) SelectPostsOfSite(ctx context.Context, arg SelectPostsOfSitePa 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 UPDATE posts SET deleted_at = ? WHERE id = ? ` diff --git a/providers/db/posts.go b/providers/db/posts.go index 7f58d1a..3b86aaf 100644 --- a/providers/db/posts.go +++ b/providers/db/posts.go @@ -47,6 +47,23 @@ func (db *Provider) SelectPostsOfSite(ctx context.Context, siteID int64, showDel 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) { row, err := db.queries.SelectPost(ctx, postID) if err != nil { diff --git a/services/publisher/iter.go b/services/publisher/iter.go index ea70616..d07d4fe 100644 --- a/services/publisher/iter.go +++ b/services/publisher/iter.go @@ -9,10 +9,10 @@ import ( ) // postIter returns a post iterator which returns posts in reverse chronological order. -func (s *Publisher) postIter(ctx context.Context, site int64) iter.Seq[models.Maybe[*models.Post]] { +func (s *Publisher) publishedPostIter(ctx context.Context, site int64) iter.Seq[models.Maybe[*models.Post]] { return func(yield func(models.Maybe[*models.Post]) bool) { paging := db.PagingParams{Offset: 0, Limit: 50} - page, err := s.db.SelectPostsOfSite(ctx, site, false, paging) + page, err := s.db.SelectPublishedPostsOfSite(ctx, site, paging) if err != nil { yield(models.Maybe[*models.Post]{Err: err}) return @@ -45,7 +45,7 @@ func (s *Publisher) postIterByCategory(ctx context.Context, categoryID int64) it return func(yield func(models.Maybe[*models.Post]) bool) { paging := db.PagingParams{Offset: 0, Limit: 50} for { - page, err := s.db.SelectPostsOfCategory(ctx, categoryID, paging) + page, err := s.db.SelectPublishedPostsOfCategory(ctx, categoryID, paging) if err != nil { yield(models.Maybe[*models.Post]{Err: err}) return diff --git a/services/publisher/service.go b/services/publisher/service.go index adfcdd7..a5072a5 100644 --- a/services/publisher/service.go +++ b/services/publisher/service.go @@ -79,7 +79,7 @@ func (p *Publisher) Publish(ctx context.Context, site models.Site) error { pubSite := pubmodel.Site{ Site: site, PostIter: func(ctx context.Context) iter.Seq[models.Maybe[*models.Post]] { - return p.postIter(ctx, site.ID) + return p.publishedPostIter(ctx, site.ID) }, BaseURL: target.BaseURL, Uploads: uploads, diff --git a/sql/queries/categories.sql b/sql/queries/categories.sql index 4b48506..b8e0e64 100644 --- a/sql/queries/categories.sql +++ b/sql/queries/categories.sql @@ -17,7 +17,7 @@ INNER JOIN post_categories pc ON pc.category_id = c.id WHERE pc.post_id = ? ORDER BY c.name ASC; --- name: SelectPostsOfCategory :many +-- name: SelectPublishedPostsOfCategory :many SELECT p.* FROM posts p INNER JOIN post_categories pc ON pc.post_id = p.id WHERE pc.category_id = ? AND p.state = 0 AND p.deleted_at = 0 diff --git a/sql/queries/posts.sql b/sql/queries/posts.sql index 5a4c18e..feaae7f 100644 --- a/sql/queries/posts.sql +++ b/sql/queries/posts.sql @@ -17,6 +17,12 @@ WHERE site_id = sqlc.arg(site_id) AND ( END ) 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 SELECT * FROM posts WHERE id = ? LIMIT 1; From d21aeadd5655adc431f5effa780e46909c28cd40 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sun, 29 Mar 2026 20:29:42 +1100 Subject: [PATCH 4/5] Fixed build --- providers/db/categories.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/providers/db/categories.go b/providers/db/categories.go index f8db6d3..23a9e67 100644 --- a/providers/db/categories.go +++ b/providers/db/categories.go @@ -83,7 +83,7 @@ func (db *Provider) SelectCategoriesOfPost(ctx context.Context, postID int64) ([ } func (db *Provider) SelectPublishedPostsOfCategory(ctx context.Context, categoryID int64, pp PagingParams) ([]*models.Post, error) { - rows, err := db.queries.SelectPostsOfCategory(ctx, sqlgen.SelectPostsOfCategoryParams{ + rows, err := db.queries.SelectPublishedPostsOfCategory(ctx, sqlgen.SelectPublishedPostsOfCategoryParams{ CategoryID: categoryID, Limit: pp.Limit, Offset: pp.Offset, From a3197f9b11f4802d7f6fc5e34223d4a0678cea3d Mon Sep 17 00:00:00 2001 From: lmika Date: Thu, 9 Apr 2026 11:40:52 +0000 Subject: [PATCH 5/5] Add Obsidian vault import feature (#8) - New 'Import Obsidian' action on site settings page - Upload a zip file of an Obsidian vault to import all notes as posts - Markdown notes imported with title from filename, published date from file timestamp, and body with front-matter stripped - Images and other attachments saved as Upload records - New obsimport service handles zip traversal and import logic - Unit tests for front-matter stripping Co-authored-by: Shelley Co-authored-by: exe.dev user Reviewed-on: https://lmika.dev/lmika/weiro/pulls/8 --- cmds/server.go | 4 + handlers/obsimport.go | 50 +++++++ services/obsimport/service.go | 229 +++++++++++++++++++++++++++++ services/obsimport/service_test.go | 51 +++++++ services/services.go | 4 + views/obsimport/form.html | 21 +++ views/obsimport/result.html | 10 ++ views/sitesettings/general.html | 7 + 8 files changed, 376 insertions(+) create mode 100644 handlers/obsimport.go create mode 100644 services/obsimport/service.go create mode 100644 services/obsimport/service_test.go create mode 100644 views/obsimport/form.html create mode 100644 views/obsimport/result.html diff --git a/cmds/server.go b/cmds/server.go index 28e2ccc..29a8c2a 100644 --- a/cmds/server.go +++ b/cmds/server.go @@ -115,6 +115,7 @@ Starting weiro without any arguments will start the server. ssh := handlers.SiteSettingsHandler{SiteService: svcs.Sites} ch := handlers.CategoriesHandler{CategoryService: svcs.Categories} pgh := handlers.PagesHandler{PageService: svcs.Pages} + oih := handlers.ObsImportHandler{ObsImportService: svcs.ObsImport, ScratchDir: cfg.ScratchDir} app.Get("/login", lh.Login) app.Post("/login", lh.DoLogin) @@ -162,6 +163,9 @@ Starting weiro without any arguments will start the server. siteGroup.Get("/settings", ssh.General) 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/new", ch.New) siteGroup.Get("/categories/:categoryID", ch.Edit) diff --git a/handlers/obsimport.go b/handlers/obsimport.go new file mode 100644 index 0000000..e20be77 --- /dev/null +++ b/handlers/obsimport.go @@ -0,0 +1,50 @@ +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), + }) +} diff --git a/services/obsimport/service.go b/services/obsimport/service.go new file mode 100644 index 0000000..0852031 --- /dev/null +++ b/services/obsimport/service.go @@ -0,0 +1,229 @@ +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] +} diff --git a/services/obsimport/service_test.go b/services/obsimport/service_test.go new file mode 100644 index 0000000..51123de --- /dev/null +++ b/services/obsimport/service_test.go @@ -0,0 +1,51 @@ +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) + } + }) + } +} diff --git a/services/services.go b/services/services.go index ab1a4ca..a79e903 100644 --- a/services/services.go +++ b/services/services.go @@ -9,6 +9,7 @@ import ( "lmika.dev/lmika/weiro/services/auth" "lmika.dev/lmika/weiro/services/categories" "lmika.dev/lmika/weiro/services/imgedit" + "lmika.dev/lmika/weiro/services/obsimport" "lmika.dev/lmika/weiro/services/pages" "lmika.dev/lmika/weiro/services/posts" "lmika.dev/lmika/weiro/services/publisher" @@ -27,6 +28,7 @@ type Services struct { ImageEdit *imgedit.Service Categories *categories.Service Pages *pages.Service + ObsImport *obsimport.Service } func New(cfg config.Config) (*Services, error) { @@ -46,6 +48,7 @@ func New(cfg config.Config) (*Services, error) { imageEditService := imgedit.New(uploadService, filepath.Join(cfg.ScratchDir, "imageedit")) categoriesService := categories.New(dbp, publisherQueue) pagesService := pages.New(dbp, publisherQueue) + obsImportService := obsimport.New(dbp, ufp, publisherQueue, filepath.Join(cfg.ScratchDir, "obsimport")) return &Services{ DB: dbp, @@ -58,6 +61,7 @@ func New(cfg config.Config) (*Services, error) { ImageEdit: imageEditService, Categories: categoriesService, Pages: pagesService, + ObsImport: obsImportService, }, nil } diff --git a/views/obsimport/form.html b/views/obsimport/form.html new file mode 100644 index 0000000..ccb27a5 --- /dev/null +++ b/views/obsimport/form.html @@ -0,0 +1,21 @@ +
+
+
Import from Obsidian
+

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.

+
+
+ +
+ +
+
+
+
+
+ + Cancel +
+
+
+
+
diff --git a/views/obsimport/result.html b/views/obsimport/result.html new file mode 100644 index 0000000..15ebe31 --- /dev/null +++ b/views/obsimport/result.html @@ -0,0 +1,10 @@ +
+
+
Import Complete
+
+

Successfully imported {{ .result.PostsImported }} post(s) and {{ .result.UploadsImported }} upload(s).

+
+ Go to Posts + Back to Settings +
+
diff --git a/views/sitesettings/general.html b/views/sitesettings/general.html index 6f1833b..c0989c5 100644 --- a/views/sitesettings/general.html +++ b/views/sitesettings/general.html @@ -66,5 +66,12 @@ +
+
+
+ Import Obsidian + Import posts and attachments from an Obsidian vault zip file. +
+
\ No newline at end of file