sublime里面开发博客的发布和删除工具

sublime里面开发博客的发布和删除工具

我们用sublime编写md文档是比较方便的,那编写完成之后,想要快速的发布到博客系统,目前还没有现成的工具。所以,就自己开发了一个easyblog。先支持cnblogs的发布和删除,后面可以扩展其他的博客系统,因为基本上的博客系统都差不多。主要内容就是:

  1. 账号管理系统
  2. 发布新博客或者是更新博客
  3. 删除博客
  4. 分类或者标签管理

程序的基本结构

目前先支持cnblogs,后面可以把其他的博客系统接入进来,大体都差不多,提供基本的api,获取博客信息、新博客、更新博客、上传图片等。

基础通用类

用于初始化博客的用户名、密码和博客地址等信息,这个理论上,各个博客系统可以复用。默认将各个博客系统的账号密码保存在home目录,这样,密码不会对外暴露,暂未做加密处理。

class EasyblogObject(object):
    settings_file = 'easyblog.sublime-settings'
    def __init__(self, blog_name, blog_config_tmpl):
        super()
        print(blog_name, blog_config_tmpl)
        assert blog_name is not None and isinstance(blog_name, str)
        assert blog_config_tmpl is not None and isinstance(blog_config_tmpl, dict) and len(blog_config_tmpl) > 0

        self.blog_name = blog_name
        self.blog_config_tmpl = blog_config_tmpl
        self.settings = sublime.load_settings(self.settings_file)
        self.config_dir = os.path.expanduser(self.settings.get("config_dir", "~"))
        self.config_file = os.path.join(self.config_dir, ".easyblog.json")
        self.init_item_len = 0
        self.init_item_index = 0
        self.cur_key = ''
        self.config = {}
        self.blog_config = {}

    def save_config(self, config):
        config[self.blog_name] = self.blog_config
        if not(os.path.exists(self.config_dir)):
            os.makedirs(self.config_dir)
        with open(self.config_file, mode='w') as f:
            json.dump(config, f)

    def get_config(self):
        if not(os.path.exists(self.config_file)):
            return {}
        else:
            with open(self.config_file, mode="r+") as f:
                config = json.load(f)
                if config is None or not(isinstance(config, dict)):
                    return {}
                else:
                    return config
    def init(self):
        # if len(self.blog_config_tmpl) == 0:
        #     sublime.status_message("blog_config is not valid dict: " + str(self.blog_config_tmpl))
        #     return
        self.config = self.get_config()
        if self.config.get(self.blog_name) is not None:
            if not(sublime.ok_cancel_dialog("config alread exists, do you want to override?")):
                sublime.status_message("init blog config canceled!")
                return
        self.blog_config = self.blog_config_tmpl.copy()
        self.init_item_len = len(self.blog_config)
        self.init_item_index = 0
        item = self.blog_config.popitem()
        self.cur_key = item[0]
        self.input_panel_view = sublime.active_window().show_input_panel(
            self.cur_key, item[1], self.on_done, None, self.on_cancel)

    def on_done(self, input_string):
        print("done")
        assert self.blog_config is not None
        self.blog_config[self.cur_key] = input_string
        self.init_item_index += 1
        if self.init_item_index < self.init_item_len:
            item = self.blog_config.popitem()
            self.cur_key = item[0]
            self.input_panel_view = sublime.active_window().show_input_panel(
                self.cur_key, item[1], self.on_done, None, self.on_cancel)
        else:
            self.config[self.blog_name] = self.blog_config
            self.save_config(self.config)
    
    def on_cancel(self):
        print("cancel")
        sublime.status_message("init blog config canceled!")

    def load_config(self):
        self.config = self.get_config()
        self.blog_config = self.config.get(self.blog_name)
        if self.blog_config is None or len(self.blog_config) == 0:
            sublime.error_message("Not init yet, please init first!")
            self.init()
            return False
        else:
            return True


