diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 58b3cd4..b54b700 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -30,6 +30,11 @@ jobs: go-version-file: go.mod - name: test run: go test -coverprofile coverage.out -covermode atomic ./... + env: + BLOGSYNC_TEST_BLOG: ${{ secrets.BLOGSYNC_BLOG }} + BLOGSYNC_USERNAME: ${{ secrets.BLOGSYNC_USERNAME }} + BLOGSYNC_PASSWORD: ${{ secrets.BLOGSYNC_PASSWORD }} + BLOGSYNC_OWNER: ${{ secrets.BLOGSYNC_OWNER }} - name: Send coverage uses: shogo82148/actions-goveralls@v1 with: diff --git a/.gitignore b/.gitignore index 1b2b627..50c5434 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.env* /blogsync /dist coverage.out diff --git a/broker.go b/broker.go index 3a6e5c3..f1fc0ef 100644 --- a/broker.go +++ b/broker.go @@ -6,6 +6,7 @@ import ( "net/http" "os" "path/filepath" + "strings" "github.com/motemen/go-wsse" "github.com/x-motemen/blogsync/atom" @@ -89,7 +90,29 @@ func (b *broker) FetchRemoteEntries(published, drafts bool) ([]*entry, error) { const entryExt = ".md" // TODO regard re.ContentType func (b *broker) LocalPath(e *entry) string { - return filepath.Join(b.localRoot(), e.URL.Path+entryExt) + if e.localPath != "" { + return e.localPath + } + if e.URL == nil { + return "" + } + localPath := e.URL.Path + + if e.IsDraft && strings.Contains(e.EditURL, "/atom/entry/") { + subdir, entryPath := extractEntryPath(e.URL.Path) + if entryPath == "" { + return "" + } + if isLikelyGivenPath(entryPath) { + // EditURL is like bellow + // https://blog.hatena.ne.jp/Songmu/songmu.hatenadiary.org/atom/entry/6801883189050452361 + paths := strings.Split(e.EditURL, "/") + if len(paths) == 8 { + localPath = subdir + "/entry/" + draftDir + paths[7] // path[7] is entryID + } + } + } + return filepath.Join(b.localRoot(), localPath+entryExt) } func (b *broker) StoreFresh(e *entry, path string) (bool, error) { @@ -109,6 +132,16 @@ func (b *broker) StoreFresh(e *entry, path string) (bool, error) { func (b *broker) Store(e *entry, path, origPath string) error { logf("store", "%s", path) + if e.IsDraft && strings.Contains(e.EditURL, "/atom/entry/") { + _, entryPath := extractEntryPath(e.URL.Path) + if entryPath == "" { + return fmt.Errorf("invalid path: %s", e.URL.Path) + } + if isLikelyGivenPath(entryPath) { + e.URL = nil + } + } + dir := filepath.Dir(path) if err := os.MkdirAll(dir, 0755); err != nil { return err @@ -148,10 +181,7 @@ func (b *broker) PutEntry(e *entry) error { if err != nil { return err } - if e.CustomPath != "" { - newEntry.CustomPath = e.CustomPath - } - return b.Store(newEntry, b.LocalPath(newEntry), b.originalPath(e)) + return b.Store(newEntry, b.LocalPath(newEntry), b.LocalPath(e)) } func (b *broker) PostEntry(e *entry, isPage bool) error { @@ -165,10 +195,6 @@ func (b *broker) PostEntry(e *entry, isPage bool) error { if err != nil { return err } - if e.CustomPath != "" { - newEntry.CustomPath = e.CustomPath - } - return b.Store(newEntry, b.LocalPath(newEntry), "") } @@ -180,13 +206,6 @@ func (b *broker) RemoveEntry(e *entry, p string) error { return os.Remove(p) } -func (b *broker) originalPath(e *entry) string { - if e.URL == nil { - return "" - } - return b.LocalPath(e) -} - func atomEndpointURLRoot(bc *blogConfig) string { owner := bc.Owner if owner == "" { diff --git a/broker_test.go b/broker_test.go index 2b346bf..321fe3a 100644 --- a/broker_test.go +++ b/broker_test.go @@ -1,10 +1,7 @@ package main import ( - "net/url" - "runtime" "testing" - "time" ) func TestEntryEndPointUrl(t *testing.T) { @@ -41,65 +38,3 @@ func TestEntryEndPointUrl(t *testing.T) { }) } } - -func TestOriginalPath(t *testing.T) { - u, _ := url.Parse("http://hatenablog.example.com/2") - jst, _ := time.LoadLocation("Asia/Tokyo") - d := time.Date(2023, 10, 10, 0, 0, 0, 0, jst) - - testCases := []struct { - name string - entry entry - expect string - expectWindows string - }{ - { - name: "entry has URL", - entry: entry{ - entryHeader: &entryHeader{ - URL: &entryURL{u}, - EditURL: u.String() + "/edit", - Title: "test", - Date: &d, - IsDraft: true, - }, - LastModified: &d, - Content: "テスト", - }, - expect: "example1.hatenablog.com/2.md", - expectWindows: "example1.hatenablog.com\\2.md", - }, - { - name: "Not URL", - entry: entry{ - entryHeader: &entryHeader{ - EditURL: u.String() + "/edit", - Title: "hoge", - IsDraft: true, - }, - LastModified: &d, - Content: "テスト", - }, - expect: "", - expectWindows: "", - }, - } - - config := blogConfig{ - BlogID: "example1.hatenablog.com", - Username: "sample1", - } - broker := newBroker(&config, nil) - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - got := broker.originalPath(&tc.entry) - if runtime.GOOS == "windows" { - tc.expect = tc.expectWindows - } - if tc.expect != got { - t.Errorf("expect: %s, got: %s", tc.expect, got) - } - }) - } -} diff --git a/config_test.go b/config_test.go index 943b5d0..f403c84 100644 --- a/config_test.go +++ b/config_test.go @@ -32,7 +32,7 @@ func TestLoadConfigration(t *testing.T) { if tmpVal != "" { return os.Setenv(envKey, tmpVal) } - return nil + return os.Unsetenv(envKey) }, func() error { if ok { return os.Setenv(envKey, env) diff --git a/doc/file-structure.md b/doc/file-structure.md new file mode 100644 index 0000000..d7c6dc0 --- /dev/null +++ b/doc/file-structure.md @@ -0,0 +1,59 @@ +# blogsyncのエントリーファイル構成 + +## ブログIDと配信ドメイン +前提として、blogsyncの設定ファイルのキーに使うドメイン状の文字列は「ブログID」です。これはブログ開設時に決めたドメインで不変です。ですので、多くの場合、ブログIDはブログの配信ドメインと一致します。しかし、独自ドメイン利用している場合、ブログIDと配信ドメインは一致しないことに注意してください。この場合、設定した独自ドメインがブログIDにはならず、当初のものがそのまま使われます。 + +例えば、筆者(Songmu)は、 https://blog.song.mu という独自ドメインでブログを運営していますが、開設時に指定した songmu.hateblo.jp がブログIDです。 + +## blogsyncがファイル配置するローカルのルートディレクトリ + +blogsyncは設定ファイルの `local_root` と ブログID を連結したパス(`$local_root/$blogID`)をルートディレクトリとしてコンテンツファイルを配置します。 + +```yaml +songmu.hateblo.jp: + local_root: /Users/Songmu/Blog +``` + +例えば上記のような設定に対して `blogsync pull songmu.hateblo.jp` すると `/Users/Songmu/Blog/songmu.hateblo.jp` 以下にコンテンツファイルが配置されます。 + +ブログIDの `songmu.hateblo.jp` ディレクトリが掘られることが冗長に感じるかもしれません。その場合は `omit_domain` 設定で階層を浅くできます。以下のような具合です。 + + +```yaml +songmu.hateblo.jp: + local_root: /Users/Songmu/Blog + omit_domain: true +``` + +こうすると `/Users/Songmu/Blog` がルートになります。 + +余談ですが、`local_root` 設定が少し分かりづらい理由としては、当初はオリジナル作者(motemen)に複数ブログを特定のディレクトリ配下で管理したいという動機があり `default.local_root` 一つだけ設定しておけば、そこ配下にブログID毎にディレクトリを掘ることを想定してたのではないかと予想しています。なので、ブログ個別設定に`local_root`を設定することをあまり想定していなかった可能性があります。例えば以下のような設定ファイルを想定していたのではないでしょうか。 + +```yaml +songmu.hateblo.jp: +songmu.hatenablog.com: +default: + local_root: /Users/Songmu/Blogs + username: Songmu + ... +``` + +この辺りは、複雑で分かりづらく、`omit_domain` という設定も後付け感があり名付けもイマイチなので、その辺りを含めて将来的に非互換変更を入れる可能性があります。 + +## コンテンツファイルの配置 +blogsyncは公開URLのパスと対応した位置にコンテンツファイルを保存します。サブディレクトリ運用の場合、サブディレクトリ含めて保存されます。また、URLのパスは以下の2種類に分かれます。 + +- 執筆者が明示的に指定するもの。いわゆるslug + - ブログ管理画面上では「カスタムURL」、blogsync上は"CustomPath"と呼ばれる +- はてなブログ側が自動付与するもの + +執筆者が明示しない場合の自動付与は **記事公開時**におこなわれる。標準では `2001/02/03/150405` のような時刻ベースのフォーマットになる。他にも、はてなダイヤリーフォーマットや、タイトルを付与したフォーマットがあり、ブログ管理画面から切り替え設定できる。 + +自動付与の場合「記事公開時に」と書いた通り、下書き時にはURLの付与は行われない。APIのレスポンスには仮のURLが返される。この仮のURLはAPIリクエスト時刻が基準になったものが返されるので、下書き更新時に毎回異なるものが返される。 + +なので、カスタムパス未指定の下書きは、URLのパスが実質的に未確定なので、blogsyncは `entry/_draft/$entryID.md` という位置にファイルを保存します。この `entryID` はエントリーのEditURLに含まれる数字列のIDです。ちなみに、固定ページはカスタムパス指定必須なので、この位置に下書きが保存されることはありません。 + +エントリーのURLを変更したい場合、ローカルのコンテンツファイル名を変更してpushすれば、URLも変更されます。 + +## エントリーと固定ページ +TBA diff --git a/entry.go b/entry.go index 0f1fcea..1e65504 100644 --- a/entry.go +++ b/entry.go @@ -27,7 +27,7 @@ type entryHeader struct { Title string `yaml:"Title"` Category []string `yaml:"Category,omitempty"` Date *time.Time `yaml:"Date,omitempty"` - URL *entryURL `yaml:"URL"` + URL *entryURL `yaml:"URL,omitempty"` EditURL string `yaml:"EditURL"` PreviewURL string `yaml:"PreviewURL,omitempty"` IsDraft bool `yaml:"Draft,omitempty"` @@ -68,6 +68,7 @@ type entry struct { LastModified *time.Time Content string ContentType string + localPath string } func (e *entry) HeaderString() string { @@ -278,3 +279,12 @@ func modTime(fpath string) (time.Time, error) { } return ti, nil } + +func extractEntryPath(p string) (subdir string, entryPath string) { + stuffs := strings.SplitN(p, "/entry/", 2) + if len(stuffs) != 2 { + return "", "" + } + entryPath = strings.TrimSuffix(stuffs[1], entryExt) + return stuffs[0], entryPath +} diff --git a/main.go b/main.go index 4a7263f..16d58f2 100644 --- a/main.go +++ b/main.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "regexp" "sort" "strings" "time" @@ -187,6 +188,22 @@ var commandFetch = &cli.Command{ }, } +var ( + // 標準フォーマット: 2011/11/07/161845 + defaultBlogPathReg = regexp.MustCompile(`^2[01][0-9]{2}/[01][0-9]/[0-3][0-9]/[0-9]{6}$`) + // はてなダイアリー風フォーマット: 20111107/1320650325 + hatenaDiaryPathReg = regexp.MustCompile(`^2[01][0-9]{2}[01][0-9][0-3][0-9]/[0-9]{9,12}$`) + // タイトルフォーマット: 2011/11/07/週末は川に行きました + titlePathReg = regexp.MustCompile(`^2[01][0-9]{2}/[01][0-9]/[0-3][0-9]/.+$`) + draftDir = "_draft/" +) + +func isLikelyGivenPath(p string) bool { + return defaultBlogPathReg.MatchString(p) || + hatenaDiaryPathReg.MatchString(p) || + titlePathReg.MatchString(p) +} + var commandPush = &cli.Command{ Name: "push", Usage: "Push local entries to remote", @@ -207,6 +224,14 @@ var commandPush = &cli.Command{ } for _, path := range c.Args().Slice() { + if !filepath.IsAbs(path) { + var err error + path, err = filepath.Abs(path) + if err != nil { + return err + } + } + f, err := os.Open(path) if err != nil { return err @@ -223,16 +248,10 @@ var commandPush = &cli.Command{ ti := time.Now() entry.LastModified = &ti } + entry.localPath = path if entry.EditURL == "" { // post new entry - if !filepath.IsAbs(path) { - var err error - path, err = filepath.Abs(path) - if err != nil { - return err - } - } bc := conf.detectBlogConfig(path) if bc == nil { return fmt.Errorf("cannot find blog for %q", path) @@ -242,11 +261,11 @@ var commandPush = &cli.Command{ // relative position from the entry directory is obtained as a custom path as below. blogPath, _ := filepath.Rel(bc.localRoot(), path) blogPath = "/" + filepath.ToSlash(blogPath) - stuffs := strings.SplitN(blogPath, "/entry/", 2) - if len(stuffs) != 2 { + _, entryPath := extractEntryPath(path) + if entryPath == "" { return fmt.Errorf("%q is not a blog entry", path) } - entry.CustomPath = strings.TrimSuffix(stuffs[1], entryExt) + entry.CustomPath = entryPath b := newBroker(bc, c.App.Writer) err = b.PostEntry(entry, false) if err != nil { @@ -264,6 +283,14 @@ var commandPush = &cli.Command{ return fmt.Errorf("cannot find blog for %s", path) } + blogPath, _ := filepath.Rel(bc.localRoot(), path) + blogPath = "/" + filepath.ToSlash(blogPath) + + if _, entryPath := extractEntryPath(path); entryPath != "" { + if !isLikelyGivenPath(entryPath) && !strings.HasPrefix(entryPath, draftDir) { + entry.CustomPath = entryPath + } + } _, err = newBroker(bc, c.App.Writer).UploadFresh(entry) if err != nil { return err @@ -298,7 +325,7 @@ var commandPost = &cli.Command{ return fmt.Errorf("blog not found: %s", blog) } - entry, err := entryFromReader(os.Stdin) + entry, err := entryFromReader(c.App.Reader) if err != nil { return err } diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..c45624e --- /dev/null +++ b/main_test.go @@ -0,0 +1,202 @@ +//go:build darwin || integration + +package main + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + "time" + + "github.com/urfave/cli/v2" +) + +func blogsyncApp(app *cli.App) func(...string) (string, error) { + buf := &bytes.Buffer{} + app.Writer = buf + return func(args ...string) (string, error) { + buf.Reset() + err := app.Run(append([]string{""}, args...)) + return strings.TrimSpace(buf.String()), err + } +} + +func exists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +var draftFileReg = regexp.MustCompile(`entry/_draft/\d+\.md$`) + +func TestBlogsync(t *testing.T) { + blogID := os.Getenv("BLOGSYNC_TEST_BLOG") + if blogID == "" { + t.Skip("BLOGSYNC_TEST_BLOG not set") + } + + pwd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + dir := t.TempDir() + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + defer func() { + if err := os.Chdir(pwd); err != nil { + t.Fatal(err) + } + }() + + dir, err = filepath.EvalSymlinks(dir) + if err != nil { + t.Fatal(err) + } + + confYAML := fmt.Sprintf(`%s: + local_root: . + omit_domain: true +`, blogID) + if owner := os.Getenv("BLOGSYNC_OWNER"); owner != "" { + confYAML += fmt.Sprintf(" owner: %s\n", owner) + } + conf := filepath.Join(dir, "blogsync.yaml") + if err := os.WriteFile(conf, []byte(confYAML), 0644); err != nil { + t.Fatal(err) + } + + app := newApp() + blogsync := blogsyncApp(app) + + t.Run("pull", func(t *testing.T) { + if _, err := blogsync("pull"); err != nil { + t.Fatal(err) + } + }) + + t.Run("post draft and publish", func(t *testing.T) { + t.Log("Post a draft without a custom path and check if the file is saved in the proper location") + app.Reader = strings.NewReader("draft\n") + entryFile, err := blogsync("post", "--draft", blogID) + app.Reader = os.Stdin + if err != nil { + t.Fatal(err) + } + defer func() { + t.Log("remove the published entry") + if _, err := blogsync("remove", entryFile); err != nil { + t.Fatal(err) + } + }() + + if !draftFileReg.MatchString(entryFile) { + t.Fatalf("unexpected draft file: %s", entryFile) + } + + t.Log("Draft files under `_draft/` will revert to the original file name if the file is renamed and pushed again") + d, f := filepath.Split(entryFile) + movedPath := filepath.Join(d, "_"+f) + if err := os.Rename(entryFile, movedPath); err != nil { + t.Fatal(err) + } + originalEntryFile := entryFile + entryFile = movedPath + if err := appendFile(movedPath, "updated\n"); err != nil { + t.Fatal(err) + } + draftFile, err := blogsync("push", entryFile) + if err != nil { + t.Fatal(err) + } + if draftFile != originalEntryFile { + entryFile = draftFile + t.Fatalf("unexpected draft file: %s", draftFile) + } + if exists(entryFile) { + t.Errorf("renamed draft file is not deleted: %s", movedPath) + } + entryFile = draftFile + + t.Log("When a draft is published, a URL is issued and the file is saved in the corresponding location") + publishedFile, err := blogsync("push", "--publish", entryFile) + if err != nil { + t.Fatal(err) + } + if exists(entryFile) { + t.Errorf("draft file not deleted: %s", entryFile) + } + entryFile = publishedFile + + _, entryPath := extractEntryPath(entryFile) + if !isLikelyGivenPath(entryPath) { + t.Errorf("unexpected published file: %s", entryFile) + } + }) + + t.Run("post draft and publish with custom path", func(t *testing.T) { + t.Log("Creating a draft with a custom path saves the file in the specified location, not under `_draft/`") + localFile := filepath.Join(dir, "entry", time.Now().Format("custom-20060102150405")+".md") + if err := os.WriteFile(localFile, []byte(`--- +Draft: true +--- +test`), 0644); err != nil { + t.Fatal(err) + } + entryFile, err := blogsync("push", localFile) + if err != nil { + t.Fatal(err) + } + defer func() { + t.Log("remove the published entry") + if _, err := blogsync("remove", entryFile); err != nil { + t.Fatal(err) + } + }() + if entryFile != localFile { + t.Errorf("unexpected published file: %s", entryFile) + } + + t.Log("When publishing a draft with a custom path, the file location is unchanged") + publishedFile, err := blogsync("push", "--publish", entryFile) + if err != nil { + t.Fatal(err) + } + if publishedFile != entryFile { + t.Errorf("unexpected published file: %s", publishedFile) + } + + t.Log("If the file name of an entry is changed, the custom path will follow suit") + d, f := filepath.Split(entryFile) + movedPath := filepath.Join(d, "custom-"+f) + if err := os.Rename(entryFile, movedPath); err != nil { + t.Fatal(err) + } + entryFile = movedPath + if err := appendFile(entryFile, "updated\n"); err != nil { + t.Fatal(err) + } + publishedFile, err = blogsync("push", entryFile) + if err != nil { + t.Fatal(err) + } + if publishedFile != entryFile { + entryFile = publishedFile + t.Errorf("unexpected published file: %s", publishedFile) + } + }) +} + +func appendFile(path string, content string) error { + fh, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0644) + if err != nil { + return err + } + if _, err := fh.WriteString(content); err != nil { + return err + } + return fh.Close() +}