代码拉取完成,页面将自动刷新
from PyQt5.QtWidgets import *
from PyQt5.QtCore import Qt, QThread, pyqtSignal
from PyQt5 import QtWidgets, QtGui, QtCore
import iNote
from search import Search
from front_matter import FrontMatter
from export import Convertor
from insert import Attach
import platform
# print(platform.platform())
pinfo = platform.platform()
if pinfo.startswith('Windows'):
import ctypes
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("myappid")
import win32con,win32api
import codecs
import pythoncom
from win32com.shell import shell
from win32com.shell import shellcon
import os, shutil, glob, time, re, sys, subprocess, pyperclip
from datetime import datetime
import webbrowser
from functools import partial
import random
class xxd:
windows = []
def __init__(self):
self.version = 'v1.2'
self.pinfo = platform.platform()
self.user_desktop = os.path.expanduser('~/Desktop')
if not os.path.exists(self.user_desktop):
# 有一些中文的linux操作系统是桌面
self.user_desktop = os.path.expanduser('~/桌面')
if not os.path.exists(self.user_desktop):
self.print('找不到桌面路径,请输入:')
self.user_desktop = QFileDialog.getExistingDirectory(self.MainWindow, '选取文件夹', os.path.expanduser('~/'))
QApplication.setAttribute(Qt.AA_EnableHighDpiScaling)
# 设置高DPI屏幕自适应
self.app = QApplication(sys.argv)
self.MainWindow = QMainWindow()
self.ui = iNote.Ui_MainWindow()
self.ui.setupUi(self.MainWindow)
# 移动一下窗体的位置,默认是在正中间,我们调整到左侧
desktop = QApplication.desktop()
self.MainWindow.move(int(desktop.width()*0.1), int(desktop.height()*0.1))
# 设置窗体风格为更好看一点的 Fusion
QApplication.setStyle(QStyleFactory.create('Fusion'))
# 更多样式美化可以参考 https://www.jianshu.com/p/543366adb423
self.MainWindow.show()
# 设置 menu菜单栏的最大宽度
max_width = 140
self.ui.menuFile.setMaximumWidth(max_width-25)
self.ui.menuSettings.setMaximumWidth(max_width-21)
self.ui.menuCategories.setMaximumWidth(max_width-17)
self.ui.menuHelp.setMaximumWidth(max_width-33)
# 获取程序所在根目录
self.rootdir = os.path.dirname(sys.argv[0])
self.typora = os.path.join(self.rootdir, 'plugin', 'typora', 'Typora.exe')
self.git = os.path.join(self.rootdir, 'plugin', 'git', 'bin', 'git.exe')
self.reg = os.path.join(self.rootdir, 'iNote.reg')
if not os.path.exists(self.reg):
# 写注册表文件,方便把 iNote 添加到 windows 系统的右键菜单
self.writeRegFile()
if not os.path.exists(self.git):
# 如果 plugin 中没有 git,就用自身的 git 命令
self.git = 'git'
# 获取目录路径
try:
# 方便后面右键菜单直接打开文件夹使用
self.wks = sys.argv[1]
if not os.path.exists(sys.argv[1]):
self.print('Folder not existed! Please choose a folder in this device!!')
self.wks = self.user_desktop
except:
dir_record = self.rootdir+'/.inote'
if os.path.exists(dir_record):
with open(self.rootdir+'/.inote', 'r', encoding='utf-8') as f:
self.wks = os.path.abspath(f.read().strip())
if not os.path.exists(self.wks):
self.wks = self.user_desktop
else:
self.wks = self.user_desktop
if len(self.wks)<1:
# 取消选择会返回空值
self.wks = os.path.abspath(self.rootdir)
self.print('Folder -> [{}]'.format(self.wks))
# 修改主窗体名字
self.archives = ['Favorites', 'References', 'Methods', 'Materials', 'Results', 'Discussion', 'Summary']
_, self.folder_name = os.path.split(self.wks)
self.MainWindow.setWindowTitle('{} - iNote'.format(self.folder_name))
# 修改folder的icon,与iTool统一
if not os.path.exists(self.wks+'/desktop.ini'):
self.changeFolderIcon()
# 创建当天文稿
# 更新 categoires.md 和 tags.md 文件,都属于 info page
# if len(glob.glob(os.path.join(self.wks, '*.md')))>0:
# self.updateInfoPage()
# 显示目录文稿列表
self.showBox = False
self.updateFileList('all')
# if self.folder_name.startswith('2'):
# # 如果是2开头的就是主日志目录,只有这个目录下才能拥有唯一的当天日期命名的文稿,这样可以更好的兼容 obsidian
# self.newDraft(mode='auto')
# 连接随机显示内容的
history_fp = self.wks+'/history.md'
message_fp = self.wks+'/message.md'
if os.path.exists(history_fp) or os.path.exists(message_fp):
self.work = WorkThread([history_fp, message_fp])
self.work.start()
self.work.trigger.connect(self.print)
# 连接槽函数
self.connectAction()
# 自动提交已有修改到本地仓库,开发模式下关闭,打包编译时注意开启
if os.path.exists(os.path.join(self.wks, '.gitignore')):
# 如果目录下存在 gitignore 文件才启用自动 commit
self.auto_commit()
# 结束运行,安全退出
sys.exit(self.app.exec_())
def fetchFrontMatter(self, fp):
'''
读取文件头,文件头若按模板来,默认有以下几项:
title, date, categories, tags, abstract, ...
为了方便,设置最多 5 项
'''
try:
worker = FrontMatter(fp)
return worker.info
except:
print(fp)
def updateInfoPage(self):
'''
读取所有的文件头几行(固定数目),获取 yaml 头信息,如果有分类和标签,
就分别写入到 categories.md 和 tags.md 文件中。
'''
files = glob.glob(os.path.join(self.wks, '*.md'))
# 批量提取每个文稿的 front matter
categories = {} # 收集每个分类下的文稿标题和文件名
tags = {} # 收集每个标签的累积计数
for fp in files:
info = self.fetchFrontMatter(fp)
_, fname = os.path.split(fp)
if "title" in info.keys():
if "categories" in info.keys():
if len(info["categories"])>1:
cats = info["categories"].split(" ")
for c in cats:
if c in ["草稿"]:
# 过滤掉草稿
continue
abstract = ""
try:
categories[c] += f"- 《[{info['title']}]({fname})》{abstract}\n"
except:
# 新建一个 category
categories[c] = f"\n\n## {c}\n\n"
categories[c] += f"- 《[{info['title']}]({fname})》{abstract}\n"
if "tags" in info.keys():
# if len(info["tags"])>1:
if type(info["tags"]) is str:
ts = info["tags"].split(" ")
for t in ts:
if t in ["空白"] or len(t)<=1:
# 不允许长度小于1的标题
continue
try:
tags[t] += 1
except:
# 新建一个 tag
tags[t] = 1
# tags 按值降序排列
tags = {k: v for k, v in sorted(tags.items(), key=lambda item: item[1], reverse=True)}
# 将信息分别写入 categories.md 和 tags.md 文件中
with open(self.wks+"/categories.md", "w", encoding="utf-8") as f:
content = "---\ntitle: 文章分类汇总页面\ndate: 2000-12-18 12:18:30\nabstract: 点击分类下条目的引用链接可直接跳转\n---\n# 分类目录\n[TOC]\n"
for key in categories:
content += categories[key]
f.write(content)
with open(self.wks+"/tags.md", "w", encoding="utf-8") as f:
content = "---\ntitle: 文章标签收集页面\ndate: 2000-12-18 12:18:30\nabstract: 标签按出现频次排序,请自行根据标签搜索文稿\n---\n\n"
for key in tags:
content += f"🔖`{key}`({tags[key]}) "
f.write(content)
def writeRegFile(self):
exe = os.path.join(self.rootdir, 'iNote.exe')
content = '''Windows Registry Editor Version 5.00
[HKEY_CLASSES_ROOT\Directory\Background\shell\iNote]
@="Open with iNote"
"icon"="\\\"{path}\\\""
[HKEY_CLASSES_ROOT\Directory\Background\shell\iNote\command]
@="\\\"{path}\\\" \\\"%V\\\""
[HKEY_CLASSES_ROOT\Directory\shell\iNote]
@="Open with iNote"
"icon"="\\\"{path}\\\""
[HKEY_CLASSES_ROOT\Directory\shell\iNote\command]
@="\\"{path}\\" \\"%V\\""'''.format(path=repr(os.path.abspath(exe)).strip("'"))
# repr() 可以将字符串以不转义方式传输
if os.path.exists(exe):
with open(self.reg, 'w', encoding='mbcs') as f:
# https://www.jianshu.com/p/ba829c96df17
# https://blog.csdn.net/jgwei/article/details/5986569
f.write(content)
def changeFolderIcon(self):
'''
仅支持 windows 系统,
通过 desktop.ini 来修改
'''
exe_path = os.path.join(os.path.abspath(self.rootdir), 'iNote.exe')
# ico_path = os.path.join(os.path.abspath(self.rootdir), 'icons', 'book.ico')
# desktop.ini 的内容
if os.path.exists(exe_path):
self.makeDesktopINI(exe_path)
else:
pass
def makeDesktopINI(self, icon_path):
ini_str = '[.ShellClassInfo]\r\nIconResource={},0\r\n[ViewState]\r\nMode=\r\nVid=\r\nFolderType=Generic\r\n'.format(icon_path)
desktop_ini = os.path.join(self.wks, 'desktop.ini')
if os.path.exists(desktop_ini):
os.remove(desktop_ini)
with codecs.open(desktop_ini, 'w', 'utf-8') as f:
f.write(ini_str)
win32api.SetFileAttributes(desktop_ini, win32con.FILE_ATTRIBUTE_HIDDEN + win32con.FILE_ATTRIBUTE_SYSTEM)
win32api.SetFileAttributes(self.wks, win32con.FILE_ATTRIBUTE_READONLY)
def startfile(self, path):
'''
一个o适用于多平台的方式
'''
if os.path.exists(path):
if self.pinfo.startswith("Windows"):
os.startfile(path)
elif self.pinfo.startswith('Linux'):
os.system(f'xdg-open "{path}"')
else:
os.system(f'open "{path}"')
else:
self.print("File Not Existed!")
def chooseFolder(self):
'''
弹窗,让用户选择一个文件夹,从桌面开始
'''
start_dir = self.wks
postdir = QFileDialog.getExistingDirectory(self.MainWindow,
"选取文件夹",
start_dir)
return postdir
def print(self, content):
self.ui.textBrowser.setPlainText(content)
def getFileCreatedTime(self, fp):
'''
自定义方法获取文件的创建时间并返回,
优先返回 frontMatter 中的时间(浮点数)如果没有就返回 os.path.getmtime
'''
base, ext = os.path.splitext(fp)
_, fname = os.path.split(base)
try:
format = '%Y%m%d_%H%M%S'
ctime = datetime.strptime(fname, format).timestamp()
except:
# 如果文件名前面是数字开头 YYYYMMDD 格式,也可以读取
try:
date_string = fname[:8]
format = '%Y%m%d'
ctime = datetime.strptime(date_string, format).timestamp()
except:
ctime = os.path.getmtime(fp)
with open(fp, 'r', encoding='utf-8') as f:
line = f.readline()
if line.startswith('---'):
for i in range(5):
# 读取前面5行,寻找 date
line2 = f.readline()
if line2.startswith('date: '):
# date: 2021-06-04 09:12:50
date_string = line2.strip('\n')[6:]
try:
format = '%Y-%m-%d %H:%M:%S'
ctime = datetime.strptime(date_string, format).timestamp()
except:
self.print('[时间戳格式错误]: {}'.format(fp))
return ctime
def inArchive(self, fpath, cname):
'''
读取文件最后一行获取 archives 信息
'''
xx = self.getArchive(fpath)
if xx==cname:
return True
else:
return False
def getArchive(self, fpath):
cname = None
with open(fpath, 'r', encoding='utf-8') as f:
content = f.read().strip()
lines = content.split("\n")
end_line = lines[-1]
if end_line.startswith('archives:'):
info = end_line
_, xx = info.split(' ')
if xx in self.archives:
cname = xx
return cname
def updateFileList(self, mode='all'):
'''
获取目录下 markdown 文件列表
默认按照创建时间排序
根据标题emoji置顶
mode 由 search 中传入字符串决定刷新
'''
files = glob.glob(os.path.join(self.wks, '*.md'))
# 这里的 files 是绝对路径
if mode == 'all':
pass
elif mode.startswith('date:'):
# 可以按照日期区间来获取
# 使用 filter 函数
# 传入 mode 应为字符串 date:yyyymmdd:n
# 表示从哪一天开始之后多少天内,或者多少天之前,n只能为正整数
try:
_, date, n = mode.split(':')
format_ = '%Y%m%d'
one_day_sec = 24*3600
n = float(n)
ctime = datetime.strptime(date, format_).timestamp()
ctime_ = ctime + n*one_day_sec
if n>0:
files = filter(lambda x: self.getFileCreatedTime(x)>=ctime and self.getFileCreatedTime(x)<=ctime_, files)
if n<0:
files = filter(lambda x: self.getFileCreatedTime(x)>=ctime_ and self.getFileCreatedTime(x)<=ctime, files)
except Exception as e:
self.print(str(e))
elif mode.startswith('archives:'):
_, cname = mode.split(':')
files = filter(lambda x: self.inArchive(x, cname), files)
elif mode.startswith('list='):
# 增加一个列表传入
files = mode.split('=')[1].split(',')
files = sorted(files, key=self.getFileCreatedTime, reverse=True)
self.ui.listWidget.clear()
try:
titles = self.fetchTitle(files)
if mode=='choose' and self.current_titles is not None:
self.insertItems(self.current_titles)
else:
self.ui.listWidget.addItems(titles)
self.current_titles = titles
except Exception as e:
self.print(str(e))
if self.showBox:
self.showBox = False
self.ui.listWidget.update()
self.print(f'[{len(files)}] files found!')
def insertItems(self, data_list):
'''
往 ListWidget 中插入更加复杂的内容,比如勾选框
'''
for i in data_list:
box = QCheckBox(i)
item = QListWidgetItem()
self.ui.listWidget.addItem(item)
self.ui.listWidget.setItemWidget(item, box)
def getChoose(self):
'''
得到选择的列表
'''
count = self.ui.listWidget.count()
c_list = [self.ui.listWidget.itemWidget(self.ui.listWidget.item(i)) for i in range(count)]
chooses = []
for c in c_list:
if type(c) is QtWidgets.QCheckBox:
# 避免为空的时候出现 bug
if c.isChecked():
chooses.append(c.text())
return chooses
def shuttleChoiceBox(self):
if self.showBox:
self.updateFileList(mode='all')
self.showBox = False
else:
self.updateFileList(mode='choose')
self.showBox = True
@staticmethod
def isEmoji(content):
'''
staticmethod,返回函数的静态方法,可以不用实例化直接调用
'''
if not content:
return False
if content in ['🚀', '🔥', '⏳']:
return True
else:
return False
def connectAction(self):
'''
连接各种槽函数
'''
### File 菜单按钮
self.ui.actionNew.triggered.connect(partial(self.newDraft, 'dialog'))
self.ui.actionGit.triggered.connect(self.commit)
self.ui.actionImport.triggered.connect(self.importItem)
self.ui.actionRootDir.triggered.connect(partial(self.startfile, self.wks))
self.ui.actionBackup.triggered.connect(self.backup)
self.ui.actionChangeDir.triggered.connect(self.changeDir)
# Settings 菜单
self.ui.isWindowTop = False
self.ui.actionKeepTop.triggered.connect(self.keepWindowTop)
self.ui.actionShortcut.triggered.connect(self.makeShortcut)
self.ui.actionCategories.triggered.connect(partial(self.openPage, "categories"))
self.ui.actionTags.triggered.connect(partial(self.openPage, "tags"))
self.ui.actionHistory.triggered.connect(partial(self.openPage, "history"))
self.showBox = False
self.ui.actionChoose.triggered.connect(self.shuttleChoiceBox)
# Search 按钮
self.ui.search.clicked.connect(self.search)
# Search 结果列表
self.ui.listWidget.itemDoubleClicked.connect(self.openFile)
# Search 结果的右键菜单
self.ui.listWidget.setContextMenuPolicy(Qt.CustomContextMenu)
self.ui.listWidget.customContextMenuRequested.connect(self.custom_right_menu)
# Archives 菜单
self.ui.actionFavorites.triggered.connect(partial(self.updateFileList, 'archives:Favorites'))
self.ui.actionReferences.triggered.connect(partial(self.updateFileList, 'archives:References'))
self.ui.actionMethods.triggered.connect(partial(self.updateFileList, 'archives:Methods'))
self.ui.actionMaterials.triggered.connect(partial(self.updateFileList, 'archives:Materials'))
self.ui.actionResults.triggered.connect(partial(self.updateFileList, 'archives:Results'))
self.ui.actionDiscussion.triggered.connect(partial(self.updateFileList, 'archives:Discussion'))
self.ui.actionSummary.triggered.connect(partial(self.updateFileList, 'archives:Summary'))
# Help 菜单
readme_url = 'https://gitee.com/sheldonxxd/inote/blob/master/readme.md'
update_url = 'https://gitee.com/sheldonxxd/inote/releases'
issue_url = 'https://gitee.com/sheldonxxd/inote/issues'
self.ui.actionReadme.triggered.connect(partial(webbrowser.open, readme_url))
self.ui.actionInfo.triggered.connect(partial(self.print, f'iNote version: {self.version}'))
self.ui.actionUpdate.triggered.connect(partial(webbrowser.open, update_url))
self.ui.actionIssue.triggered.connect(partial(webbrowser.open, issue_url))
def changeDir(self):
'''
修改工作目录
'''
newwks = self.chooseFolder()
if len(newwks)>2 and os.path.exists(newwks):
self.wks = newwks
# content, ok = QInputDialog.getText(self.MainWindow,
# "iNote", "切换到文件夹:", QLineEdit.Normal, "")
# if ok:
# if len(content)>2 and os.path.exists(content):
# self.wks = content
# else:
# self.print('无法切换到不存在的目录!')
self.updateFileList()
_, self.folder_name = os.path.split(self.wks)
self.MainWindow.setWindowTitle('{} - iNote'.format(self.folder_name))
# 保存工作目录的记录,从上一次的历史目录打开。
with open(self.rootdir+'/.inote', 'w', encoding='utf-8') as f:
f.write(self.wks)
def backup(self):
'''
备份当前项目文件到指定目录(移动硬盘)
采取增量备份的方式
如果要恢复,直接使用 Import 选中全部 zip 即可
'''
bk_config_file = self.wks+'/.backup'
if os.path.exists(bk_config_file):
with open(bk_config_file, 'r', encoding='utf-8') as f:
info = f.readline().strip()
if os.path.exists(info):
bdir = info
else:
bdir = self.chooseFolder()
with open(bk_config_file, 'w', encoding='utf-8') as f:
f.write(bdir)
else:
bdir = self.chooseFolder()
with open(bk_config_file, 'w', encoding='utf-8') as f:
f.write(bdir)
filelist = glob.glob(os.path.join(self.wks, '*.md'))
filelist2 = []
c = 0
for fp in filelist:
title = self.readTitle(fp)
title = self.validateTitle(title)
mtime = os.path.getmtime(fp)
base, ext = os.path.splitext(fp)
_, fname = os.path.split(base)
fp2 = os.path.join(bdir, f'{title}_{fname}.iNote.zip')
filelist2.append(fp2)
if os.path.exists(fp2):
mtime2 = os.path.getmtime(fp2)
if mtime2>=mtime:
continue
if fname in ['categories', 'tags', 'history']:
# 不对这三个进行备份
continue
self._tozip(fp, bdir)
c += 1
self.print(f'{c} items backup!')
# 如果标题修改,之前备份的文件名失效,直接清除
filelist3 = glob.glob(bdir+'/*.iNote.zip')
for fp in filelist3:
if fp not in filelist2:
# os.remove(fp)
# 2021-08-12 18:46:34
# 直接删除还是不大好,可以放到内部的.trash目录中
trash_dir = os.path.join(bdir, '.trash')
if not os.path.exists(trash_dir):
os.mkdir(trash_dir)
_, fpath = os.path.split(fp)
shutil.move(fp, trash_dir+'/'+fpath)
def openPage(self, name):
'''
打开特定页面,比如 categories, tags, history
'''
fp = os.path.join(self.wks, name+'.md')
self.updateInfoPage() # 更新一下
if os.path.exists(fp):
self.openFile_typora(fp)
else:
self.print(f"{name} not existed!")
def makeShortcut(self):
'''
创建快捷方式指向 self.wks 并且使用 iNote.exe 打开
'''
rootdir = self.rootdir
wks = self.wks
exe = os.path.join(rootdir, 'iNote.exe')
iconname = exe
_, name = os.path.split(wks)
if self.pinfo.startswith("Windows"):
try:
lnkname = os.path.join(self.user_desktop, f'{name}.lnk')
if os.path.exists(lnkname):
self.print(f'{lnkname}.lnk 已存在于桌面')
shortcut = pythoncom.CoCreateInstance(
shell.CLSID_ShellLink, None,
pythoncom.CLSCTX_INPROC_SERVER, shell.IID_IShellLink)
shortcut.SetPath(exe)
shortcut.SetArguments(wks)
shortcut.SetWorkingDirectory(wks) # 设置快捷方式的起始位置, 不然会出现找不到辅助文件的情况
shortcut.SetIconLocation(iconname, 0) # 可有可无,没有就默认使用文件本身的图标
if os.path.splitext(lnkname)[-1] != '.lnk':
lnkname += ".lnk"
shortcut.QueryInterface(pythoncom.IID_IPersistFile).Save(lnkname, 0)
return True
except Exception as e:
self.print(e.args)
return False
elif self.pinfo.startswith("Linux"):
content=f'[Desktop Entry]\nName={name}\nExec={rootdir}/iNote {wks}\nType=Application'
with open(self.user_desktop+f'/{name}.desktop', 'w', encoding='utf-8') as f:
f.write(content)
else:
self.print('Not supported for Mac user yet!')
def newDraft(self, mode='dialog'):
# 草稿没有 front matter,带有 front matter 的可以是 newPost
if mode=='dialog':
fname, ok = QInputDialog.getText(self.MainWindow,
"新建文档", "文件名:", QLineEdit.Normal, "请输入标题")
dd = self.getDateString(fmt='%Y-%m-%d')
if ok:
if fname=='请输入标题':
self.print('No draft created!')
ok = False
else:
title = fname
fname = dd+'_'+fname
elif mode=='auto':
ok = True
fname = self.getDateString(fmt='%Y-%m-%d')
title = f'📅日志{fname}'
else:
ok = False
if ok:
fname = self.validateTitle(fname)
fpath = os.path.join(self.wks, '{}.md'.format(fname))
if not os.path.exists(fpath):
self.write(fpath, title)
self.print('[{}.md] created!'.format(fname))
else:
self.print('File already exists!')
# 打开文件,统一打开方式
self.openFile_typora(fpath)
# 刷新文件列表
self.updateFileList()
else:
self.print('No draft created!')
def openFile_typora(self, fpath):
'''
尝试使用 typora 打开,如果 plugin 中没有 typora 就用系统默认的方式打开文件
fp:文件的绝对路径
'''
# if os.path.exists(self.typora):
# subprocess.Popen('{} {}'.format(self.typora, fpath), shell=True)
# else:
# self.startfile(fpath)
# 2022-02-14 16:30:57 typora停止支持无法使用,使用vscode打开。
self.startfile(fpath)
def write(self, fpath, title='日志标题'):
'''
在目录下新增 markdown 文件
'''
timeStamp = self.getDateString('%Y-%m-%d %H:%M:%S')
today_str = self.getDateString(fmt='%Y-%m-%d')
backlink = f'\nfrom: [[{today_str}]]\n' # 创建指向主干目录中的当日主日志的wiki链接
content=f'''\
---
title: {title}
date: {timeStamp}
tags: 空白
categories: 草稿
excerpt: 这是摘要
emojis: 🚀 🔥 ⏳ ⭐ 🎈 🔨 🛒 🔍 📊 💎 🎯 🌈 👀 🎨 🚌 🏃 💻 📂 🤔 ❓ 🔈 ❌ ✅ 📅 🕘 💯
---
{backlink}
'''
with open(fpath, 'w', encoding='utf-8') as f:
f.write(content)
asset_dir, ext = os.path.splitext(fpath)
if not os.path.exists(asset_dir):
os.mkdir(asset_dir)
def getDateString(self, fmt='%Y%m%d_%H%M%S'):
tt = time.time()
tl = time.localtime(tt)
dd = time.strftime(fmt,tl)
return dd
def auto_commit(self):
'''
在程序启动的时候自动提交一次版本到本地仓库
'''
message = 'start at {}'.format(self.getDateString())
self._commit(message)
def commit(self):
'''
使用 git 版本控制(调用 plugin 目录下的 git,如果没有就报错)
需要判断 folder 有没有 .git 目录,决定是否 git init(包括创建 .gitignore)
'''
dd = self.getDateString()
message, ok = QInputDialog.getText(self.MainWindow,
"提交修改", "备注:", QLineEdit.Normal, "{}".format(dd))
self._commit(message)
if message!=dd:
# 有意义的主动提交的信息才会写入历史
self.write_history(dd, message)
def write_history(self, dd, message):
'''
记录里程碑式的事件,一般由用户自己提交的 commit message 会被记录到 history.md 文稿中
'''
fp = os.path.join(self.wks, "history.md")
content = ""
if not os.path.exists(fp):
content = "---\ntitle: 项目重要进展汇总\ndate: 2000-12-18 12:18:30\n---\n\n"
with open(fp, "a", encoding="utf-8") as f:
content += f"- 📅[{dd}]: {message}\n"
f.write(content)
def write_message(self, msg):
tt = time.time()
tl = time.localtime(tt)
dd = time.strftime('%Y-%m-%d %H:%M:%S',tl)
fp = os.path.join(self.wks, "message.md")
content = ""
if not os.path.exists(fp):
content = "---\ntitle: 说说页面\ndate: 2000-12-18 12:18:30\n---\n\n"
with open(fp, "a", encoding="utf-8") as f:
content += f"- 📅[{dd}]: {msg}\n"
f.write(content)
def _commit(self, message):
# 先检查 gitignore 文件吧
# message 不能有空格,所以加双引号吧
message = '"'+message+'"'
gitignore_fp = os.path.join(self.wks, '.gitignore')
if not os.path.exists(gitignore_fp):
with open(gitignore_fp, 'w', encoding='utf-8') as f:
f.write('# 只对根目录下的markdown文件进行tracking\n/*\n!*.md\n!.gitignore')
try:
# 判断.git 目录决定是否 init
git_dir = os.path.join(self.wks, '.git')
if os.path.exists(git_dir):
command = '{git} add .&&{git} commit -m {message}'.format(git=self.git, message=message)
else:
command = '{git} init && {git} add .&&{git} commit -m {message}'.format(git=self.git, message=message)
subprocess.Popen(command, cwd=self.wks, shell=True)
self.print('commit successed!')
except Exception as e:
self.print(str(e))
def keepWindowTop(self):
if not self.ui.isWindowTop:
self.MainWindow.setWindowFlags(QtCore.Qt.WindowStaysOnTopHint) #置顶
self.ui.isWindowTop = True
self.print('The windown is always on top!')
else:
self.MainWindow.setWindowFlags(QtCore.Qt.Widget) #取消置顶
self.ui.isWindowTop = False
self.print('Always on top cancelled!')
self.MainWindow.show()
def search(self):
'''
搜索关键词为空或者小于两个字符时,搜索为刷新文件列表
支持多关键词搜索(多关键词空格分开)和正则式搜索(加*作为前缀)
'''
content = self.ui.searchText.text()
if content=='help':
inote_repo_url = 'https://gitee.com/sheldonxxd/inote'
webbrowser.open(inote_repo_url)
elif content.startswith('date:'):
self.updateFileList(mode=content)
elif content.startswith(':'):
# 这种情况是输入一段 message 并写入 message.md
content = content[1:]
self.write_message(content)
elif len(content)>1:
sss = Search(self.wks, content)
sss.open()
sss.match()
self.ui.listWidget.clear()
# self.ui.listWidget.addItems(sss.results)
# sss.reulsts 中存放的是文件名,还需要转一下
results = self.fetchTitleByName(sss.results)
# 2021-08-21 09:16:20
# 常规搜索结果要给 self.current_files,否则切换的时候容易出错
self.current_titles = results
self.ui.listWidget.addItems(results)
self.ui.listWidget.update()
self.print('Found [{}] results!'.format(len(results)))
else:
self.updateFileList()
# 清空搜索框内容
# self.ui.searchText.clear()
def fetchTitleByName(self, fnames):
'''
fnames:文件名列表
批量读取文件的 title
参考 updateFileList 方法
'''
files = [os.path.join(self.wks, x+'.md') for x in fnames]
return self.fetchTitle(files)
def readTitle(self, fp):
with open(fp, 'r', encoding='utf-8') as f:
content = f.readline() # 读取文件第一行的标题
if content.startswith('#'):
# 对应无 front matter 模式
title = content.strip('#').strip(' ').strip('\n') # 去掉标题markdow标记
elif content.startswith('---'):
# front matter 至少有三行,从上到下分别是 title: 、excerpt、date(创建日期),所以再读一行即可
title = f.readline().strip('\n')[7:] # title后面冒号有空格
else:
title = content.strip('\n')
return title
def fetchTitle(self, files):
'''
files: 文件绝对路径列表
'''
titles_0 = []
titles_1 = []
for fp in files:
title = self.readTitle(fp)
_, fname = os.path.split(fp)
if xxd.isEmoji(title[0]):
# 检查标题是否以emoji开头,如果是就置顶
titles_1.append(title+'...@{}'.format(fname))
else:
titles_0.append(title+'...@{}'.format(fname))
titles = titles_1 + titles_0
return titles
def openFile(self, item):
text = item.text()
try:
fpath = self.fetchFilepath(text)
# self.startfile(fpath)
# 默认使用 plugin 下边的 typora 打开
self.openFile_typora(fpath)
except Exception as e:
self.print(str(e))
def fetchFilepath(self, text):
'''
content:列表item的字符串
'''
title = text.split('@')
fname = title[-1]
fpath = os.path.join(self.wks, fname)
return fpath
def custom_right_menu(self, pos):
'''
搜索结果右键功能菜单,包含导出文稿和资源文件到桌面,删除,插入资源或链接,引用该文稿等
# TODO
多选从框修改为 https://www.jianshu.com/p/18b6e141ef0f
'''
func_dict = {
'cite':['cite.png', self.cite],
'related':['network.png', self.showRelatedItems],
'openAssets':['rocket.ico', self.openAssets],
'addFiles':['Movie.png', self.add],
'toArchive':['categories.png', self.toArchive],
'toDesktop':['desktop.png', self.toDesktop],
'toHTML':['html.png', self.exportHTML],
'toDOCX':['docx.png', self.exportDOCX],
'toTrash':['trash.png', self.toTrash],
'copy':['copy.png', self.copyItem],
'paste':['paste.png', self.pasteItem],
'import':['import.png', self.importItem],
'post':['summary.png', self.post],
}
func_dict2 = {
'open':['focus.png', self.openFiles],
'cite':['cite.png', self.cite],
'toArchive':['categories.png', self.toArchive],
'toDesktop':['desktop.png', self.toDesktop],
# 'toHTML':['html.png', self.exportHTML],
# 'toDOCX':['docx.png', self.exportDOCX],
'toTrash':['trash.png', self.toTrash],
'gather':['gather.png', self.gather],
'copy':['copy.png', self.copyItem],
'paste':['paste.png', self.pasteItem],
'import':['import.png', self.importItem],
}
menu = QtWidgets.QMenu()
menu.setMaximumWidth(140)
if not self.showBox:
for key in func_dict:
opt = menu.addAction(key)
icon = QtGui.QIcon()
icon.addPixmap(QtGui.QPixmap(":/xxd/icons/{}".format(func_dict[key][0])), QtGui.QIcon.Normal, QtGui.QIcon.Off)
opt.setIcon(icon)
func_dict[key].append(opt)
hitIndex = self.ui.listWidget.indexAt(pos).row()
if hitIndex > -1:
text =self.ui.listWidget.item(hitIndex).text()
fpath = self.fetchFilepath(text)
action = menu.exec_(self.ui.listWidget.mapToGlobal(pos))
for key in func_dict:
if action == func_dict[key][2]:
func_dict[key][1](fpath)
elif hitIndex == -1:
text = 'no file choosed!'
action = menu.exec_(self.ui.listWidget.mapToGlobal(pos))
for key in func_dict:
if action == func_dict[key][2] and key in ['paste', 'import']:
func_dict[key][1](text)
else:
self.print(text)
else:
# 选择模式下,直接收集勾选的,而且能够执行的操作也有限
for key in func_dict2:
opt = menu.addAction(key)
icon = QtGui.QIcon()
icon.addPixmap(QtGui.QPixmap(":/xxd/icons/{}".format(func_dict2[key][0])), QtGui.QIcon.Normal, QtGui.QIcon.Off)
opt.setIcon(icon)
func_dict2[key].append(opt)
text_list = self.getChoose()
file_list = [self.fetchFilepath(x) for x in text_list]
action = menu.exec_(self.ui.listWidget.mapToGlobal(pos))
for key in func_dict2:
if action == func_dict2[key][2]:
func_dict2[key][1](file_list)
def gather(self, fpaths):
'''
将指定的多个文稿汇总打包,
注意 front matter 中的信息只提取标题和时间,然后作为二级标题
然后新建的汇总文稿 front-matter中只有标题和日期,
并且新的标题为`实验日志汇总-YYYYMMDD`
原文稿以链接方式插入到文稿后部。
'''
# 先收集内容
box = []
for fp in fpaths:
with open(fp, 'r', encoding='utf-8') as f:
content = f.read()
# 根据frontmatter的特征进行切割
if not content.startswith('---\n'):
# 如果不是以这个开头说明没有front matter
self.print(f'[Error]{fp}')
time.sleep(3)
continue
parts = content.split('---\n')
front_matter = parts[1]
infos = front_matter.split('\n')
title = infos[0][7:].strip()
date = infos[1][6:].strip()
main_text = parts[2].strip()
if main_text.startswith('>'):
# 可以删除第一行
fjeo = main_text.split('\n')
main_text = fjeo[1:]
main_text = '\n'.join(main_text)
box.append([title, date, main_text])
box.reverse() # list逆序一下
tt = time.time()
tl = time.localtime(tt)
dd = time.strftime('%Y%m%d',tl)
dd2 = time.strftime('%Y%m%d_%H%M%S',tl)
dd3 = time.strftime('%Y-%m-%d %H:%M:%S',tl)
fp2 = f"{self.wks}/summary-{dd2}.md"
front_matter = f'---\ntitle: 🎈实验日志汇总-{dd}\ndate: {dd3}\n---\n\n'
content = []
for item in box:
title, date, text = item
merge = f'## {title}\n\n📅 {date}\n\n{text}\n\n***\n'
content.append(merge)
content = front_matter+''.join(content) + self.cite(fpaths)
# references = self.cite(fpaths)
with open(fp2, 'w', encoding='utf-8') as f:
f.write(content)
self.print('所选文稿已汇总!')
def openFiles(self,fpaths):
if len(fpaths)<10:
for fpath in fpaths:
try:
self.openFile_typora(fpath)
except Exception as e:
self.print(str(e))
else:
self.print('open too many files! aborted..')
def showRelatedItems(self, fpath):
'''
根据 cite 的情况,寻找相关的文稿,引用和被引用都要找
fpath 是绝对路径
'''
with open(fpath, 'r', encoding='utf-8') as f:
content = f.read()
pattern = '🔗《.+\((.+)\)》'
res = re.findall(pattern, content)
res = list(set(res)) # 去重
res = [os.path.join(self.wks, x) for x in res]
res.append(fpath)
flist = 'list='+','.join(res)
try:
self.updateFileList(mode=flist)
except Exception as e:
self.print(f'[No related pages]:{str(e)}')
def exportHTML(self, fpaths):
self._export('html', fpaths)
def exportDOCX(self, fpaths):
self._export('docx', fpaths)
def _export(self, ext, fpaths):
sty = os.path.join(self.rootdir, 'plugin', 'pandoc')
if type(fpaths) is list:
for fpath in fpaths:
worker = Convertor(fpath, sty)
if ext=='html':
worker.toHTML()
elif ext=='docx':
worker.toDOCX()
else:
worker = Convertor(fpaths, sty)
if ext=='html':
worker.toHTML()
elif ext=='docx':
worker.toDOCX()
# 当导出单个文件的时候,导出完成后可调用默认程序打开导出的目录
base, _ = os.path.splitext(fpaths)
_, fname = os.path.split(base)
fpath = base+f'/{fname}.'+ext
# # print(fpath)
# self.startfile(fpath)
# 当导出文件比较大的时候,此处可能会阻塞,所以最好的方案还是弹出目录
self.startfile(base+'/')
def copyItem(self, fpaths):
'''
将文稿的绝对地址先拷贝到粘贴板,方便后面 paste 功能使用
'''
if type(fpaths) is list:
content = '\n'.join(fpaths)
elif os.path.exists(fpaths):
content = fpaths
pyperclip.copy(content)
self.print('Filepaths Copyied!')
def pasteItem(self, fpaths=None):
'''
从粘贴板中获取其它库的文稿的绝对地址,复制文稿和附录到本库
'''
content = pyperclip.paste()
fpaths = content.split('\n') # 如果只有一行,也会变成列表
# fpath = pyperclip.paste().strip('"').replace('\\','/')
for line in fpaths:
fpath = line.replace('\\', '/')
if os.path.exists(fpath):
_, fname = os.path.split(fpath)
fdir = os.path.dirname(fpath)
check_file = os.path.join(self.wks, fname)
if fdir!=self.wks:
if not os.path.exists(check_file):
base, ext = os.path.splitext(fpath)
_, base_name = os.path.split(base)
if ext=='.md':
shutil.copy(fpath, self.wks+'/'+fname)
try:
shutil.copytree(base, self.wks+'/'+base_name)
self.print('Successfully copied from {}'.format(fpath))
self.updateFileList()
except Exception as e:
self.print(str(e))
else:
self.print('File with same name [{}] existed!'.format(fname))
else:
self.print('File already existed!')
else:
self.print('No filepath founded in clipboard!')
def importItem(self, fpath_):
'''
弹出对话框,导入后缀为 iNote.zip 的压缩包文件到本库
'''
filetypes = ['iNote_Archives', 'zip (*.zip)']
filepaths = self.getFiles(filetypes)
counter = 0
if len(filepaths)>0:
for fp in filepaths:
if fp.endswith('iNote.zip'):
_, fname = os.path.split(fp)
fname = fname[:-10]
check_file = os.path.join(self.wks, fname+'.md')
if not os.path.exists(check_file):
try:
counter += 1
shutil.unpack_archive(filename=fp, extract_dir=self.wks)
self.print('{} items imported!'.format(counter))
self.updateFileList()
except Exception as e:
self.print(str(e))
else:
self.print('File with same name [{}] existed!'.format(fname+'.md'))
else:
self.print('No archive file choosed!')
def cite(self, fpaths):
'''
右键 cite 所选 item,把信息复制到粘贴板
:link:《[]()》
读取 item 的 title 并生成 《[title](filename.html)》。
'''
if type(fpaths) is list:
content = []
for fpath in fpaths:
base, _ = os.path.splitext(fpath)
_, fname = os.path.split(base)
titles = self.fetchTitleByName([fname])
title = titles[0].split('@')[0]
c = f"- 🔗《[{title}]({fname}.md)》\n"
content.append(c)
content = '\n' + ''.join(content)
else:
base, ext = os.path.splitext(fpaths)
_, fname = os.path.split(base)
titles = self.fetchTitleByName([fname])
title = titles[0].split('@')[0]
content = f" 🔗《[{title}]({fname}.md)》 "
pyperclip.copy(content)
# 粘贴板的内容可以粘贴到 typora 中
self.print('Citation copied!'.format(fname))
return content
def openAssets(self, fpath):
base, _ = os.path.splitext(fpath)
assets_dir = base + '/'
if not os.path.exists(assets_dir):
# 如果没有附录文件夹就创建一个
os.mkdir(assets_dir)
self.startfile(assets_dir)
#-------------------------------------------------------------------------- 插入相关功能
def add(self, fpath):
files, _ = QFileDialog.getOpenFileNames(self.MainWindow,
"选取文件",
self.user_desktop,
'all files (*)')
worker = Attach(fpath, files)
worker.run()
self.print('文件已复制至粘贴板,请直接paste到markdown编辑器中')
def validateTitle(self, title):
rstr = r"[\/\\\:\*\?\"\<\>\|]" # '/ \ : * ? " < > |'
new_title = re.sub(rstr, " ", title) # 替换为空格
return new_title
def _tozip(self, fpath, export_dir):
title = self.readTitle(fpath)
# 这9种字符被替换成空格 \ / : * ? " < > |
title = self.validateTitle(title)
base, ext = os.path.splitext(fpath)
_, fname = os.path.split(base)
asset_path, _ = os.path.splitext(fpath)
if not os.path.exists(asset_path):
os.mkdir(asset_path)
target_path = export_dir+f'/{title}_{fname}' # 避免title重复
zip_filepath = target_path+'.iNote'
if not os.path.exists(target_path):
os.mkdir(target_path)
fpath2 = target_path + '/{}.md'.format(fname)
asset_path2 = target_path + '/{}/'.format(fname)
try:
shutil.copy(fpath, fpath2)
shutil.copytree(asset_path, asset_path2)
shutil.make_archive(zip_filepath, 'zip', target_path)
shutil.rmtree(target_path)
self.print('{} exported to {}!'.format(title, export_dir))
except Exception as e:
self.print(str(e))
def toDesktop(self, fpath):
'''
将文稿和附录都拷贝到桌面的 Export 文件夹中
'''
export_dir = os.path.join(self.user_desktop, 'Export')
if not os.path.exists(export_dir):
os.mkdir(export_dir)
if type(fpath) is list:
for fp in fpath:
self._tozip(fp, export_dir)
else:
self._tozip(fpath, export_dir)
def toTrash(self, fpaths):
'''
删除文件,要弹出对话框让用户确认!
删除文件同时,也要清理资源目录
同时更新 index.yaml
'''
reply = QMessageBox.warning(self.MainWindow,
"警告",
"确认要删除文稿及其附录资源?\n注意此操作不可恢复!!",
QMessageBox.Yes | QMessageBox.No)
trash_path = os.path.join(self.user_desktop, 'Trash/')
if not os.path.exists(trash_path):
os.mkdir(trash_path)
if reply==16384:
if type(fpaths) is list:
for fpath in fpaths:
self._deleteItem(fpath, trash_path)
elif os.path.exists(fpaths):
self._deleteItem(fpaths, trash_path)
elif reply==65536:
self.print('cancel delete')
else:
pass
def _deleteItem(self, fpath, trash_path):
base, ext = os.path.splitext(fpath)
_, fname = os.path.split(base)
asset_path, _ = os.path.splitext(fpath)
fpath2 = os.path.join(trash_path, '{}.md'.format(fname))
asset_path2 = os.path.join(trash_path, '{}'.format(fname))
try:
shutil.move(fpath, fpath2)
if os.path.exists(asset_path):
shutil.move(asset_path, asset_path2)
self.print('{} moved to desktop!'.format(fname))
self.updateFileList()
except Exception as e:
self.print(str(e))
def toArchive(self, fpath):
'''
将文稿添加到固定的分类,其实就是在文稿末尾添加标记
'''
items = self.archives
value, ok = QInputDialog.getItem(self.MainWindow, "Archive", "Choose a Archive:", items, 0, True)
if type(fpath) is str:
cc = self.getArchive(fpath)
if cc is None:
if ok:
try:
with open(fpath, 'a', encoding='utf-8') as f:
f.write(f'\n\n---\narchives: {value}\n')
except Exception as e:
self.print(str(e))
else:
self.print('cancel archiving!')
else:
self.print(f'Already archived in [{cc}]!')
elif type(fpath) is list:
ec = []
for fp in fpath:
if ok:
cc = self.getArchive(fp)
if cc is None:
try:
with open(fp, 'a', encoding='utf-8') as f:
f.write(f'\n\n---\narchives: {value}\n')
except Exception as e:
self.print(str(e))
else:
ec.append(f'[{cc}] {fp}')
self.print('Already Archived Files\n'+'\n'.join(ec))
def post(self, fp):
xxd = FrontMatter(fp, gui=True)
icon = QtGui.QIcon()
icon.addPixmap(QtGui.QPixmap(":/xxd/icons/book.ico"), QtGui.QIcon.Normal, QtGui.QIcon.Off)
xxd.MainWindow.setWindowIcon(icon)
desktop = QApplication.desktop()
xxd.MainWindow.move(int(desktop.width()*0.1)+100, int(desktop.height()*0.1)+100)
xxd.MainWindow.show()
self.windows.append(xxd)
# 这个非常重要,否则新开的子窗口会闪退
# https://blog.csdn.net/qq_41398808/article/details/102816142
class WorkThread(QThread):
# 自定义信号对象。参数str就代表这个信号可以传一个字符串
trigger = pyqtSignal(str)
def __init__(self, filelist):
# 初始化函数
super(WorkThread, self).__init__()
self.file_list = filelist
def run(self):
#重写线程执行的run函数
#触发自定义信号
if type(self.file_list) is list:
N = len(self.file_list)
while True:
idx = random.randint(0, N-1)
fp = self.file_list[idx]
if os.path.exists(fp):
with open(fp, 'r', encoding='utf-8') as f:
# content = f.readlines()
content = f.read().split('---\n')[2]
# 通过自定义信号把待显示的字符串传递给槽函数
content = content.strip().split('\n')
content = list(filter(lambda x:len(x)>1, content))
n = len(content)
idx2 = random.randint(0, n-1)
cut = content[idx2]
sig = f'{cut}'
self.trigger.emit(sig)
time.sleep(6)
if __name__ == '__main__':
worker = xxd()
# 2021-9-27 manjaro-linux 上报错,会导致打开文件夹对话框无效,已经有人报告bug,是kde的问题
# https://www.mail-archive.com/kde-bugs-dist@kde.org/msg603571.html
# kf.service.services: KApplicationTrader: mimeType "x-scheme-handler/file" not found
此处可能存在不合适展示的内容,页面不予展示。您可通过相关编辑功能自查并修改。
如您确认内容无涉及 不当用语 / 纯广告导流 / 暴力 / 低俗色情 / 侵权 / 盗版 / 虚假 / 无价值内容或违法国家有关法律法规的内容,可点击提交进行申诉,我们将尽快为您处理。