class EasyblogCnblogsCommand(EasyblogObject):
    def __init__(self, *args):
        super().__init__(CNBLOGS, {
        # 'user_unique_name': '',                                  # 你的用户名,用于拼接文章 url
        'url': 'https://rpc.cnblogs.com/metaweblog/',              # 你的 MetaWeblog 访问地址
        'username': '',                                            # 你的登录用户名,可能跟上面的不一致
        'password': '',                                            # 你的登录密码
        })
        self.blog_posts = {} # title: blogid
        self.blog_posts_file = os.path.join(self.config_dir, ".easyblog_posts/cnblogs.json")

    def load_blog_posts(self):
        if not(os.path.exists(self.blog_posts_file)):
            sublime.status_message("Posts may not be synced!")
            return False
        else:
            with open(self.blog_posts_file, 'r') as f:
                self.blog_posts = json.load(f)
        return True

    def save_blog_posts(self):
        if not(os.path.exists(os.path.dirname(self.blog_posts_file))):
            os.makedirs(os.path.dirname(self.blog_posts_file))
        with open(self.blog_posts_file, 'w') as f:
            json.dump(self.blog_posts, f)        

    def get_cnblogs(self):
        assert self.blog_config is not None
        return Cnblogs(self.blog_config['url'], self.blog_config['username'], self.blog_config['password'])

并行上传图片,替换里面的图片链接

图片上传基本上可以通用,里面的上传功能通过callback的形式,用func进行传递,把上传的具体方法传入,不同的博客系统可以传入不同的方法:

def upload(md_path, func, write_flag=True):
    md_path = os.path.expanduser(md_path)
    change_flag = False
    with open(md_path, 'r', encoding='utf-8') as f:
        md = f.read()
        images = find_md_img(md)
        if images:  # 有本地图片,异步上传
            net_images = asyncio.run(upload_tasks(images, func))
            # print(net_images)
            image_mapping = dict(zip(images, net_images))
            md = replace_md_img(md_path, image_mapping)
            change_flag = True
        else:
            print('无需上传图片')
    if change_flag and write_flag:
        with open(md_path, 'w', encoding='utf-8') as f:
            f.write(md)
            print(f'图片链接替换完成,更新markdown')
    # print(md)
    return md

async def upload_tasks(images, func):
    tasks = []
    for img_path in images:
        task = asyncio.create_task(upload_img(img_path, func))
        task.add_done_callback(print_process)
        tasks.append(task)
    return await asyncio.gather(*tasks)

async def upload_img(path, func):
    """上传图片"""
    if os.path.exists(path):
        name = os.path.basename(path)
        _, suffix = os.path.splitext(name)
        with open(path, 'rb') as f:
            file = {
                "bits": f.read(),
                "name": name,
                "type": mime_mapping[suffix]
            }
            return func(file)
    else:
        return ''

def print_process(task):
    """回调,获取url"""
    url = task.result()
    if url:
        print(f'图片上传成功,url:{url}')
    else:
        print('图片上传失败')

def find_md_img(md):
    """查找markdown中的图片,排除网络图片(不用上传)"""
    uploaded_images = re.findall('<!-- !\\[[^\\]]*?\\]\\(([^\\)]*?)\\).*? -->', md)
    uploaded_images += re.findall('<!-- <center><img src="([^"]*?)"[^>]*?/></center> -->', md)
    print("uploaded_images", uploaded_images)
    images = re.findall("!\\[[^\\]]*?\\]\\(([^\\)]*)\\)", md)
    images += re.findall('<img src="([^"]*?)"', md)
    images = list(set(images))
    images = [img for img in images 
        if not re.match("((http(s?))|(ftp))://.*", img) and uploaded_images.count(img) == 0]
    print(f'共找到{len(images)}张本地图片{images}')
    return images

def replace_md_img(path, img_mapping):
    """替换markdown中的图片链接"""
    with open(path, 'r', encoding='utf-8') as f:
        md = f.read()
        for local, net in img_mapping.items():  # 替换图片链接
            images = list(set(re.findall("(!\\[[^\\]]*?\\]\\(%s\\)(\\{[^\\}]*?\\})?)" % local, md)))
            # print("images", images)
            for img in images:
                if isinstance(img, tuple):
                    repl = '<!-- %s --><center><img src="%s" %s/></center>' % (img[0], net, img[1][1:-1])
                    md = md.replace(img[0], repl)
                else:
                    repl = '<!-- %s --><center><img src="%s"/></center>' % (img, net)
                    md = md.replace(img, repl)
                # print("repl", repl)
            images = set(re.findall('<img src="%s"[^>]*?/>' % local, md))
            # print("images2", images)            
            for img in images:
                if len(img) > len(local) + 13:
                    params = img[len(local)+11:-2]
                    repl = '<!-- %s --><center><img src="%s" %s/></center>' % (img, net, params)
                else:
                    repl = '<!-- %s --><center><img src="%s"/></center>' % (img, net)
                # print(repl)
                md = md.replace(img, repl)
        return md  
