refactor(settings): remove diary and template features
This commit is contained in:
1 parent
a26ebea843
commit
849cd358d0
12 files changed
+19
-173
No files matched your search
@@ -33,14 +33,13 @@ docker run -d --name rawmark \
|
||||
- 首次打开直接设置登录密码,之后凭密码登录;请在将新实例开放给他人访问前完成初始化。
|
||||
- 笔记以普通 `.md` 文件保存在 `数据目录/vault/`,粘贴、上传和导入的图片统一存放在笔记同级的 `assets/`,链接使用 `./assets/文件名`,同目录相同内容去重。笔记跨目录移动时图片跟随,仍被其他笔记引用的原图保留;删除单篇笔记不会删除共享图片。旧 `笔记名.assets/` 仍可读取、移动与恢复;外部修改会实时同步到网页。
|
||||
- 知识工具:标准 Markdown 链接构成图谱,支持正文 `#标签` 与 YAML `tags`。笔记或文件夹在应用内改名、移动时维护入链和相对出链;公式和代码示例不参与关系提取。
|
||||
- 日记与模板:默认日记路径为 `Daily/YYYY-MM-DD.md`(本地日期),已有日记直接打开;模板为 `Templates/` 下的普通 Markdown,支持 `{{date}}`、`{{time}}`、`{{title}}`,资源链接按新笔记位置调整。可在设置中修改目录和日记模板。
|
||||
- 外观:浅色玻璃工作台与深色模式,默认字体为 Maple Mono Normal NL NF CN v7.9(界面、正文与代码),字体资源随应用本地提供,文字在所需字体加载完成后显示,加载失败会提示刷新;代码块使用 highlight.js,固定采用官网的 Atom One Dark 配色,与页面明暗模式独立;界面字号默认 14px,可输入 14–20px 并单独重置,与编辑器及阅读字号独立;排版、玻璃透明度、选中文本工具栏均可在设置中调整并即时保存。
|
||||
- 选区格式:电脑与触屏选中文字后提供外观、加粗、斜体、下划线、删除线、高亮和链接。外观可设置配色、字体、局部字号与效果;下划线及外观以受限的标准 HTML 行内标签保存在 Markdown 中,源码与导出保留标签,其他软件的显示取决于其 HTML 支持。清除样式只重置局部外观;底部不再显示格式栏。
|
||||
- 图片管理:侧栏默认隐藏 `assets` 和 `笔记名.assets`;右键文件夹选择「图片管理」,查看当前目录及直属附件目录的图片,支持检索、排序、网格/列表、批量上传、引用查看及预览、重命名、移动、替换。可切换整个知识库;清理孤立图片须确认候选,服务端再次核对全库引用后移入回收站,可恢复。上传至所选目录的 `assets/`,相同内容自动去重。根目录可在侧栏空白处右键或通过命令面板打开。图片改名/移动会更新笔记中的本地引用;替换保留路径,选择相同扩展名的图片(最大 50 MB),旧图片不进回收站。
|
||||
- `--user` 让容器以宿主机用户身份读写数据目录;不指定时容器以 UID 65532 运行,需要该用户对目录有写权限。
|
||||
- 请通过 HTTPS 反向代理(Caddy、Nginx 等)访问,并转发 WebSocket(`/api/v1/events`)。
|
||||
- 多选与导出:Ctrl/Cmd 点击切换选择,Shift 点击选择可见范围;右键批量删除、移动或导出 Markdown。单篇无附件直接下载 `.md`,其他情况下载包含 Markdown 与本地附件的 ZIP;重名目标不会被覆盖,批量操作会汇报单项失败。
|
||||
- 设置:桌面使用居中窗口,手机使用分类与内容两级布局;支持搜索设置并定位、常用排版项单独重置,自动保存状态与失败提示可见。外观、编辑器和日记与模板分别管理,安装入口位于「关于」。
|
||||
- 设置:桌面使用居中窗口,手机使用分类与内容两级布局;支持搜索设置并定位、常用排版项单独重置,自动保存状态与失败提示可见。外观与编辑器分别管理,安装入口位于「关于」。
|
||||
- 快捷键:支持按命令名称、ID 或组合键搜索,添加、逐条移除和单项/全部重置,并拒绝冲突绑定。默认 Ctrl+Alt+1~6 设置对应级别标题,同级再次触发恢复正文;字号可直接输入。
|
||||
- 文件目录操作:右键「文件目录」标题或目录空白处,可搜索、打开标签索引、新建笔记、新建目录、导入文档及管理图片;也可点击标题打开菜单。文件夹右键操作以所选文件夹为目标。
|
||||
- 导入:文件目录或文件夹右键菜单选择「导入」,支持多选 UTF-8 `.md` 文件或单个 ZIP(总上传上限 100 MB)。ZIP 新建同名文件夹并保留笔记层级,本地图片归入各笔记同级 `assets/`;全部改链后移除已迁移且无引用的重复原图,其他附件和未使用图片保留。同名不同内容不覆盖,缺失的本地链接会提示,外部图片不会自动下载。
|
||||
|
||||
@@ -20,8 +20,6 @@ if (!buildVersion) throw new Error('缺少应用构建版本信息')
|
||||
|
||||
const section = computed(() => (ws.settings === 'index' ? 'appearance' : ws.settings!))
|
||||
// 手机上一次只显示分组列表或分组内容
|
||||
const dailyTemplates = computed(() => ws.entries.filter(entry => !entry.dir && entry.path.endsWith('.md')))
|
||||
const missingDailyTemplate = computed(() => settings.dailyTemplate && !dailyTemplates.value.some(entry => entry.path === settings.dailyTemplate))
|
||||
const showList = ref(ws.settings === 'index')
|
||||
const current = computed(() => SETTINGS_SECTIONS.find((s) => s.id === section.value)!)
|
||||
const query = ref('')
|
||||
@@ -31,7 +29,7 @@ const dialog = ref<HTMLElement>()
|
||||
const content = ref<HTMLElement>()
|
||||
const heading = ref<HTMLElement>()
|
||||
const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null
|
||||
const preferenceSection = computed(() => ['appearance', 'editor', 'templates', 'shortcuts'].includes(section.value))
|
||||
const preferenceSection = computed(() => ['appearance', 'editor', 'shortcuts'].includes(section.value))
|
||||
const saveLabel = computed(() => settingsSave.pending ? '保存中…' : settingsSave.result === 'error' ? '保存失败' : settingsSave.result === 'saved' ? '已保存' : '修改自动保存')
|
||||
|
||||
async function open(id: SettingsSection) {
|
||||
@@ -114,15 +112,6 @@ function changeUIFont(event: Event) {
|
||||
if (value !== settings.uiFontSize) void change({ uiFontSize: value })
|
||||
}
|
||||
|
||||
function changeFolder(key: 'dailyFolder' | 'templatesFolder', event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const path = input.value.trim()
|
||||
if ((key === 'templatesFolder' && !path) || path && path.split('/').some(part => !part || part.startsWith('.') || /[\\\0]/.test(part))) {
|
||||
toast('请输入有效的仓库相对目录'); input.value = settings[key]; return
|
||||
}
|
||||
void change({ [key]: path })
|
||||
}
|
||||
|
||||
// ---- 修改登录密码 ----
|
||||
const pw = ref({ current: '', next: '', confirm: '' })
|
||||
const pwError = ref('')
|
||||
@@ -196,13 +185,6 @@ async function changePassword() {
|
||||
<SettingRow id="tab-size" control="rm-tab-size" resettable @reset="change({ tabSize: DEFAULTS.tabSize })"><select id="rm-tab-size" :value="settings.tabSize" @change="change({ tabSize: Number(($event.target as HTMLSelectElement).value) })"><option v-for="n in TAB_SIZES" :key="n" :value="n">{{ n }} 列</option></select></SettingRow>
|
||||
</template>
|
||||
|
||||
<template v-else-if="section === 'templates'">
|
||||
<SettingRow id="daily-folder" control="rm-daily-folder"><input id="rm-daily-folder" :value="settings.dailyFolder" placeholder="根目录" @change="changeFolder('dailyFolder', $event)" /></SettingRow>
|
||||
<SettingRow id="templates-folder" control="rm-templates-folder"><input id="rm-templates-folder" :value="settings.templatesFolder" @change="changeFolder('templatesFolder', $event)" /></SettingRow>
|
||||
<SettingRow id="daily-template" control="rm-daily-template"><select id="rm-daily-template" :value="settings.dailyTemplate" @change="change({ dailyTemplate: ($event.target as HTMLSelectElement).value })"><option value="">空白笔记</option><option v-for="entry in dailyTemplates" :key="entry.path" :value="entry.path">{{ entry.path }}</option><option v-if="missingDailyTemplate" :value="settings.dailyTemplate">{{ settings.dailyTemplate }}(不存在)</option></select></SettingRow>
|
||||
<p class="rm-share-note">模板支持 <code v-pre>{{date}}</code>、<code v-pre>{{time}}</code>、<code v-pre>{{title}}</code>。</p>
|
||||
</template>
|
||||
|
||||
<template v-else-if="section === 'account'">
|
||||
<form id="rm-setting-password" class="rm-auth-card rm-settings-form" tabindex="-1" @submit.prevent="changePassword">
|
||||
<h3>修改登录密码</h3>
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import Icon from './Icon.vue'
|
||||
import { settings } from './settings'
|
||||
import { createFromTemplate } from './templates'
|
||||
import { createNote, toast, ws } from './workspace'
|
||||
const templates = computed(() => ws.entries.filter(entry => !entry.dir && entry.path.startsWith(settings.templatesFolder + '/') && entry.path.endsWith('.md')))
|
||||
const selected = ref('')
|
||||
const path = ref('未命名.md')
|
||||
const busy = ref(false)
|
||||
async function create() {
|
||||
if (!path.value.endsWith('.md') || path.value.split('/').some(part => !part || part.startsWith('.') || /[\\\0]/.test(part))) { toast('请输入有效的 Markdown 笔记路径'); return }
|
||||
busy.value = true
|
||||
try { if (await createFromTemplate(path.value, selected.value)) ws.templateOpen = false }
|
||||
catch (error) { toast('读取模板失败:' + (error instanceof Error ? error.message : '网络错误')) }
|
||||
finally { busy.value = false }
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div class="rm-dialog-backdrop" @click.self="!busy && (ws.templateOpen = false)" @keydown.esc.stop="!busy && (ws.templateOpen = false)">
|
||||
<section class="rm-dialog rm-template-dialog" role="dialog" aria-label="从模板新建">
|
||||
<div class="rm-dialog-head"><h2>从模板开始</h2><button class="rm-icon-btn" title="关闭" :disabled="busy" @click="ws.templateOpen = false"><Icon name="close" /></button></div>
|
||||
<p class="rm-share-note">模板是 {{ settings.templatesFolder }}/ 下的 Markdown 文件。支持 <code v-pre>{{date}}</code>、<code v-pre>{{time}}</code> 和 <code v-pre>{{title}}</code>,图片沿用原模板资源。</p>
|
||||
<form @submit.prevent="create">
|
||||
<label>选择模板<select v-model="selected"><option value="">空白笔记</option><option v-for="template in templates" :key="template.path" :value="template.path">{{ template.path }}</option></select></label>
|
||||
<label>笔记路径<input v-model="path" aria-label="笔记路径" required /></label>
|
||||
<div class="rm-template-actions"><button class="rm-btn" type="button" :disabled="busy" @click="ws.templateOpen = false; createNote(settings.templatesFolder)">新建模板文件</button><button class="rm-btn rm-btn-primary" :disabled="busy">创建笔记</button></div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
.rm-template-dialog { width: min(520px, calc(100vw - 24px)); overflow-y: auto; } form { display: grid; gap: 20px; padding: 20px; } label { display: grid; gap: 9px; font-size: var(--rm-ui-font-size); } input, select { padding: 10px; border: 1px solid var(--rm-divider); border-radius: 8px; background: var(--rm-bg); color: var(--rm-text); font: inherit; } .rm-template-actions { display: flex; justify-content: space-between; gap: 12px; }
|
||||
@media(max-width:767px), (pointer:coarse) { input, select { font-size: max(16px, var(--rm-ui-font-size)); } }
|
||||
</style>
|
||||
@@ -4,7 +4,6 @@ import { api, ApiError } from './api'
|
||||
import { registerCommand } from './commands'
|
||||
import EditorPane from './EditorPane.vue'
|
||||
import KnowledgePanel from './KnowledgePanel.vue'
|
||||
import TemplateDialog from './TemplateDialog.vue'
|
||||
import EditorChrome from './EditorChrome.vue'
|
||||
import Icon from './Icon.vue'
|
||||
import SelectionToolbar from './SelectionToolbar.vue'
|
||||
@@ -133,7 +132,6 @@ onBeforeUnmount(() => {
|
||||
<OutlinePanel v-if="ws.outlineOpen && !ws.focusMode && ws.active && ws.view === 'notes' && ws.images === null" />
|
||||
</Transition>
|
||||
</div>
|
||||
<TemplateDialog v-if="ws.templateOpen" />
|
||||
<QuickSwitcher v-if="ws.switcher !== null" />
|
||||
<SettingsDialog v-if="ws.settings" />
|
||||
<ImportDialog v-if="ws.importing !== null" :dir="ws.importing" @close="ws.importing = null" />
|
||||
|
||||
@@ -41,13 +41,26 @@ async function search(selector: string, query: string) {
|
||||
}
|
||||
|
||||
describe('设置查找与重置', () => {
|
||||
it('移除日记和模板设置、搜索与命令入口', async () => {
|
||||
expect(searchSettings('日记')).toEqual([])
|
||||
expect(searchSettings('日历')).toEqual([])
|
||||
expect(DEFAULTS).not.toHaveProperty('dailyFolder')
|
||||
expect(searchSettings('模板')).toEqual([])
|
||||
expect(DEFAULTS).not.toHaveProperty('templatesFolder')
|
||||
ws.settings = 'appearance'
|
||||
mount(SettingsDialog)
|
||||
expect(host!.textContent).not.toContain('日记')
|
||||
expect(host!.querySelector('#rm-templates-folder')).toBeNull()
|
||||
expect(host!.textContent).not.toContain('模板')
|
||||
})
|
||||
|
||||
it('设置和命令不再提供分享入口', async () => {
|
||||
expect(searchSettings('分享')).toEqual([])
|
||||
expect(allCommands().map(command => command.id)).not.toContain('note.share')
|
||||
expect(allCommands().map(command => command.id)).not.toContain('app.shares')
|
||||
ws.settings = 'appearance'
|
||||
mount(SettingsDialog)
|
||||
expect(host!.querySelectorAll('.rm-settings-tab')).toHaveLength(9)
|
||||
expect(host!.querySelectorAll('.rm-settings-tab')).toHaveLength(8)
|
||||
expect(host!.textContent).not.toContain('分享')
|
||||
})
|
||||
|
||||
@@ -55,7 +68,6 @@ describe('设置查找与重置', () => {
|
||||
expect(searchSettings(' ')).toEqual([])
|
||||
expect(searchSettings('CSS 高级')).toEqual([expect.objectContaining({ id: 'css-snippets', section: 'appearance' })])
|
||||
expect(searchSettings('api')).toEqual(expect.arrayContaining([expect.objectContaining({ section: 'tokens', title: 'API 令牌' }), expect.objectContaining({ id: 'openapi' })]))
|
||||
settings.dailyFolder = 'private-note-folder'
|
||||
settings.cssSnippets = [{ id: 'private', name: 'private-snippet-name', enabled: true, css: ':root { --private: 1 }' }]
|
||||
expect(searchSettings('private')).toEqual([])
|
||||
})
|
||||
|
||||
@@ -17,7 +17,7 @@ export const CONTENT_WIDTHS = [
|
||||
|
||||
export const SIDEBAR_WIDTH = { min: 200, max: 480 }
|
||||
|
||||
export const DEFAULTS = { dailyFolder: 'Daily', templatesFolder: 'Templates', dailyTemplate: '', selectionToolbar: true, ...DISPLAY_DEFAULTS, sidebarWidth: 288, spellcheck: false, tabSize: 4 }
|
||||
export const DEFAULTS = { selectionToolbar: true, ...DISPLAY_DEFAULTS, sidebarWidth: 288, spellcheck: false, tabSize: 4 }
|
||||
export type Settings = typeof DEFAULTS & Appearance & { shortcuts: Record<string, string[]> }
|
||||
|
||||
const CACHE_KEY = 'rawmark.settings'
|
||||
@@ -36,9 +36,6 @@ export class SettingsAppearanceError extends Error {
|
||||
function sanitize(raw: Record<string, unknown>): Settings {
|
||||
const pick = <T>(v: unknown, allowed: T[], def: T) => (allowed.includes(v as T) ? (v as T) : def)
|
||||
return {
|
||||
dailyFolder: typeof raw.dailyFolder === 'string' ? raw.dailyFolder : DEFAULTS.dailyFolder,
|
||||
templatesFolder: typeof raw.templatesFolder === 'string' && raw.templatesFolder !== '' ? raw.templatesFolder : DEFAULTS.templatesFolder,
|
||||
dailyTemplate: typeof raw.dailyTemplate === 'string' ? raw.dailyTemplate : DEFAULTS.dailyTemplate,
|
||||
...sanitizeDisplaySettings(raw),
|
||||
selectionToolbar: typeof raw.selectionToolbar === 'boolean' ? raw.selectionToolbar : true,
|
||||
shortcuts: sanitizeShortcuts(raw.shortcuts),
|
||||
|
||||
@@ -4,7 +4,6 @@ import type { SettingsSection } from './workspace'
|
||||
export const SETTINGS_SECTIONS: { id: SettingsSection; title: string; icon: IconName; description: string }[] = [
|
||||
{ id: 'appearance', title: '外观', icon: 'palette', description: '界面字号、主题、明暗模式与 CSS 片段' },
|
||||
{ id: 'editor', title: '编辑器', icon: 'edit', description: '正文排版、代码字体与编辑行为' },
|
||||
{ id: 'templates', title: '日记与模板', icon: 'calendar', description: '日记目录、模板目录与日记模板' },
|
||||
{ id: 'shortcuts', title: '快捷键', icon: 'command', description: '查找命令,添加、移除或重置快捷键' },
|
||||
{ id: 'plugins', title: '插件', icon: 'grid', description: '安装、启停和管理插件及其设置页' },
|
||||
{ id: 'tokens', title: 'API 令牌', icon: 'key', description: '创建或撤销 REST API 访问令牌' },
|
||||
@@ -30,9 +29,6 @@ export const SETTING_ITEMS = {
|
||||
'spellcheck': { section: 'editor', title: '拼写检查', description: '使用浏览器的拼写检查,检查语言取决于浏览器设置。', keywords: '语言 拼写' },
|
||||
'tab-size': { section: 'editor', title: '制表符宽度', description: '仅调整 Tab 字符的显示宽度,不改写正文;Tab 键仍按两个空格缩进。', keywords: 'Tab 缩进 空格' },
|
||||
'typography-preview': { section: 'editor', title: '排版预览', description: '查看当前正文与代码字体的显示效果。', keywords: '字号 行高 段距 预览' },
|
||||
'daily-folder': { section: 'templates', title: '日记目录', description: '仓库相对目录;留空时保存在根目录,修改不会移动已有日记。', keywords: '路径 Daily' },
|
||||
'templates-folder': { section: 'templates', title: '模板目录', description: '仓库相对目录,修改不会移动已有模板。', keywords: '路径 Templates' },
|
||||
'daily-template': { section: 'templates', title: '日记模板', description: '日记按本地日期命名,已有日记直接打开。', keywords: '空白笔记 日期' },
|
||||
'password': { section: 'account', title: '修改登录密码', description: '修改后其他设备需要重新登录。', keywords: '账户 安全 鉴权' },
|
||||
'logout': { section: 'account', title: '退出登录', description: '退出当前设备的登录会话。', keywords: '账户 会话' },
|
||||
'install': { section: 'about', title: '安装应用', description: '浏览器支持安装时,可将 RawMark 添加到桌面或主屏幕。', keywords: 'PWA 安装' },
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import { localDate, renderTemplate } from '../markdown/template'
|
||||
import { api } from './api'
|
||||
import { joinPath } from './paths'
|
||||
import { settings } from './settings'
|
||||
import { createNoteAt, openNote, toast, ws } from './workspace'
|
||||
|
||||
export async function createFromTemplate(path: string, template: string) {
|
||||
if (ws.entries.some(entry => entry.path === path)) { toast('目标笔记已存在'); return false }
|
||||
const content = template ? renderTemplate((await api.readFile(template)).content, template, path) : ''
|
||||
const created = await createNoteAt(path, content)
|
||||
if (created) ws.sidebarOpen = false
|
||||
return created
|
||||
}
|
||||
|
||||
let openingDaily = false
|
||||
export async function openDailyNote() {
|
||||
// 模板读取也属于打开日记的过程,此时尚未加入乐观目录。
|
||||
if (openingDaily) return
|
||||
openingDaily = true
|
||||
try {
|
||||
const path = joinPath(settings.dailyFolder, localDate() + '.md')
|
||||
if (ws.entries.some(entry => !entry.dir && entry.path === path)) { await openNote(path); return }
|
||||
await createFromTemplate(path, settings.dailyTemplate)
|
||||
}
|
||||
catch (error) { toast('创建日记失败:' + (error instanceof Error ? error.message : '网络错误')) }
|
||||
finally { openingDaily = false }
|
||||
}
|
||||
@@ -7,9 +7,6 @@ import { drafts, type Draft } from './drafts'
|
||||
import { connectEvents } from './events'
|
||||
import { pluginEvents } from '../plugins/registry'
|
||||
import { attachView, closeTab, createNoteAt, editorStateOf, flush, keepLocalVersion, importNotes, move, remove, readyToReload, openNote, refreshTree, replaceImage, restore, startWorkspace, stopWorkspace, storeViewState, ws } from './workspace'
|
||||
import { openDailyNote } from './templates'
|
||||
import { settings } from './settings'
|
||||
import { localDate } from '../markdown/template'
|
||||
|
||||
vi.mock('./events', () => ({ connectEvents: vi.fn(() => () => {}) }))
|
||||
|
||||
@@ -143,32 +140,6 @@ describe('笔记打开与创建', () => {
|
||||
expect(ws.toasts.some(toast => toast.text.includes('打开失败:文件不存在'))).toBe(false)
|
||||
})
|
||||
|
||||
it('日记模板读取期间重复打开只创建一个标签,之后仍可再次打开', async () => {
|
||||
const previousFolder = settings.dailyFolder, previousTemplate = settings.dailyTemplate
|
||||
settings.dailyFolder = 'Daily-review'
|
||||
settings.dailyTemplate = 'Templates/daily.md'
|
||||
const path = settings.dailyFolder + '/' + localDate() + '.md'
|
||||
let finish!: (value: { content: string; rev: string }) => void
|
||||
vi.spyOn(api, 'readFile').mockImplementation(() => new Promise(resolve => { finish = resolve }))
|
||||
vi.spyOn(api, 'writeFile').mockResolvedValue('r1')
|
||||
vi.spyOn(drafts, 'delete').mockResolvedValue(undefined)
|
||||
try {
|
||||
const first = openDailyNote(), second = openDailyNote()
|
||||
finish({ content: '# {{date}}\n\n日记', rev: 'template-r1' })
|
||||
await Promise.all([first, second])
|
||||
expect(ws.tabs.filter(tab => tab.path === path)).toHaveLength(1)
|
||||
expect(editorStateOf(path)?.doc.toString()).toContain('日记')
|
||||
await closeTab(path)
|
||||
vi.mocked(api.readFile).mockResolvedValue({ content: '重新打开的日记', rev: 'r1' })
|
||||
await openDailyNote()
|
||||
expect(ws.tabs.filter(tab => tab.path === path)).toHaveLength(1)
|
||||
expect(editorStateOf(path)?.doc.toString()).toBe('重新打开的日记')
|
||||
await closeTab(path)
|
||||
} finally {
|
||||
settings.dailyFolder = previousFolder
|
||||
settings.dailyTemplate = previousTemplate
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('保存与关闭', () => {
|
||||
|
||||
@@ -35,7 +35,7 @@ export interface Tab {
|
||||
conflictRev?: string
|
||||
}
|
||||
|
||||
export type SettingsSection = 'appearance' | 'editor' | 'templates' | 'account' | 'shortcuts' | 'tokens' | 'trash' | 'plugins' | 'about'
|
||||
export type SettingsSection = 'appearance' | 'editor' | 'account' | 'shortcuts' | 'tokens' | 'trash' | 'plugins' | 'about'
|
||||
|
||||
export interface Toast {
|
||||
id: number
|
||||
@@ -78,7 +78,6 @@ export const ws = reactive({
|
||||
sourceMode: false,
|
||||
focusMode: false,
|
||||
view: 'notes' as 'notes' | 'graph' | 'tags',
|
||||
templateOpen: false,
|
||||
// 最近打开的笔记,最新的在前
|
||||
recent: JSON.parse(localStorage.getItem(RECENT_KEY) ?? '[]') as string[],
|
||||
toasts: [] as Toast[],
|
||||
@@ -1078,7 +1077,7 @@ registerBuiltinCommands()
|
||||
|
||||
function registerBuiltinCommands() {
|
||||
for (const command of editorCommands) registerCommand({ ...command,
|
||||
when: () => ws.view === 'notes' && ws.settings === null && ws.images === null && !ws.templateOpen && ws.active !== null && !!view,
|
||||
when: () => ws.view === 'notes' && ws.settings === null && ws.images === null && ws.active !== null && !!view,
|
||||
run: () => { if (view) { command.run(view); view.focus() } },
|
||||
})
|
||||
const hasNote = () => ws.active !== null
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { localDate, renderTemplate } from './template'
|
||||
|
||||
describe('Markdown 模板', () => {
|
||||
it('按本地日期替换变量,资源相对新笔记重定位并保留代码示例', () => {
|
||||
const now = new Date(2026, 9, 22, 8, 5)
|
||||
const source = '# {{title}}\n{{date}} {{time}}\n [文档][ref]\n\n[ref]: ../docs/guide.md#标题\n\n``\n```\n\n```'
|
||||
expect(localDate(now)).toBe('2026-10-22')
|
||||
const result = renderTemplate(source, 'Templates/日记.md', 'Daily/2026-10-22.md', now)
|
||||
expect(result).toContain('# 2026-10-22\n2026-10-22 08:05')
|
||||
expect(result).toContain('')
|
||||
expect(result).toContain('[ref]: ../docs/guide.md#%E6%A0%87%E9%A2%98')
|
||||
expect(result).toContain('``\n```\n\n```')
|
||||
})
|
||||
it('链接实体和 Markdown 转义按真实文件名重定位', () => {
|
||||
const result = renderTemplate('[文档](a&b.md) [括号](a\\(1\\).md)', 'Templates/日记.md', 'Daily/今天.md')
|
||||
expect(result).toContain('[文档](../Templates/a%26b.md)')
|
||||
expect(result).toContain('[括号](../Templates/a(1).md)')
|
||||
})
|
||||
})
|
||||
@@ -1,25 +0,0 @@
|
||||
import { markdownParser } from './html'
|
||||
import { decodeLinkDestination } from './links'
|
||||
|
||||
export function localDate(now = new Date()): string {
|
||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function renderTemplate(content: string, template: string, target: string, now = new Date()): string {
|
||||
const changes: { from: number; to: number; value: string }[] = []
|
||||
markdownParser.parse(content).iterate({ enter(node) {
|
||||
if (node.name !== 'URL' || !['Link', 'Image', 'LinkReference'].includes(node.node.parent?.name ?? '')) return
|
||||
const raw = decodeLinkDestination(content.slice(node.from, node.to).replace(/^<|>$/g, ''))
|
||||
if (/^(?:[a-z][a-z\d+.-]*:|\/|#)/i.test(raw)) return
|
||||
const url = new URL(raw, 'https://rawmark.local/' + template.split('/').map(encodeURIComponent).join('/'))
|
||||
const parts = decodeURIComponent(url.pathname.slice(1)).split('/')
|
||||
const base = target.slice(0, Math.max(0, target.lastIndexOf('/'))).split('/').filter(Boolean)
|
||||
while (base.length && base[0] === parts[0]) { base.shift(); parts.shift() }
|
||||
const relative = [...base.map(() => '..'), ...parts].map(part => encodeURIComponent(part)).join('/') + url.search + url.hash
|
||||
changes.push({ from: node.from, to: node.to, value: relative })
|
||||
} })
|
||||
for (const change of changes.reverse()) content = content.slice(0, change.from) + change.value + content.slice(change.to)
|
||||
const variables: Record<string, string> = { date: localDate(now), time: `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`, title: target.slice(target.lastIndexOf('/') + 1).replace(/\.md$/, '') }
|
||||
return content.replace(/\{\{(date|time|title)\}\}/g, (_, key: string) => variables[key])
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user