diff --git a/README.md b/README.md index 205355e..ef0ff83 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ docker run -d --name rawmark \ - 笔记以普通 `.md` 文件保存在 `数据目录/vault/`,图片按 Typora 习惯存放在同级的 `笔记名.assets/`,可直接用 Typora 等编辑器打开;外部修改会实时同步到网页。 - 知识工具:标准 Markdown 链接构成图谱和反向链接,支持正文 `#标签` 与 YAML `tags`。笔记或文件夹在应用内改名、移动时维护入链和相对出链;公式和代码示例不参与关系提取。 - 日记与模板:默认日记路径为 `Daily/YYYY-MM-DD.md`(本地日期),已有日记直接打开;模板为 `Templates/` 下的普通 Markdown,支持 `{{date}}`、`{{time}}`、`{{title}}`,资源链接按新笔记位置调整。可在设置中修改目录和日记模板。 -- 外观:浅色玻璃工作台与深色模式,字体资源随应用本地提供;排版、玻璃透明度、选中文本工具栏均可在设置中调整并即时保存。 +- 外观:浅色玻璃工作台与深色模式,字体资源随应用本地提供;界面字号默认 16px,可输入 14–20px 并单独重置,与编辑器及阅读字号独立;排版、玻璃透明度、选中文本工具栏均可在设置中调整并即时保存。 - 图片管理:侧栏默认隐藏 `assets` 和 `笔记名.assets`;右键文件夹选择「图片管理」,查看当前目录及直属附件目录的图片,支持检索、排序、网格/列表、批量上传、引用查看及预览、重命名、移动、替换。可切换整个知识库;清理孤立图片须确认候选,服务端再次核对全库引用与有效分享后移入回收站,可恢复。上传至所选目录的 `assets/`,相同内容自动去重。根目录可在侧栏空白处右键或通过命令面板打开。图片改名/移动会更新笔记中的本地引用;替换保留路径,选择相同扩展名的图片(最大 50 MB),旧图片不进回收站。 - `--user` 让容器以宿主机用户身份读写数据目录;不指定时容器以 UID 65532 运行,需要该用户对目录有写权限。 - 请通过 HTTPS 反向代理(Caddy、Nginx 等)访问,并转发 WebSocket(`/api/v1/events`)。 diff --git a/internal/api/openapi.json b/internal/api/openapi.json index 7058ccf..00895ce 100644 --- a/internal/api/openapi.json +++ b/internal/api/openapi.json @@ -1810,6 +1810,13 @@ "dark" ] }, + "uiFontSize": { + "type": "integer", + "minimum": 14, + "maximum": 20, + "default": 16, + "description": "界面字号,与正文排版字号独立" + }, "fontSize": { "type": "integer" }, diff --git a/internal/api/themes.go b/internal/api/themes.go index 458dd9f..be61e17 100644 --- a/internal/api/themes.go +++ b/internal/api/themes.go @@ -154,7 +154,7 @@ func (s *Server) publicAppearance(w http.ResponseWriter, r *http.Request) { return } out := map[string]any{} - for _, key := range []string{"colorMode", "fontSize", "contentWidth", "textFont", "codeFont", "lineHeight", "paragraphSpacing", "glassOpacity"} { + for _, key := range []string{"colorMode", "uiFontSize", "fontSize", "contentWidth", "textFont", "codeFont", "lineHeight", "paragraphSpacing", "glassOpacity"} { if value, ok := settings[key]; ok { out[key] = value } diff --git a/internal/api/themes_test.go b/internal/api/themes_test.go index d06d60c..8b3193c 100644 --- a/internal/api/themes_test.go +++ b/internal/api/themes_test.go @@ -43,7 +43,7 @@ func TestThemeAndShareAppearanceBoundaries(t *testing.T) { t.Fatal("未读取到主题 CSS") } f.write("笔记.md", "正文") - f.do(f.admin, "PATCH", "/api/v1/settings", `{"theme":"paper","colorMode":"dark","fontSize":18,"privateSetting":"不公开","cssSnippets":[{"id":"a","name":"启用","css":"body { color: red }","enabled":true},{"id":"b","name":"停用","css":"不公开片段","enabled":false}]}`, nil) + f.do(f.admin, "PATCH", "/api/v1/settings", `{"theme":"paper","colorMode":"dark","fontSize":18,"uiFontSize":20,"privateSetting":"不公开","cssSnippets":[{"id":"a","name":"启用","css":"body { color: red }","enabled":true},{"id":"b","name":"停用","css":"不公开片段","enabled":false}]}`, nil) sh := decode[shareView](t, f.do(f.admin, "POST", "/api/v1/shares", `{"path":"笔记.md","password":"访问密码"}`, nil)) base := "/api/v1/public/shares/" + sh.Token if code := f.status(guest, "GET", base+"/appearance", "", nil); code != 401 { @@ -59,7 +59,7 @@ func TestThemeAndShareAppearanceBoundaries(t *testing.T) { if err := json.Unmarshal(raw, &appearance); err != nil { t.Fatal(err) } - if res.StatusCode != 200 || appearance["colorMode"] != "dark" || !strings.Contains(appearance["themeCSS"].(string), "#faf6ed") { + if res.StatusCode != 200 || appearance["colorMode"] != "dark" || appearance["uiFontSize"] != float64(20) || appearance["fontSize"] != float64(18) || !strings.Contains(appearance["themeCSS"].(string), "#faf6ed") { t.Fatalf("分享外观错误:%s", raw) } if strings.Contains(string(raw), "不公开") || len(appearance["cssSnippets"].([]any)) != 1 { diff --git a/internal/search/search.go b/internal/search/search.go index 5308a3c..dccf34d 100644 --- a/internal/search/search.go +++ b/internal/search/search.go @@ -19,8 +19,8 @@ import ( "rawmark/internal/vault" ) -// 修改表结构时递增,旧索引会被丢弃重建 -const schemaVersion = 2 +// 表结构或派生元数据的解析规则变化时递增,旧索引会被丢弃重建。 +const schemaVersion = 3 const schema = ` CREATE TABLE docs ( diff --git a/internal/search/search_test.go b/internal/search/search_test.go index e3a3c70..309933f 100644 --- a/internal/search/search_test.go +++ b/internal/search/search_test.go @@ -205,3 +205,22 @@ func TestMetadataTracksChangesAndRebuild(t *testing.T) { t.Fatalf("索引恢复错误:%+v %v", notes, err) } } + +func TestRebuildsLinkedImageMetadataFromOldIndex(t *testing.T) { + f := newFixture(t) + f.write("a.md", "[![预览](assets/used.png)](https://example.com)") + f.sync() + // 模拟旧版索引漏记图片引用;笔记正文和 rev 均不改变。 + if _, err := f.idx.db.Exec(`UPDATE docs SET metadata = ?`, `{"title":"a","tags":[],"links":[],"assets":[],"warnings":[]}`); err != nil { + t.Fatal(err) + } + if _, err := f.idx.db.Exec(`PRAGMA user_version = 2`); err != nil { + t.Fatal(err) + } + f.reopen() + f.sync() + notes, ready, err := f.idx.Metadata() + if err != nil || !ready || len(notes) != 1 || len(notes[0].Assets) != 1 || notes[0].Assets[0].Target != "assets/used.png" { + t.Fatalf("旧索引未重新提取图片引用:%+v %v", notes, err) + } +} diff --git a/internal/vault/cleanup_test.go b/internal/vault/cleanup_test.go index ded64a3..b84a621 100644 --- a/internal/vault/cleanup_test.go +++ b/internal/vault/cleanup_test.go @@ -66,3 +66,33 @@ func TestFolderUploadReusesDeduplication(t *testing.T) { t.Fatalf("目标目录不存在应报错:%v", err) } } + +func TestCleanupProtectsLinkedImages(t *testing.T) { + for _, style := range []string{"内联链接", "引用式链接"} { + t.Run(style, func(t *testing.T) { + v := newVault(t) + link, err := v.SaveFolderAsset("", "preview.png", strings.NewReader("image"), 100) + if err != nil { + t.Fatal(err) + } + content := "[![预览](" + link + ")](https://example.com)" + if style == "引用式链接" { + content = "[![预览][image]][site]\n\n[image]: " + link + "\n[site]: https://example.com\n" + } + if _, err := v.Write("a.md", []byte(content), Precondition{}); err != nil { + t.Fatal(err) + } + metadata := Analyze("a.md", []byte(content)) + if len(metadata.Assets) != 1 || metadata.Assets[0].Target != link { + t.Fatalf("链接内图片未记录引用:%+v", metadata) + } + result, err := v.CleanupImages([]string{link}, func() ([]string, error) { return nil, nil }) + if err != nil || len(result.Deleted) != 0 || len(result.Skipped) != 1 || result.Skipped[0].Reason != "仍被笔记引用" { + t.Fatalf("链接内图片未受保护:%+v %v", result, err) + } + if _, err := v.Stat(link); err != nil { + t.Fatal("正文引用的图片不应被移走", err) + } + }) + } +} diff --git a/internal/vault/metadata.go b/internal/vault/metadata.go index ba2ea68..8329334 100644 --- a/internal/vault/metadata.go +++ b/internal/vault/metadata.go @@ -132,7 +132,7 @@ func Analyze(note string, content []byte) Metadata { return ast.WalkSkipChildren, nil case *ast.Link: addRef(node.Destination.Str(source), node.Pos()) - return ast.WalkSkipChildren, nil + // 链接文字中可以嵌套图片,仍须记录其资源引用。 case *ast.Image: addRef(node.Destination.Str(source), node.Pos()) return ast.WalkSkipChildren, nil diff --git a/internal/vault/metadata_test.go b/internal/vault/metadata_test.go index b543a0b..0293bec 100644 --- a/internal/vault/metadata_test.go +++ b/internal/vault/metadata_test.go @@ -93,3 +93,27 @@ func TestRenamedAssetsPreserveMathExamples(t *testing.T) { t.Fatalf("公式示例被修改:%s", got) } } + +func TestMovePreservesEntityShapedFilenames(t *testing.T) { + v := newVault(t) + for p, body := range map[string]string{"source.md": "[目标](target.md)", "target.md": "正文"} { + if _, err := v.Write(p, []byte(body), Precondition{}); err != nil { + t.Fatal(err) + } + } + const target = "a©.md" + if _, err := v.Move("target.md", target, nil); err != nil { + t.Fatal(err) + } + body, _, err := v.Read("source.md") + if err != nil { + t.Fatal(err) + } + metadata := Analyze("source.md", body) + if string(body) != "[目标](a%26copy;.md)" || len(metadata.Links) != 1 || metadata.Links[0].Target != target { + t.Fatalf("改名后的链接指向错误:%s %+v", body, metadata.Links) + } + if _, _, err := v.Read(metadata.Links[0].Target); err != nil { + t.Fatal("改写后的链接无法打开", err) + } +} diff --git a/internal/vault/vault.go b/internal/vault/vault.go index 4ee570c..ff16a68 100644 --- a/internal/vault/vault.go +++ b/internal/vault/vault.go @@ -785,4 +785,4 @@ func EscapeLinkPath(s string) string { return linkEscaper.Replace(s) } -var linkEscaper = strings.NewReplacer("%", "%25", "#", "%23", "?", "%3F", " ", "%20", "(", "%28", ")", "%29", "<", "%3C", ">", "%3E") +var linkEscaper = strings.NewReplacer("%", "%25", "&", "%26", "#", "%23", "?", "%3F", " ", "%20", "(", "%28", ")", "%29", "<", "%3C", ">", "%3E") diff --git a/web/src/app/Icon.vue b/web/src/app/Icon.vue index 6300f57..4361d85 100644 --- a/web/src/app/Icon.vue +++ b/web/src/app/Icon.vue @@ -44,8 +44,9 @@ defineProps<{ name: IconName; size?: number }>() diff --git a/web/src/app/ImportDialog.vue b/web/src/app/ImportDialog.vue index 0bed2d8..6831a44 100644 --- a/web/src/app/ImportDialog.vue +++ b/web/src/app/ImportDialog.vue @@ -102,13 +102,14 @@ async function submit() { diff --git a/web/src/app/KnowledgePanel.vue b/web/src/app/KnowledgePanel.vue index d4edfca..1dbf932 100644 --- a/web/src/app/KnowledgePanel.vue +++ b/web/src/app/KnowledgePanel.vue @@ -75,10 +75,11 @@ watch(() => ws.view, () => { query.value = ''; tag.value = '' }) diff --git a/web/src/app/MobileToolbar.vue b/web/src/app/MobileToolbar.vue index a0aaab8..55b5d81 100644 --- a/web/src/app/MobileToolbar.vue +++ b/web/src/app/MobileToolbar.vue @@ -1,7 +1,7 @@ diff --git a/web/src/app/NoteRelations.vue b/web/src/app/NoteRelations.vue index 8b46071..c4f4ab9 100644 --- a/web/src/app/NoteRelations.vue +++ b/web/src/app/NoteRelations.vue @@ -18,9 +18,9 @@ const note = computed(() => metadata.notes.find(note => note.path === ws.active) diff --git a/web/src/app/SettingsDialog.vue b/web/src/app/SettingsDialog.vue index c25c60a..57500cf 100644 --- a/web/src/app/SettingsDialog.vue +++ b/web/src/app/SettingsDialog.vue @@ -4,7 +4,7 @@ import { api, ApiError } from './api' import { runCommand } from './commands' import Icon, { type IconName } from './Icon.vue' import AppearanceSettings from './AppearanceSettings.vue' -import { CODE_FONTS, TEXT_FONTS } from '../themes/typography' +import { CODE_FONTS, TEXT_FONTS, UI_FONT_SIZE } from '../themes/typography' import PluginsPanel from './PluginsPanel.vue' import { installApp, installPrompt } from './pwa' import { CONTENT_WIDTHS, TAB_SIZES, loadSettings, settings, updateSettings, type Settings } from './settings' @@ -71,6 +71,17 @@ function changeFont(event: Event) { if (value !== settings.fontSize) void change({ fontSize: value }) } +function changeUIFont(event: Event) { + const input = event.target as HTMLInputElement + const value = input.valueAsNumber + if (!Number.isInteger(value) || value < UI_FONT_SIZE.min || value > UI_FONT_SIZE.max) { + toast(`界面字号须为 ${UI_FONT_SIZE.min}–${UI_FONT_SIZE.max} 之间的整数`) + input.value = String(settings.uiFontSize) + return + } + 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() @@ -124,14 +135,23 @@ async function changePassword() {
diff --git a/web/src/app/TreeNode.vue b/web/src/app/TreeNode.vue index 208b745..089038b 100644 --- a/web/src/app/TreeNode.vue +++ b/web/src/app/TreeNode.vue @@ -15,6 +15,9 @@ const isOpen = computed(() => expanded.has(props.node.path)) const isActive = computed(() => (image.value ? ws.image : ws.active) === props.node.path) const isRenaming = computed(() => ws.renaming === props.node.path) const menuOpen = ref(false) +const row = ref() +const menuElement = ref() +const menuPosition = ref({ left: 0, top: 0 }) const dropTarget = ref(false) const input = ref() const draft = ref('') @@ -30,8 +33,7 @@ function onTouchStart(e: TouchEvent) { pressStart = { x: e.touches[0].clientX, y: e.touches[0].clientY } pressTimer = setTimeout(() => { longPressed = true - contextSelection(props.node.path) - menuOpen.value = true + showMenu() }, LONG_PRESS_MS) } @@ -98,6 +100,21 @@ function showMenu() { menuOpen.value = true } +watch(menuOpen, async (open, _previous, onCleanup) => { + if (!open) return + const close = () => { menuOpen.value = false } + window.addEventListener('resize', close) + onCleanup(() => window.removeEventListener('resize', close)) + await nextTick() + if (!menuOpen.value || !row.value || !menuElement.value) return + const anchor = row.value.getBoundingClientRect() + const { width, height } = menuElement.value.getBoundingClientRect() + menuPosition.value = { + left: Math.max(4, Math.min(anchor.right - width, innerWidth - width - 4)), + top: Math.max(4, Math.min(anchor.bottom, innerHeight - height - 4)), + } +}) + function cancelRename() { ws.renaming = null } @@ -129,6 +146,7 @@ function onDrop(e: DragEvent) {