mime_mapping = {
    ".323": "text/h323",
    ".asx": "video/x-ms-asf",
    ".acx": "application/internet-property-stream",
    ".ai": "application/postscript",
    ".aif": "audio/x-aiff",
    ".aiff": "audio/aiff",
    ".axs": "application/olescript",
    ".aifc": "audio/aiff",
    ".asr": "video/x-ms-asf",
    ".avi": "video/x-msvideo",
    ".asf": "video/x-ms-asf",
    ".au": "audio/basic",
    ".application": "application/x-ms-application",
    ".bin": "application/octet-stream",
    ".bas": "text/plain",
    ".bcpio": "application/x-bcpio",
    ".bmp": "image/bmp",
    ".cdf": "application/x-cdf",
    ".cat": "application/vndms-pkiseccat",
    ".crt": "application/x-x509-ca-cert",
    ".c": "text/plain",
    ".css": "text/css",
    ".cer": "application/x-x509-ca-cert",
    ".crl": "application/pkix-crl",
    ".cmx": "image/x-cmx",
    ".csh": "application/x-csh",
    ".cod": "image/cis-cod",
    ".cpio": "application/x-cpio",
    ".clp": "application/x-msclip",
    ".crd": "application/x-mscardfile",
    ".deploy": "application/octet-stream",
    ".dll": "application/x-msdownload",
    ".dot": "application/msword",
    ".doc": "application/msword",
    ".dvi": "application/x-dvi",
    ".dir": "application/x-director",
    ".dxr": "application/x-director",
    ".der": "application/x-x509-ca-cert",
    ".dib": "image/bmp",
    ".dcr": "application/x-director",
    ".disco": "text/xml",
    ".exe": "application/octet-stream",
    ".etx": "text/x-setext",
    ".evy": "application/envoy",
    ".eml": "message/rfc822",
    ".eps": "application/postscript",
    ".flr": "x-world/x-vrml",
    ".fif": "application/fractals",
    ".gtar": "application/x-gtar",
    ".gif": "image/gif",
    ".gz": "application/x-gzip",
    ".hta": "application/hta",
    ".htc": "text/x-component",
    ".htt": "text/webviewhtml",
    ".h": "text/plain",
    ".hdf": "application/x-hdf",
    ".hlp": "application/winhlp",
    ".html": "text/html",
    ".htm": "text/html",
    ".hqx": "application/mac-binhex40",
    ".isp": "application/x-internet-signup",
    ".iii": "application/x-iphone",
    ".ief": "image/ief",
    ".ivf": "video/x-ivf",
    ".ins": "application/x-internet-signup",
    ".ico": "image/x-icon",
    ".jpg": "image/jpeg",
    ".jfif": "image/pjpeg",
    ".jpe": "image/jpeg",
    ".jpeg": "image/jpeg",
    ".js": "application/x-javascript",
    ".lsx": "video/x-la-asf",
    ".latex": "application/x-latex",
    ".lsf": "video/x-la-asf",
    ".manifest": "application/x-ms-manifest",
    ".mhtml": "message/rfc822",
    ".mny": "application/x-msmoney",
    ".mht": "message/rfc822",
    ".mid": "audio/mid",
    ".mpv2": "video/mpeg",
    ".man": "application/x-troff-man",
    ".mvb": "application/x-msmediaview",
    ".mpeg": "video/mpeg",
    ".m3u": "audio/x-mpegurl",
    ".mdb": "application/x-msaccess",
    ".mpp": "application/vnd.ms-project",
    ".m1v": "video/mpeg",
    ".mpa": "video/mpeg",
    ".me": "application/x-troff-me",
    ".m13": "application/x-msmediaview",
    ".movie": "video/x-sgi-movie",
    ".m14": "application/x-msmediaview",
    ".mpe": "video/mpeg",
    ".mp2": "video/mpeg",
    ".mov": "video/quicktime",
    ".mp3": "audio/mpeg",
    ".mpg": "video/mpeg",
    ".ms": "application/x-troff-ms",
    ".nc": "application/x-netcdf",
    ".nws": "message/rfc822",
    ".oda": "application/oda",
    ".ods": "application/oleobject",
    ".pmc": "application/x-perfmon",
    ".p7r": "application/x-pkcs7-certreqresp",
    ".p7b": "application/x-pkcs7-certificates",
    ".p7s": "application/pkcs7-signature",
    ".pmw": "application/x-perfmon",
    ".ps": "application/postscript",
    ".p7c": "application/pkcs7-mime",
    ".pbm": "image/x-portable-bitmap",
    ".ppm": "image/x-portable-pixmap",
    ".pub": "application/x-mspublisher",
    ".pnm": "image/x-portable-anymap",
    ".png": "image/png",
    ".pml": "application/x-perfmon",
    ".p10": "application/pkcs10",
    ".pfx": "application/x-pkcs12",
    ".p12": "application/x-pkcs12",
    ".pdf": "application/pdf",
    ".pps": "application/vnd.ms-powerpoint",
    ".p7m": "application/pkcs7-mime",
    ".pko": "application/vndms-pkipko",
    ".ppt": "application/vnd.ms-powerpoint",
    ".pmr": "application/x-perfmon",
    ".pma": "application/x-perfmon",
    ".pot": "application/vnd.ms-powerpoint",
    ".prf": "application/pics-rules",
    ".pgm": "image/x-portable-graymap",
    ".qt": "video/quicktime",
    ".ra": "audio/x-pn-realaudio",
    ".rgb": "image/x-rgb",
    ".ram": "audio/x-pn-realaudio",
    ".rmi": "audio/mid",
    ".ras": "image/x-cmu-raster",
    ".roff": "application/x-troff",
    ".rtf": "application/rtf",
    ".rtx": "text/richtext",
    ".sv4crc": "application/x-sv4crc",
    ".spc": "application/x-pkcs7-certificates",
    ".setreg": "application/set-registration-initiation",
    ".snd": "audio/basic",
    ".stl": "application/vndms-pkistl",
    ".setpay": "application/set-payment-initiation",
    ".stm": "text/html",
    ".shar": "application/x-shar",
    ".sh": "application/x-sh",
    ".sit": "application/x-stuffit",
    ".spl": "application/futuresplash",
    ".sct": "text/scriptlet",
    ".scd": "application/x-msschedule",
    ".sst": "application/vndms-pkicertstore",
    ".src": "application/x-wais-source",
    ".sv4cpio": "application/x-sv4cpio",
    ".tex": "application/x-tex",
    ".tgz": "application/x-compressed",
    ".t": "application/x-troff",
    ".tar": "application/x-tar",
    ".tr": "application/x-troff",
    ".tif": "image/tiff",
    ".txt": "text/plain",
    ".texinfo": "application/x-texinfo",
    ".trm": "application/x-msterminal",
    ".tiff": "image/tiff",
    ".tcl": "application/x-tcl",
    ".texi": "application/x-texinfo",
    ".tsv": "text/tab-separated-values",
    ".ustar": "application/x-ustar",
    ".uls": "text/iuls",
    ".vcf": "text/x-vcard",
    ".wps": "application/vnd.ms-works",
    ".wav": "audio/wav",
    ".wrz": "x-world/x-vrml",
    ".wri": "application/x-mswrite",
    ".wks": "application/vnd.ms-works",
    ".wmf": "application/x-msmetafile",
    ".wcm": "application/vnd.ms-works",
    ".wrl": "x-world/x-vrml",
    ".wdb": "application/vnd.ms-works",
    ".wsdl": "text/xml",
    ".xap": "application/x-silverlight-app",
    ".xml": "text/xml",
    ".xlm": "application/vnd.ms-excel",
    ".xaf": "x-world/x-vrml",
    ".xla": "application/vnd.ms-excel",
    ".xls": "application/vnd.ms-excel",
    ".xof": "x-world/x-vrml",
    ".xlt": "application/vnd.ms-excel",
    ".xlc": "application/vnd.ms-excel",
    ".xsl": "text/xml",
    ".xbm": "image/x-xbitmap",
    ".xlw": "application/vnd.ms-excel",
    ".xpm": "image/x-xpixmap",
    ".xwd": "image/x-xwindowdump",
    ".xsd": "text/xml",
    ".z": "application/x-compress",
    ".zip": "application/x-zip-compressed",
    ".*": "application/octet-stream",
}

