Automatically fetch reply and like title

(Updates #45)
pull/47/head
Jan-Lukas Else 2 months ago
parent 34ab1b1fb2
commit 6bfaf16e25

@ -75,6 +75,9 @@ type goBlog struct {
compressors []mediaCompression
mediaStorageInit sync.Once
mediaStorage mediaStorage
// Microformats
mfInit sync.Once
mfCache *ristretto.Cache
// Minify
min minify.Minifier
// Plugins

@ -100,6 +100,8 @@ type configBlog struct {
hideOldContentWarning bool
hideShareButton bool
hideTranslateButton bool
addReplyTitle bool
addLikeTitle bool
// Editor state WebSockets
esws sync.Map
esm sync.Mutex
@ -530,6 +532,14 @@ func (a *goBlog) initConfig(logging bool) error {
if err != nil {
return err
}
bc.addReplyTitle, err = a.getBooleanSettingValue(settingNameWithBlog(blog, addReplyTitleSetting), false)
if err != nil {
return err
}
bc.addLikeTitle, err = a.getBooleanSettingValue(settingNameWithBlog(blog, addLikeTitleSetting), false)
if err != nil {
return err
}
}
// Log success
a.cfg.initialized = true

@ -467,9 +467,11 @@ func (a *goBlog) blogSettingsRouter(_ *configBlog) func(r chi.Router) {
r.Post(settingsCreateSectionPath, a.settingsCreateSection)
r.Post(settingsUpdateSectionPath, a.settingsUpdateSection)
r.Post(settingsUpdateDefaultSectionPath, a.settingsUpdateDefaultSection)
r.Post(settingsHideOldContentWarningPath, a.settingsHideOldContentWarning)
r.Post(settingsHideShareButtonPath, a.settingsHideShareButton)
r.Post(settingsHideTranslateButtonPath, a.settingsHideTranslateButton)
r.Post(settingsHideOldContentWarningPath, a.settingsHideOldContentWarning())
r.Post(settingsHideShareButtonPath, a.settingsHideShareButton())
r.Post(settingsHideTranslateButtonPath, a.settingsHideTranslateButton())
r.Post(settingsAddReplyTitlePath, a.settingsAddReplyTitle())
r.Post(settingsAddLikeTitlePath, a.settingsAddLikeTitle())
r.Post(settingsUpdateUserPath, a.settingsUpdateUser)
r.Post(settingsUpdateProfileImagePath, a.serveUpdateProfileImage)
r.Post(settingsDeleteProfileImagePath, a.serveDeleteProfileImage)

@ -0,0 +1,188 @@
package main
import (
"bytes"
"context"
"net/http"
"net/url"
"strings"
"time"
"github.com/PuerkitoBio/goquery"
"github.com/carlmjohnson/requests"
"github.com/dgraph-io/ristretto"
"go.goblog.app/app/pkgs/bufferpool"
"go.goblog.app/app/pkgs/contenttype"
"go.goblog.app/app/pkgs/httpcachetransport"
"willnorris.com/go/microformats"
)
func (a *goBlog) initMicroformatsCache() {
a.mfInit.Do(func() {
a.mfCache, _ = ristretto.NewCache(&ristretto.Config{
NumCounters: 100,
MaxCost: 10, // Cache http responses for 10 requests
BufferItems: 64,
IgnoreInternalCost: true,
})
})
}
type microformatsResult struct {
Title, Content, Author, Url string
source string
hasUrl bool
}
func (a *goBlog) parseMicroformats(u string, cache bool) (*microformatsResult, error) {
buf := bufferpool.Get()
defer bufferpool.Put(buf)
rb := requests.URL(u).
Method(http.MethodGet).
Accept(contenttype.HTMLUTF8).
Client(a.httpClient).
ToBytesBuffer(buf)
if cache {
a.initMicroformatsCache()
rb.Transport(httpcachetransport.NewHttpCacheTransport(a.httpClient.Transport, a.mfCache, 10*time.Minute))
}
err := rb.Fetch(context.Background())
if err != nil {
return nil, err
}
return a.parseMicroformatsFromBytes(u, buf.Bytes())
}
func (a *goBlog) parseMicroformatsFromBytes(u string, b []byte) (*microformatsResult, error) {
parsedUrl, err := url.Parse(u)
if err != nil {
return nil, err
}
m := &microformatsResult{
source: u,
}
// Fill from microformats
m.fillFromData(microformats.Parse(bytes.NewReader(b), parsedUrl))
if m.Url == "" {
m.Url = u
}
// Set title when content is empty as well
if m.Title == "" && m.Content == "" {
doc, err := goquery.NewDocumentFromReader(bytes.NewReader(b))
if err != nil {
return nil, err
}
if title := doc.Find("title"); title != nil {
m.Title = title.Text()
}
}
// Reset title if it's just a prefix of the content
if m.Title != "" && strings.HasPrefix(m.Content, m.Title) {
m.Title = ""
}
return m, nil
}
func (m *microformatsResult) fillFromData(mf *microformats.Data) {
// Fill data
for _, i := range mf.Items {
if m.fill(i) {
break
}
}
}
func (m *microformatsResult) fill(mf *microformats.Microformat) bool {
if mfHasType(mf, "h-entry") {
// Check URL
if url, ok := mf.Properties["url"]; ok && len(url) > 0 {
if url0, ok := url[0].(string); ok {
if strings.EqualFold(url0, m.source) {
// Is searched entry
m.hasUrl = true
m.Url = url0
// Reset attributes to refill
m.Author = ""
m.Title = ""
m.Content = ""
} else if m.hasUrl {
// Already found entry
return false
} else if m.Url == "" {
// Is the first entry
m.Url = url0
} else {
// Is not the first entry
return false
}
}
}
// Title
m.fillTitle(mf)
// Content
m.fillContent(mf)
// Author
m.fillAuthor(mf)
return m.hasUrl
}
for _, mfc := range mf.Children {
if m.fill(mfc) {
return true
}
}
return false
}
func (m *microformatsResult) fillTitle(mf *microformats.Microformat) {
if m.Title != "" {
return
}
if name, ok := mf.Properties["name"]; ok && len(name) > 0 {
if title, ok := name[0].(string); ok {
m.Title = strings.TrimSpace(title)
}
}
}
func (m *microformatsResult) fillContent(mf *microformats.Microformat) {
if m.Content != "" {
return
}
if contents, ok := mf.Properties["content"]; ok && len(contents) > 0 {
if content, ok := contents[0].(map[string]string); ok {
if contentHTML, ok := content["html"]; ok {
m.Content = cleanHTMLText(contentHTML)
// Replace newlines with spaces
m.Content = strings.ReplaceAll(m.Content, "\n", " ")
// Collapse double spaces
m.Content = strings.Join(strings.Fields(m.Content), " ")
// Trim spaces
m.Content = strings.TrimSpace(m.Content)
}
}
}
}
func (m *microformatsResult) fillAuthor(mf *microformats.Microformat) {
if m.Author != "" {
return
}
if authors, ok := mf.Properties["author"]; ok && len(authors) > 0 {
if author, ok := authors[0].(*microformats.Microformat); ok {
if names, ok := author.Properties["name"]; ok && len(names) > 0 {
if name, ok := names[0].(string); ok {
m.Author = strings.TrimSpace(name)
}
}
}
}
}
func mfHasType(mf *microformats.Microformat, typ string) bool {
for _, t := range mf.Type {
if typ == t {
return true
}
}
return false
}

@ -0,0 +1,37 @@
package main
import (
"net/http"
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func Test_parseMicroformats(t *testing.T) {
app := &goBlog{
cfg: createDefaultTestConfig(t),
}
err := app.initConfig(false)
require.NoError(t, err)
testHtmlBytes, err := os.ReadFile("testdata/wmtest.html")
require.NoError(t, err)
testHtml := string(testHtmlBytes)
mockClient := newFakeHttpClient()
mockClient.setFakeResponse(http.StatusOK, testHtml)
app.httpClient = mockClient.Client
m, err := app.parseMicroformats("https://example.net/articles/micropub-crossposting-to-twitter-and-enabling-tweetstorms", false)
require.NoError(t, err)
assert.Equal(t, "Micropub, Crossposting to Twitter, and Enabling “Tweetstorms”", m.Title)
assert.NotEmpty(t, m.Content)
assert.Equal(t, "Test Blogger", m.Author)
assert.Equal(t, "https://example.net/articles/micropub-crossposting-to-twitter-and-enabling-tweetstorms", m.Url)
}

@ -0,0 +1,45 @@
package httpcachetransport
import (
"bufio"
"bytes"
"net/http"
"net/http/httputil"
"time"
"github.com/dgraph-io/ristretto"
)
type httpCacheTransport struct {
parent http.RoundTripper
ristrettoCache *ristretto.Cache
ttl time.Duration
}
func (t *httpCacheTransport) RoundTrip(r *http.Request) (*http.Response, error) {
requestUrl := r.URL.String()
if t.ristrettoCache != nil {
if cached, hasCached := t.ristrettoCache.Get(requestUrl); hasCached {
if cachedResp, ok := cached.([]byte); ok {
return http.ReadResponse(bufio.NewReader(bytes.NewReader(cachedResp)), r)
}
}
}
resp, err := t.parent.RoundTrip(r)
if err == nil && t.ristrettoCache != nil {
respBytes, err := httputil.DumpResponse(resp, true)
if err != nil {
return resp, err
}
t.ristrettoCache.SetWithTTL(requestUrl, respBytes, 1, t.ttl)
t.ristrettoCache.Wait()
return http.ReadResponse(bufio.NewReader(bytes.NewReader(respBytes)), r)
}
return resp, err
}
// Creates a new http.RoundTripper that caches all
// request responses (by the request URL) in ristretto.
func NewHttpCacheTransport(parent http.RoundTripper, ristrettoCache *ristretto.Cache, ttl time.Duration) http.RoundTripper {
return &httpCacheTransport{parent, ristrettoCache, ttl}
}

@ -0,0 +1,50 @@
package httpcachetransport
import (
"bufio"
"context"
"net/http"
"strings"
"testing"
"time"
"github.com/carlmjohnson/requests"
"github.com/dgraph-io/ristretto"
"github.com/stretchr/testify/assert"
)
const fakeResponse = `HTTP/1.1 200 OK
Content-Type: text/html; charset=UTF-8
Date: Wed, 14 Dec 2022 10:34:03 GMT
<!doctype html>
<html>
</html>`
func TestHttpCacheTransport(t *testing.T) {
cache, _ := ristretto.NewCache(&ristretto.Config{
NumCounters: 100,
MaxCost: 10,
BufferItems: 64,
IgnoreInternalCost: true,
})
counter := 0
orig := requests.RoundTripFunc(func(req *http.Request) (res *http.Response, err error) {
counter++
return http.ReadResponse(bufio.NewReader(strings.NewReader(fakeResponse)), req)
})
client := &http.Client{
Transport: NewHttpCacheTransport(orig, cache, time.Minute),
}
err := requests.URL("https://example.com/").Client(client).Fetch(context.Background())
assert.NoError(t, err)
err = requests.URL("https://example.com/").Client(client).Fetch(context.Background())
assert.NoError(t, err)
assert.Equal(t, 1, counter)
}

@ -85,6 +85,22 @@ func (a *goBlog) checkPost(p *post) (err error) {
}
p.Parameters[pk] = pvs
}
// Automatically add reply title
if replyLink := p.firstParameter(a.cfg.Micropub.ReplyParam); replyLink != "" && p.firstParameter(a.cfg.Micropub.ReplyTitleParam) == "" &&
a.cfg.Blogs[p.Blog].addReplyTitle {
// Is reply, but has no reply title
if mf, err := a.parseMicroformats(replyLink, true); err == nil && mf.Title != "" {
p.addParameter(a.cfg.Micropub.ReplyTitleParam, mf.Title)
}
}
// Automatically add like title
if likeLink := p.firstParameter(a.cfg.Micropub.LikeParam); likeLink != "" && p.firstParameter(a.cfg.Micropub.LikeTitleParam) == "" &&
a.cfg.Blogs[p.Blog].addLikeTitle {
// Is like, but has no like title
if mf, err := a.parseMicroformats(likeLink, true); err == nil && mf.Title != "" {
p.addParameter(a.cfg.Micropub.LikeTitleParam, mf.Title)
}
}
// Check path
if p.Path != "/" {
p.Path = strings.TrimSuffix(p.Path, "/")

@ -35,6 +35,10 @@ func (p *post) firstParameter(parameter string) (result string) {
return
}
func (p *post) addParameter(parameter, value string) {
p.Parameters[parameter] = append(p.Parameters[parameter], value)
}
func (a *goBlog) postHtml(p *post, absolute bool) (res string) {
buf := bufferpool.Get()
a.postHtmlToWriter(buf, p, absolute)

@ -23,12 +23,31 @@ func (a *goBlog) serveSettings(w http.ResponseWriter, r *http.Request) {
hideOldContentWarning: bc.hideOldContentWarning,
hideShareButton: bc.hideShareButton,
hideTranslateButton: bc.hideTranslateButton,
addReplyTitle: bc.addReplyTitle,
addLikeTitle: bc.addLikeTitle,
userNick: a.cfg.User.Nick,
userName: a.cfg.User.Name,
},
})
}
func (a *goBlog) booleanBlogSettingHandler(settingName string, apply func(*configBlog, bool)) http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
blog, bc := a.getBlog(r)
// Read values
settingValue := r.FormValue(settingName) == "on"
// Update
err := a.saveBooleanSettingValue(settingNameWithBlog(blog, settingName), settingValue)
if err != nil {
a.serveError(w, r, "Failed to update setting in database", http.StatusInternalServerError)
return
}
// Apply
apply(bc, settingValue)
http.Redirect(w, r, bc.getRelativePath(settingsPath), http.StatusFound)
})
}
const settingsDeleteSectionPath = "/deletesection"
func (a *goBlog) settingsDeleteSection(w http.ResponseWriter, r *http.Request) {
@ -157,53 +176,45 @@ func (a *goBlog) settingsUpdateDefaultSection(w http.ResponseWriter, r *http.Req
const settingsHideOldContentWarningPath = "/oldcontentwarning"
func (a *goBlog) settingsHideOldContentWarning(w http.ResponseWriter, r *http.Request) {
blog, bc := a.getBlog(r)
// Read values
hideOldContentWarning := r.FormValue(hideOldContentWarningSetting) == "on"
// Update
err := a.saveBooleanSettingValue(settingNameWithBlog(blog, hideOldContentWarningSetting), hideOldContentWarning)
if err != nil {
a.serveError(w, r, "Failed to update setting to hide old content warning in database", http.StatusInternalServerError)
return
}
bc.hideOldContentWarning = hideOldContentWarning
a.cache.purge()
http.Redirect(w, r, bc.getRelativePath(settingsPath), http.StatusFound)
func (a *goBlog) settingsHideOldContentWarning() http.HandlerFunc {
return a.booleanBlogSettingHandler(hideOldContentWarningSetting, func(cb *configBlog, b bool) {
cb.hideOldContentWarning = b
a.cache.purge()
})
}
const settingsHideShareButtonPath = "/sharebutton"
func (a *goBlog) settingsHideShareButton(w http.ResponseWriter, r *http.Request) {
blog, bc := a.getBlog(r)
// Read values
hideShareButton := r.FormValue(hideShareButtonSetting) == "on"
// Update
err := a.saveBooleanSettingValue(settingNameWithBlog(blog, hideShareButtonSetting), hideShareButton)
if err != nil {
a.serveError(w, r, "Failed to update setting to hide share button in database", http.StatusInternalServerError)
return
}
bc.hideShareButton = hideShareButton
a.cache.purge()
http.Redirect(w, r, bc.getRelativePath(settingsPath), http.StatusFound)
func (a *goBlog) settingsHideShareButton() http.HandlerFunc {
return a.booleanBlogSettingHandler(hideShareButtonSetting, func(cb *configBlog, b bool) {
cb.hideShareButton = b
a.cache.purge()
})
}
const settingsHideTranslateButtonPath = "/translatebutton"
func (a *goBlog) settingsHideTranslateButton(w http.ResponseWriter, r *http.Request) {
blog, bc := a.getBlog(r)
// Read values
hideTranslateButton := r.FormValue(hideTranslateButtonSetting) == "on"
// Update
err := a.saveBooleanSettingValue(settingNameWithBlog(blog, hideTranslateButtonSetting), hideTranslateButton)
if err != nil {
a.serveError(w, r, "Failed to update setting to hide translate button in database", http.StatusInternalServerError)
return
}
bc.hideTranslateButton = hideTranslateButton
a.cache.purge()
http.Redirect(w, r, bc.getRelativePath(settingsPath), http.StatusFound)
func (a *goBlog) settingsHideTranslateButton() http.HandlerFunc {
return a.booleanBlogSettingHandler(hideTranslateButtonSetting, func(cb *configBlog, b bool) {
cb.hideTranslateButton = b
a.cache.purge()
})
}
const settingsAddReplyTitlePath = "/replytitle"
func (a *goBlog) settingsAddReplyTitle() http.HandlerFunc {
return a.booleanBlogSettingHandler(addReplyTitleSetting, func(cb *configBlog, b bool) {
cb.addReplyTitle = b
})
}
const settingsAddLikeTitlePath = "/liketitle"
func (a *goBlog) settingsAddLikeTitle() http.HandlerFunc {
return a.booleanBlogSettingHandler(addLikeTitleSetting, func(cb *configBlog, b bool) {
cb.addLikeTitle = b
})
}
const settingsUpdateUserPath = "/user"

@ -19,6 +19,8 @@ const (
hideTranslateButtonSetting = "hidetranslatebutton"
userNickSetting = "usernick"
userNameSetting = "username"
addReplyTitleSetting = "addreplytitle"
addLikeTitleSetting = "addliketitle"
)
func (a *goBlog) getSettingValue(name string) (string, error) {

@ -1,4 +1,6 @@
acommentby: "Ein Kommentar von"
addliketitledesc: "Automatisch einen Like-Titel zu neuen und aktualisierten Beiträgen mit einem Like-Link ohne manuell gesetzten Like-Titel hinzufügen."
addreplytitledesc: "Automatisch einen Reply-Titel zu neuen und aktualisierten Beiträgen mit einem Reply-Link ohne manuell gesetzten Reply-Titel hinzufügen."
captchainstructions: "Bitte gib die Ziffern aus dem oberen Bild ein"
chars: "Buchstaben"
comment: "Kommentar"

@ -1,4 +1,6 @@
acommentby: "A comment by"
addliketitledesc: "Automatically add like title to new and updated posts with a like link and no manually set like title."
addreplytitledesc: "Automatically add reply title to new and updated posts with a reply link and no manually set reply title."
apfollower: "Follower"
apfollowers: "ActivityPub followers"
apinbox: "Inbox"

16
ui.go

@ -1536,6 +1536,8 @@ type settingsRenderData struct {
hideOldContentWarning bool
hideShareButton bool
hideTranslateButton bool
addReplyTitle bool
addLikeTitle bool
userNick string
userName string
}
@ -1584,6 +1586,20 @@ func (a *goBlog) renderSettings(hb *htmlbuilder.HtmlBuilder, rd *renderData) {
hideTranslateButtonSetting,
srd.hideTranslateButton,
)
// Add reply title
a.renderBooleanSetting(hb, rd,
rd.Blog.getRelativePath(settingsPath+settingsAddReplyTitlePath),
a.ts.GetTemplateStringVariant(rd.Blog.Lang, "addreplytitledesc"),
addReplyTitleSetting,
srd.addReplyTitle,
)
// Add like title
a.renderBooleanSetting(hb, rd,
rd.Blog.getRelativePath(settingsPath+settingsAddLikeTitlePath),
a.ts.GetTemplateStringVariant(rd.Blog.Lang, "addliketitledesc"),
addLikeTitleSetting,
srd.addLikeTitle,
)
// User settings
a.renderUserSettings(hb, rd, srd)

@ -34,7 +34,6 @@ type mention struct {
Author string
Status webmentionStatus
Submentions []*mention
hasUrl bool
}
func (a *goBlog) initWebmention() {

@ -9,15 +9,12 @@ import (
"io"
"log"
"net/http"
"net/url"
"strings"
"time"
"github.com/PuerkitoBio/goquery"
"github.com/samber/lo"
"go.goblog.app/app/pkgs/bufferpool"
"go.goblog.app/app/pkgs/contenttype"
"willnorris.com/go/microformats"
)
func (a *goBlog) initWebmentionQueue() {
@ -150,9 +147,9 @@ func (a *goBlog) verifyMention(m *mention) error {
}
func (a *goBlog) verifyReader(m *mention, body io.Reader) error {
linksBuffer, gqBuffer, mfBuffer := bufferpool.Get(), bufferpool.Get(), bufferpool.Get()
defer bufferpool.Put(linksBuffer, gqBuffer, mfBuffer)
if _, err := io.Copy(io.MultiWriter(linksBuffer, gqBuffer, mfBuffer), body); err != nil {
linksBuffer, mfBuffer := bufferpool.Get(), bufferpool.Get()
defer bufferpool.Put(linksBuffer, mfBuffer)
if _, err := io.Copy(io.MultiWriter(linksBuffer, mfBuffer), body); err != nil {
return err
}
// Check if source mentions target
@ -187,136 +184,10 @@ func (a *goBlog) verifyReader(m *mention, body io.Reader) error {
return errors.New("target not found in source")
}
// Fill mention attributes
sourceURL, err := url.Parse(defaultIfEmpty(m.NewSource, m.Source))
mf, err := a.parseMicroformatsFromBytes(defaultIfEmpty(m.NewSource, m.Source), mfBuffer.Bytes())
if err != nil {
return err
}
m.Title = ""
m.Content = ""
m.Author = ""
m.Url = ""
m.hasUrl = false
m.fillFromData(microformats.Parse(mfBuffer, sourceURL))
if m.Url == "" {
m.Url = m.Source
}
// Set title when content is empty as well
if m.Title == "" && m.Content == "" {
doc, err := goquery.NewDocumentFromReader(gqBuffer)
if err != nil {
return err
}
if title := doc.Find("title"); title != nil {
m.Title = title.Text()
}
}
// Reset title if it's just a prefix of the content
if m.Title != "" && strings.HasPrefix(m.Content, m.Title) {
m.Title = ""
}
m.Title, m.Content, m.Author, m.Url = mf.Title, mf.Content, mf.Author, defaultIfEmpty(mf.Url, m.Source)
return nil
}
func (m *mention) fillFromData(mf *microformats.Data) {
// Fill data
for _, i := range mf.Items {
if m.fill(i) {
break
}
}
}
func (m *mention) fill(mf *microformats.Microformat) bool {
if mfHasType(mf, "h-entry") {
// Check URL
if url, ok := mf.Properties["url"]; ok && len(url) > 0 {
if url0, ok := url[0].(string); ok {
if strings.EqualFold(url0, defaultIfEmpty(m.NewSource, m.Source)) {
// Is searched entry
m.hasUrl = true
m.Url = url0
// Reset attributes to refill
m.Author = ""
m.Title = ""
m.Content = ""
} else if m.hasUrl {
// Already found entry
return false
} else if m.Url == "" {
// Is the first entry
m.Url = url0
} else {
// Is not the first entry
return false
}
}
}
// Title
m.fillTitle(mf)
// Content
m.fillContent(mf)
// Author
m.fillAuthor(mf)
return m.hasUrl
}
for _, mfc := range mf.Children {
if m.fill(mfc) {
return true
}
}
return false
}
func (m *mention) fillTitle(mf *microformats.Microformat) {
if m.Title != "" {
return
}
if name, ok := mf.Properties["name"]; ok && len(name) > 0 {
if title, ok := name[0].(string); ok {
m.Title = strings.TrimSpace(title)
}
}
}
func (m *mention) fillContent(mf *microformats.Microformat) {
if m.Content != "" {
return
}
if contents, ok := mf.Properties["content"]; ok && len(contents) > 0 {
if content, ok := contents[0].(map[string]string); ok {
if contentHTML, ok := content["html"]; ok {
m.Content = cleanHTMLText(contentHTML)
// Replace newlines with spaces
m.Content = strings.ReplaceAll(m.Content, "\n", " ")
// Collapse double spaces
m.Content = strings.Join(strings.Fields(m.Content), " ")
// Trim spaces
m.Content = strings.TrimSpace(m.Content)
}
}
}
}
func (m *mention) fillAuthor(mf *microformats.Microformat) {
if m.Author != "" {
return
}
if authors, ok := mf.Properties["author"]; ok && len(authors) > 0 {
if author, ok := authors[0].(*microformats.Microformat); ok {
if names, ok := author.Properties["name"]; ok && len(names) > 0 {
if name, ok := names[0].(string); ok {
m.Author = strings.TrimSpace(name)
}
}
}
}
}
func mfHasType(mf *microformats.Microformat, typ string) bool {
for _, t := range mf.Type {
if typ == t {
return true
}
}
return false
}

Loading…
Cancel
Save