cnblogs的方法

import xmlrpc.client
import ssl
import os
import sys

class Cnblogs:
    def __init__(self, url, username, password):
        self.url, self.username, self.password = url, username, password
        self.proxy = xmlrpc.client.ServerProxy(self.url)

    def deletePost(self, post_id):
        '''
        删除博文
        '''
        return self.proxy.blogger.deletePost('', post_id, self.username, self.password, True)
    
    def getUsersBlogs(self):
        '''
        获取空间的标语
        '''
        return self.proxy.blogger.getUsersBlogs('', self.username, self.password)        
    
    def editPost(self, post_id, article):
        '''
        编辑已经上传的博文,编程成功返回True。如果是md格式,必须包含 'categories':['[Markdown]'],也可以包含其他的分类
        '''
        return self.proxy.metaWeblog.editPost(post_id, self.username, self.password,
                                          dict(title=article['title'],
                                            categories=article['categories'],
                                              description=article['content'],
                                               mt_keywords=article['tags']),
                                          True)

    def getCategories(self):
        '''
        获取分类
        '''
        usersBlogs = self.getUsersBlogs()
        if usersBlogs is not None and isinstance(usersBlogs, list) and len(usersBlogs) > 0:
            blog_id = usersBlogs[0]['blogid']
            return self.proxy.metaWeblog.getCategories(blog_id, self.username, self.password)   
    
    def getPost(self, post_id):
        '''
        获取博文
        '''
        return self.proxy.metaWeblog.getPost(post_id, self.username, self.password)

    def getRecentPosts(self, count):
        '''
        获取最近多少篇博文,最多99
        '''
        return self.proxy.metaWeblog.getRecentPosts('', self.username, self.password, count)


    def newMediaObject(self, file_obj):
        '''
        发布新的博文,返回post_id字符串. 如果是md格式,必须包含 'categories':['[Markdown]'],也可以包含其他的分类
        '''
        return self.proxy.metaWeblog.newMediaObject('', self.username, self.password, 
                                                    dict(bits=file_obj['bits'],
                                                        name=file_obj['name'],
                                                        type=file_obj['type']))

    def newPost(self, article):
        '''
        发布新的博文,返回post_id字符串. 如果是md格式,必须包含 'categories':['[Markdown]'],也可以包含其他的分类
        '''
        return self.proxy.metaWeblog.newPost('', self.username, self.password,
                                             dict(title=article['title'],
                                                categories=article['categories'],
                                                description=article['content'],
                                                  mt_keywords=article['tags']),
                                             True)
    
    def newCategory(self, category):
        '''
        创建新的分类,返回id,int类型
        '''
        usersBlogs = self.getUsersBlogs()
        if usersBlogs is not None and isinstance(usersBlogs, list) and len(usersBlogs) > 0:
            blog_id = usersBlogs[0]['blogid']        
            return self.proxy.wp.newCategory(blog_id, self.username, self.password, category)

cnblogs在sublime中的命令

  • easyblog_cnblogs_init 进行账户、密码、网址的设定
  • easyblog_cnblogs_sync 进行最近99篇文章的同步,把文章title和postid的映射关系建立好,后面发布的时候可以判断是新增还是更新
  • easyblog_cnblogs_post 进行文章的发布,会确保已经初始化和同步过文章,然后,判断是新增还是更新博客,其中图片会并行上传,替换后会把文章更新掉,后面改动,不会上传相同的图片,加快速度,对于要发布到多个博客系统的情况,要把链接替换文章的开关关掉,不然,有些博客系统对于引用其他博客的图片有限制
  • easyblog_cnblogs_delete 进行发布文章的删除,这个功能有时可用,有时不可用,估计和博客系统有关系。
class EasyblogCnblogsCommand(EasyblogObject):
    def __init__(self, *args):
        super().__init__(CNBLOGS, {
        # 'user_unique_name': '',                                  # 你的用户名,用于拼接文章 url
        'url': 'https://rpc.cnblogs.com/metaweblog/',              # 你的 MetaWeblog 访问地址
        'username': '',                                            # 你的登录用户名,可能跟上面的不一致
        'password': '',                                            # 你的登录密码
        })
        self.blog_posts = {} # title: blogid
        self.blog_posts_file = os.path.join(self.config_dir, ".easyblog_posts/cnblogs.json")

    def load_blog_posts(self):
        if not(os.path.exists(self.blog_posts_file)):
            sublime.status_message("Posts may not be synced!")
            return False
        else:
            with open(self.blog_posts_file, 'r') as f:
                self.blog_posts = json.load(f)
        return True

    def save_blog_posts(self):
        if not(os.path.exists(os.path.dirname(self.blog_posts_file))):
            os.makedirs(os.path.dirname(self.blog_posts_file))
        with open(self.blog_posts_file, 'w') as f:
            json.dump(self.blog_posts, f)        

    def get_cnblogs(self):
        assert self.blog_config is not None
        return Cnblogs(self.blog_config['url'], self.blog_config['username'], self.blog_config['password'])


class EasyblogCnblogsInitCommand(EasyblogCnblogsCommand, sublime_plugin.WindowCommand):
    def run(self):
        self.init()

class EasyblogCnblogsSyncCommand(EasyblogCnblogsCommand, sublime_plugin.WindowCommand):
    def run(self):
        if not(self.load_config()):
            return

        self.blog_posts = {}            
        self.load_blog_posts()

        cnblogs = self.get_cnblogs()
        posts = cnblogs.getRecentPosts(99)
        if posts is None or not(isinstance(posts, list)) or len(posts) == 0:
            sublime.status_message("No posts to sync!")
        else:        
            for post in posts:
                # print(post)
                self.blog_posts[post['title']] = post['postid']

        self.save_blog_posts()


class EasyblogCnblogsPostCommand(EasyblogCnblogsCommand, sublime_plugin.TextCommand):
    def __init__(self, view):
        print(view)
        super().__init__()
        self.view = view
    
    def run(self, _):
        if not(self.load_config()):
            return
        if not(self.load_blog_posts()):
            self.view.run_command("easyblog_cnblogs_sync")
        
        if self.view is not None:
            file_name = self.view.file_name()
            print(file_name)
            if file_name is not None and file_name.endswith('.md'):
                self.view.run_command("save")
                cnblogs = self.get_cnblogs()
                title = os.path.splitext(os.path.basename(file_name))[0]
                blogid = self.blog_posts.get(title) 

                content = upload(file_name, lambda f: self.upload_img(cnblogs, f), True)
                article = {
                    'title': title,
                    'categories': ['[Markdown]'],
                    'tags': os.path.basename(os.path.dirname(file_name)),
                    'content': content
                }
                # print(blogid, article)
                
                if blogid is None:
                    blogid = cnblogs.newPost(article)
                    self.blog_posts[title] = blogid
                    self.save_blog_posts()
                    sublime.status_message('blog has been posted!')
                else:
                    cnblogs.editPost(blogid, article)
                    sublime.status_message('blog has been edit!')
            else:
                sublime.status_message("Only .md files support!")
        else:
            sublime.status_message("No view selected.")

    def upload_img(self, cnblogs: Cnblogs, file_obj: dict):
        result = cnblogs.newMediaObject(file_obj)
        # result = {'url': file_obj['name']}
        if result is not None and isinstance(result, dict):
            return result['url']
        else:
            return ''


class EasyblogCnblogsDeleteCommand(EasyblogCnblogsCommand, sublime_plugin.TextCommand):
    def __init__(self, view):
        super().__init__()
        self.view = view

    def run(self, _):
        if not(self.load_config()):
            return
        if not(self.load_blog_posts()):
            self.view.run_command("easyblog_cnblogs_sync")
        
        if self.view is not None:
            file_name = self.view.file_name()
            if file_name is not None and file_name.endswith('.md'):
                self.view.run_command("save")
                title = os.path.splitext(os.path.basename(file_name))[0]
                blogid = self.blog_posts.get(title)
                print(blogid)
                if blogid is None:
                    sublime.status_message('Blog has not been posted yet!')
                else:
                    cnblogs = self.get_cnblogs()
                    if cnblogs.deletePost(blogid):
                        del self.blog_posts[title]
                        self.save_blog_posts()
                        sublime.status_message('Blog has been deleted!')
                    else:
                        sublime.status_message('Blog delet failed!')
            else:
                sublime.status_message("Only .md files support!")
        else:
            sublime.status_message("No view selected.")
posted @ 2022-04-29 14:09  yangwen0228  阅读(40)  评论(0编辑  收藏  举报