Add tree multiselection, batch actions and Markdown export
This commit is contained in:
1 parent
f473ec64b6
commit
ffc95d6dbc
14 files changed
+532
-46
No files matched your search
+2
-1
@@ -76,6 +76,7 @@ func (s *Server) routes() []route {
|
|||||||
{"POST /api/v1/auth/password", sessionOnly, s.changePassword},
|
{"POST /api/v1/auth/password", sessionOnly, s.changePassword},
|
||||||
|
|
||||||
{"GET /api/v1/tree", authed, s.tree},
|
{"GET /api/v1/tree", authed, s.tree},
|
||||||
|
{"POST /api/v1/export", authed, s.export},
|
||||||
{"GET /api/v1/files/{path...}", authed, s.readFile},
|
{"GET /api/v1/files/{path...}", authed, s.readFile},
|
||||||
{"PUT /api/v1/files/{path...}", authed, s.writeFile},
|
{"PUT /api/v1/files/{path...}", authed, s.writeFile},
|
||||||
{"DELETE /api/v1/files/{path...}", authed, s.deleteFile},
|
{"DELETE /api/v1/files/{path...}", authed, s.deleteFile},
|
||||||
@@ -182,7 +183,7 @@ func (s *Server) requireAuth(next http.Handler, allowToken bool) http.Handler {
|
|||||||
writeError(w, 401, "API 令牌无效")
|
writeError(w, 401, "API 令牌无效")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if scope != "write" && r.Method != http.MethodGet && r.Method != http.MethodHead {
|
if scope != "write" && r.Method != http.MethodGet && r.Method != http.MethodHead && !(r.Method == http.MethodPost && r.URL.Path == "/api/v1/export") {
|
||||||
writeError(w, 403, "只读令牌不能修改数据")
|
writeError(w, 403, "只读令牌不能修改数据")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"mime"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) export(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body struct {
|
||||||
|
Paths []string `json:"paths"`
|
||||||
|
}
|
||||||
|
if !readJSON(w, r, &body) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
file, name, err := s.vault.Export(r.Context(), body.Paths)
|
||||||
|
if err != nil {
|
||||||
|
vaultError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
defer os.Remove(file.Name())
|
||||||
|
contentType := "application/zip"
|
||||||
|
if strings.HasSuffix(name, ".md") {
|
||||||
|
contentType = "text/markdown; charset=utf-8"
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", contentType)
|
||||||
|
w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": name}))
|
||||||
|
http.ServeContent(w, r, name, time.Time{}, file)
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExportMarkdownAndReferencedAssets(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
f.write("资料/文章.md", "# 标题\n\n``\n[其他笔记](未选中.md)")
|
||||||
|
if err := os.MkdirAll(filepath.Join(f.root, "assets"), 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(f.root, "assets/图.png"), []byte("图片内容"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
response := f.do(f.admin, "POST", "/api/v1/export", `{"paths":["资料/文章.md"]}`, nil)
|
||||||
|
data, _ := io.ReadAll(response.Body)
|
||||||
|
response.Body.Close()
|
||||||
|
if response.StatusCode != 200 {
|
||||||
|
t.Fatalf("导出失败:%d %s", response.StatusCode, data)
|
||||||
|
}
|
||||||
|
reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
files := map[string]string{}
|
||||||
|
for _, file := range reader.File {
|
||||||
|
r, err := file.Open()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
content, _ := io.ReadAll(r)
|
||||||
|
r.Close()
|
||||||
|
files[file.Name] = string(content)
|
||||||
|
}
|
||||||
|
if files["assets/图.png"] != "图片内容" || !strings.Contains(files["资料/文章.md"], "../assets/图.png") || len(files) != 2 {
|
||||||
|
t.Fatalf("附件或目录不符:%v", files)
|
||||||
|
}
|
||||||
|
f.write("单篇.md", "原始 Markdown")
|
||||||
|
response = f.do(f.admin, "POST", "/api/v1/export", `{"paths":["单篇.md"]}`, nil)
|
||||||
|
defer response.Body.Close()
|
||||||
|
data, _ = io.ReadAll(response.Body)
|
||||||
|
if response.StatusCode != 200 || string(data) != "原始 Markdown" || !strings.HasPrefix(response.Header.Get("Content-Type"), "text/markdown") {
|
||||||
|
t.Fatalf("单篇导出不符:%d %s", response.StatusCode, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExportRejectsMissingAssetsAndSymlinks(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
f.write("文章.md", "")
|
||||||
|
for _, body := range []string{`{"paths":["文章.md"]}`, `{"paths":[".rawmark/app.db"]}`, `{"paths":[]}`} {
|
||||||
|
response := f.do(f.admin, "POST", "/api/v1/export", body, nil)
|
||||||
|
response.Body.Close()
|
||||||
|
if response.StatusCode == 200 {
|
||||||
|
t.Fatalf("不应成功:%s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.write("真实.md", "内容")
|
||||||
|
if err := os.Symlink(filepath.Join(f.root, "真实.md"), filepath.Join(f.root, "别名.md")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
response := f.do(f.admin, "POST", "/api/v1/export", `{"paths":["别名.md"]}`, nil)
|
||||||
|
response.Body.Close()
|
||||||
|
if response.StatusCode != 400 {
|
||||||
|
t.Fatalf("应拒绝符号链接:%d", response.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
"info": {
|
"info": {
|
||||||
"title": "RawMark API",
|
"title": "RawMark API",
|
||||||
"version": "1",
|
"version": "1",
|
||||||
"description": "RawMark 的 REST API。自动化调用请在「API 令牌」中创建令牌,以 `Authorization: Bearer <token>` 访问;只读令牌只能使用 GET。分享与令牌的管理接口只接受登录会话。所有错误响应均为 `{\"error\": \"说明\"}`。"
|
"description": "RawMark 的 REST API。自动化调用请在「API 令牌」中创建令牌,以 `Authorization: Bearer <token>` 访问;只读令牌可使用 GET 和 POST /export(只读导出)。分享与令牌的管理接口只接受登录会话。所有错误响应均为 `{\"error\": \"说明\"}`。"
|
||||||
},
|
},
|
||||||
"servers": [
|
"servers": [
|
||||||
{
|
{
|
||||||
@@ -2326,6 +2326,61 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"/export": {
|
||||||
|
"post": {
|
||||||
|
"summary": "导出原始 Markdown 和本地附件",
|
||||||
|
"description": "接收非空路径列表;单篇无附件返回 .md,目录、多选或含附件返回 ZIP。ZIP 保留仓库相对路径;不下载外部资源,不跟随其他未选中笔记。缺失附件或非法路径返回错误。此 POST 接口不修改数据,只读令牌可调用。",
|
||||||
|
"tags": [
|
||||||
|
"文件"
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"paths"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"paths": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "原始 Markdown 或 ZIP 附件",
|
||||||
|
"content": {
|
||||||
|
"application/zip": {
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "binary"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"text/markdown": {
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "路径无效"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "文件或附件不存在"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"components": {
|
"components": {
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
package vault
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"html"
|
||||||
|
"io"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Export 在临时文件中完成导出,成功后由调用方关闭并删除文件。
|
||||||
|
// ZIP 保留仓库相对路径,正文不改写;只导出原始文件,不解析或下载外部资源。
|
||||||
|
func (v *Vault) Export(ctx context.Context, paths []string) (result *os.File, name string, err error) {
|
||||||
|
if len(paths) == 0 {
|
||||||
|
return nil, "", fmt.Errorf("%w:请选择导出内容", ErrInvalidPath)
|
||||||
|
}
|
||||||
|
v.mu.Lock()
|
||||||
|
defer v.mu.Unlock()
|
||||||
|
files := map[string]bool{}
|
||||||
|
directory := false
|
||||||
|
for _, p := range paths {
|
||||||
|
info, e := v.Stat(p)
|
||||||
|
if e != nil {
|
||||||
|
return nil, "", e
|
||||||
|
}
|
||||||
|
if e = v.exportPath(p); e != nil {
|
||||||
|
return nil, "", e
|
||||||
|
}
|
||||||
|
if !info.IsDir() {
|
||||||
|
files[p] = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
directory = true
|
||||||
|
files[p] = true
|
||||||
|
entries, e := v.TreeAt(p)
|
||||||
|
if e != nil {
|
||||||
|
return nil, "", e
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
files[path.Join(p, entry.Path)] = entry.Dir
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 仅收集已选笔记的附件,不追踪链接到的其他笔记。
|
||||||
|
notes := make([]string, 0)
|
||||||
|
for p, dir := range files {
|
||||||
|
if !dir && IsNote(p) {
|
||||||
|
notes = append(notes, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, note := range notes {
|
||||||
|
if e := ctx.Err(); e != nil {
|
||||||
|
return nil, "", e
|
||||||
|
}
|
||||||
|
content, _, e := v.Read(note)
|
||||||
|
if e != nil {
|
||||||
|
return nil, "", e
|
||||||
|
}
|
||||||
|
var linkError error
|
||||||
|
transformMarkdownLines(content, importRef, func(line []byte) []byte {
|
||||||
|
return transformOutsideCode(line, func(text []byte) []byte {
|
||||||
|
for _, span := range importLinkSpans(text) {
|
||||||
|
target := string(text[span.start:span.end])
|
||||||
|
if span.html {
|
||||||
|
target = html.UnescapeString(target)
|
||||||
|
} else {
|
||||||
|
target = markdownEscape.ReplaceAllString(target, "$1")
|
||||||
|
}
|
||||||
|
u, e := url.Parse(target)
|
||||||
|
if e != nil {
|
||||||
|
linkError = fmt.Errorf("%w:附件地址无效 %s", ErrInvalidPath, target)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if u.IsAbs() || u.Host != "" || strings.HasPrefix(target, "/") || u.Path == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rel := path.Clean(path.Join(path.Dir(note), u.Path))
|
||||||
|
if IsNote(rel) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if e = v.exportPath(rel); e != nil {
|
||||||
|
linkError = fmt.Errorf("附件 %s:%w", rel, e)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
info, e := v.Stat(rel)
|
||||||
|
if e != nil {
|
||||||
|
linkError = fmt.Errorf("附件 %s:%w", rel, e)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if !info.IsDir() {
|
||||||
|
files[rel] = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if linkError != nil {
|
||||||
|
return nil, "", linkError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tmp, e := os.CreateTemp("", "rawmark-export-*")
|
||||||
|
if e != nil {
|
||||||
|
return nil, "", e
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err != nil {
|
||||||
|
tmp.Close()
|
||||||
|
os.Remove(tmp.Name())
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if len(paths) == 1 && !directory && len(files) == 1 && IsNote(paths[0]) {
|
||||||
|
source, _, e := v.OpenRaw(paths[0])
|
||||||
|
if e != nil {
|
||||||
|
return nil, "", e
|
||||||
|
}
|
||||||
|
_, e = io.Copy(tmp, source)
|
||||||
|
source.Close()
|
||||||
|
if e != nil {
|
||||||
|
return nil, "", e
|
||||||
|
}
|
||||||
|
name = path.Base(paths[0])
|
||||||
|
} else {
|
||||||
|
writer := zip.NewWriter(tmp)
|
||||||
|
ordered := make([]string, 0, len(files))
|
||||||
|
for p := range files {
|
||||||
|
ordered = append(ordered, p)
|
||||||
|
}
|
||||||
|
sort.Strings(ordered)
|
||||||
|
for _, p := range ordered {
|
||||||
|
if e := ctx.Err(); e != nil {
|
||||||
|
return nil, "", e
|
||||||
|
}
|
||||||
|
if e := v.exportPath(p); e != nil {
|
||||||
|
return nil, "", e
|
||||||
|
}
|
||||||
|
if files[p] {
|
||||||
|
if _, e := writer.Create(p + "/"); e != nil {
|
||||||
|
return nil, "", e
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
source, info, e := v.OpenRaw(p)
|
||||||
|
if e != nil {
|
||||||
|
return nil, "", e
|
||||||
|
}
|
||||||
|
header, e := zip.FileInfoHeader(info)
|
||||||
|
if e != nil {
|
||||||
|
source.Close()
|
||||||
|
return nil, "", e
|
||||||
|
}
|
||||||
|
header.Name, header.Method = p, zip.Deflate
|
||||||
|
dest, e := writer.CreateHeader(header)
|
||||||
|
if e == nil {
|
||||||
|
_, e = io.Copy(dest, source)
|
||||||
|
}
|
||||||
|
source.Close()
|
||||||
|
if e != nil {
|
||||||
|
return nil, "", e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if e := writer.Close(); e != nil {
|
||||||
|
return nil, "", e
|
||||||
|
}
|
||||||
|
name = "导出.zip"
|
||||||
|
if len(paths) == 1 {
|
||||||
|
name = strings.TrimSuffix(path.Base(paths[0]), NoteExt) + ".zip"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, e := tmp.Seek(0, io.SeekStart); e != nil {
|
||||||
|
return nil, "", e
|
||||||
|
}
|
||||||
|
return tmp, name, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 附件可能通过 ../ 指向其他目录,但不能读取隐藏路径或经符号链接绕过范围。
|
||||||
|
func (v *Vault) exportPath(rel string) error {
|
||||||
|
full, err := v.abs(rel)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
real, err := filepath.EvalSymlinks(full)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if real != full {
|
||||||
|
return ErrInvalidPath
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -1,27 +1,27 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
import { batchFiles } from './batch'
|
||||||
|
import { minimalPaths } from './selection'
|
||||||
import { fuzzyMatch, segments } from './fuzzy'
|
import { fuzzyMatch, segments } from './fuzzy'
|
||||||
import Icon from './Icon.vue'
|
import Icon from './Icon.vue'
|
||||||
import { basename, dirname, inAssetDir, joinPath, noteTitle } from './paths'
|
import { basename, dirname, inAssetDir, joinPath, noteTitle } from './paths'
|
||||||
import { move, ws } from './workspace'
|
import { move, ws } from './workspace'
|
||||||
|
|
||||||
// 「移动到…」:选择目标文件夹(手机上没有拖放,电脑上也比拖放快)
|
// 「移动到…」:选择目标文件夹(手机上没有拖放,电脑上也比拖放快)
|
||||||
const props = defineProps<{ path: string }>()
|
const props = defineProps<{ path: string | string[] }>()
|
||||||
const emit = defineEmits<{ close: [] }>()
|
const emit = defineEmits<{ close: [] }>()
|
||||||
|
|
||||||
const input = ref<HTMLInputElement>()
|
const input = ref<HTMLInputElement>()
|
||||||
const list = ref<HTMLElement>()
|
const list = ref<HTMLElement>()
|
||||||
const query = ref('')
|
const query = ref('')
|
||||||
const selected = ref(0)
|
const selected = ref(0)
|
||||||
const isDir = computed(() => ws.entries.some((e) => e.path === props.path && e.dir))
|
const paths = computed(() => minimalPaths(typeof props.path === 'string' ? [props.path] : props.path))
|
||||||
const title = computed(() => (isDir.value ? basename(props.path) : noteTitle(props.path)))
|
const title = computed(() => paths.value.length > 1 ? `${paths.value.length} 项` : noteTitle(paths.value[0]))
|
||||||
|
|
||||||
// 可选的目标:根目录与所有文件夹;排除当前所在的文件夹,移动文件夹时还要排除它自己及其子文件夹
|
|
||||||
const targets = computed(() => {
|
const targets = computed(() => {
|
||||||
const here = dirname(props.path)
|
const inside = (dir: string) => paths.value.some((path) => ws.entries.some((entry) => entry.path === path && entry.dir) && (dir === path || dir.startsWith(path + '/')))
|
||||||
const inside = (d: string) => isDir.value && (d === props.path || d.startsWith(props.path + '/'))
|
return ['', ...ws.entries.filter((entry) => entry.dir && !inAssetDir(entry.path)).map((entry) => entry.path)]
|
||||||
const dirs = ws.entries.filter((e) => e.dir && !inAssetDir(e.path) && e.path !== here && !inside(e.path)).map((e) => e.path)
|
.filter((dir) => !inside(dir) && !paths.value.every((path) => dirname(path) === dir))
|
||||||
return here === '' ? dirs : ['', ...dirs]
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const items = computed(() => {
|
const items = computed(() => {
|
||||||
@@ -42,7 +42,8 @@ function choose(i: number) {
|
|||||||
const target = items.value[i]
|
const target = items.value[i]
|
||||||
if (!target) return
|
if (!target) return
|
||||||
emit('close')
|
emit('close')
|
||||||
void move(props.path, joinPath(target.path, basename(props.path)))
|
if (paths.value.length === 1) void move(paths.value[0], joinPath(target.path, basename(paths.value[0])))
|
||||||
|
else void batchFiles('move', paths.value, target.path)
|
||||||
}
|
}
|
||||||
|
|
||||||
function onKeydown(e: KeyboardEvent) {
|
function onKeydown(e: KeyboardEvent) {
|
||||||
|
|||||||
+26
-13
@@ -2,8 +2,9 @@
|
|||||||
import { computed, nextTick, ref, watch } from 'vue'
|
import { computed, nextTick, ref, watch } from 'vue'
|
||||||
import Icon from './Icon.vue'
|
import Icon from './Icon.vue'
|
||||||
import { basename, inAssetDir, isImage, joinPath } from './paths'
|
import { basename, inAssetDir, isImage, joinPath } from './paths'
|
||||||
import { expanded, type TreeNode } from './tree'
|
import { contextSelection, expanded, selectedPaths, selectPath, type TreeNode } from './tree'
|
||||||
import { createFolder, createNote, move, openNote, remove, renameTarget, ws } from './workspace'
|
import { batchFiles, exportFiles } from './batch'
|
||||||
|
import { createFolder, createNote, move, openNote, renameTarget, ws } from './workspace'
|
||||||
|
|
||||||
const props = defineProps<{ node: TreeNode; depth: number }>()
|
const props = defineProps<{ node: TreeNode; depth: number }>()
|
||||||
|
|
||||||
@@ -28,6 +29,7 @@ function onTouchStart(e: TouchEvent) {
|
|||||||
pressStart = { x: e.touches[0].clientX, y: e.touches[0].clientY }
|
pressStart = { x: e.touches[0].clientX, y: e.touches[0].clientY }
|
||||||
pressTimer = setTimeout(() => {
|
pressTimer = setTimeout(() => {
|
||||||
longPressed = true
|
longPressed = true
|
||||||
|
contextSelection(props.node.path)
|
||||||
menuOpen.value = true
|
menuOpen.value = true
|
||||||
}, LONG_PRESS_MS)
|
}, LONG_PRESS_MS)
|
||||||
}
|
}
|
||||||
@@ -41,12 +43,14 @@ function cancelPress() {
|
|||||||
pressStart = null
|
pressStart = null
|
||||||
}
|
}
|
||||||
|
|
||||||
function activate() {
|
function activate(event: MouseEvent) {
|
||||||
// 长按后松手产生的点击不打开笔记
|
// 长按后松手产生的点击不打开笔记
|
||||||
if (longPressed) {
|
if (longPressed) {
|
||||||
longPressed = false
|
longPressed = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
selectPath(props.node.path, event)
|
||||||
|
if (event.ctrlKey || event.metaKey || event.shiftKey) return
|
||||||
if (props.node.dir) {
|
if (props.node.dir) {
|
||||||
if (isOpen.value) expanded.delete(props.node.path)
|
if (isOpen.value) expanded.delete(props.node.path)
|
||||||
else expanded.add(props.node.path)
|
else expanded.add(props.node.path)
|
||||||
@@ -73,7 +77,7 @@ function commitRename() {
|
|||||||
if (target && target !== props.node.path) void move(props.node.path, target)
|
if (target && target !== props.node.path) void move(props.node.path, target)
|
||||||
}
|
}
|
||||||
|
|
||||||
function runMenu(action: 'note' | 'folder' | 'import' | 'images' | 'rename' | 'move' | 'share' | 'delete') {
|
function runMenu(action: 'note' | 'folder' | 'import' | 'images' | 'rename' | 'move' | 'share' | 'delete' | 'export') {
|
||||||
menuOpen.value = false
|
menuOpen.value = false
|
||||||
const path = props.node.path
|
const path = props.node.path
|
||||||
if (action === 'note' || action === 'folder') {
|
if (action === 'note' || action === 'folder') {
|
||||||
@@ -83,8 +87,14 @@ function runMenu(action: 'note' | 'folder' | 'import' | 'images' | 'rename' | 'm
|
|||||||
else if (action === 'images') ws.images = path
|
else if (action === 'images') ws.images = path
|
||||||
else if (action === 'rename') ws.renaming = path
|
else if (action === 'rename') ws.renaming = path
|
||||||
else if (action === 'share') ws.sharing = path
|
else if (action === 'share') ws.sharing = path
|
||||||
else if (action === 'move') ws.moving = path
|
else if (action === 'move') ws.moving = [...selectedPaths]
|
||||||
else void remove(path)
|
else if (action === 'export') void exportFiles([...selectedPaths])
|
||||||
|
else void batchFiles('delete', [...selectedPaths])
|
||||||
|
}
|
||||||
|
|
||||||
|
function showMenu() {
|
||||||
|
contextSelection(props.node.path)
|
||||||
|
menuOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelRename() {
|
function cancelRename() {
|
||||||
@@ -119,11 +129,13 @@ function onDrop(e: DragEvent) {
|
|||||||
<li class="rm-tree-item">
|
<li class="rm-tree-item">
|
||||||
<div
|
<div
|
||||||
class="rm-tree-row"
|
class="rm-tree-row"
|
||||||
:class="{ active: isActive, drop: dropTarget, 'menu-open': menuOpen }"
|
:class="{ active: isActive, selected: selectedPaths.has(node.path), drop: dropTarget, 'menu-open': menuOpen }"
|
||||||
:style="{ paddingLeft: depth * 14 + 8 + 'px' }"
|
:style="{ paddingLeft: depth * 14 + 8 + 'px' }"
|
||||||
:draggable="!isRenaming && !assetDir"
|
:draggable="!isRenaming && !assetDir"
|
||||||
|
role="treeitem"
|
||||||
|
:aria-selected="selectedPaths.has(node.path)"
|
||||||
@click="activate"
|
@click="activate"
|
||||||
@contextmenu.prevent="menuOpen = true"
|
@contextmenu.prevent.stop="showMenu"
|
||||||
@touchstart.passive="onTouchStart"
|
@touchstart.passive="onTouchStart"
|
||||||
@touchmove.passive="onTouchMove"
|
@touchmove.passive="onTouchMove"
|
||||||
@touchend="cancelPress"
|
@touchend="cancelPress"
|
||||||
@@ -146,20 +158,21 @@ function onDrop(e: DragEvent) {
|
|||||||
/>
|
/>
|
||||||
<Icon v-if="image" name="image" :size="14" />
|
<Icon v-if="image" name="image" :size="14" />
|
||||||
<span v-if="!isRenaming" class="rm-tree-name">{{ node.dir ? node.name : node.name.replace(/\.md$/, '') }}</span>
|
<span v-if="!isRenaming" class="rm-tree-name">{{ node.dir ? node.name : node.name.replace(/\.md$/, '') }}</span>
|
||||||
<button class="rm-icon-btn rm-tree-more" title="更多操作" @click.stop="menuOpen = !menuOpen">
|
<button class="rm-icon-btn rm-tree-more" title="更多操作" @click.stop="menuOpen ? (menuOpen = false) : showMenu()">
|
||||||
<Icon name="more" />
|
<Icon name="more" />
|
||||||
</button>
|
</button>
|
||||||
<div v-if="menuOpen" class="rm-menu" @click.stop>
|
<div v-if="menuOpen" class="rm-menu" @click.stop>
|
||||||
<template v-if="node.dir && !assetDir">
|
<template v-if="node.dir && !assetDir && selectedPaths.size === 1">
|
||||||
<button @click="runMenu('note')"><Icon name="noteAdd" />新建笔记</button>
|
<button @click="runMenu('note')"><Icon name="noteAdd" />新建笔记</button>
|
||||||
<button @click="runMenu('folder')"><Icon name="folderAdd" />新建文件夹</button>
|
<button @click="runMenu('folder')"><Icon name="folderAdd" />新建文件夹</button>
|
||||||
<button @click="runMenu('images')"><Icon name="image" />图片管理</button>
|
<button @click="runMenu('images')"><Icon name="image" />图片管理</button>
|
||||||
<button @click="runMenu('import')"><Icon name="import" />导入</button>
|
<button @click="runMenu('import')"><Icon name="import" />导入</button>
|
||||||
</template>
|
</template>
|
||||||
<button v-if="image" @click="menuOpen = false; ws.image = node.path"><Icon name="image" />预览 / 替换图片</button>
|
<button v-if="image && selectedPaths.size === 1" @click="menuOpen = false; ws.image = node.path"><Icon name="image" />预览 / 替换图片</button>
|
||||||
<button v-if="!assetDir" @click="runMenu('rename')"><Icon name="edit" />重命名</button>
|
<button v-if="!assetDir && selectedPaths.size === 1" @click="runMenu('rename')"><Icon name="edit" />重命名</button>
|
||||||
<button v-if="!assetDir" @click="runMenu('move')"><Icon name="folder" />移动到…</button>
|
<button v-if="!assetDir" @click="runMenu('move')"><Icon name="folder" />移动到…</button>
|
||||||
<button v-if="!image" @click="runMenu('share')"><Icon name="link" />分享</button>
|
<button v-if="!image && selectedPaths.size === 1" @click="runMenu('share')"><Icon name="link" />分享</button>
|
||||||
|
<button @click="runMenu('export')"><Icon name="note" />导出 Markdown</button>
|
||||||
<button v-if="!assetDir" class="danger" @click="runMenu('delete')"><Icon name="trash" />删除</button>
|
<button v-if="!assetDir" class="danger" @click="runMenu('delete')"><Icon name="trash" />删除</button>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="menuOpen" class="rm-menu-backdrop" @click.stop="menuOpen = false" @contextmenu.prevent.stop="menuOpen = false" />
|
<div v-if="menuOpen" class="rm-menu-backdrop" @click.stop="menuOpen = false" @contextmenu.prevent.stop="menuOpen = false" />
|
||||||
|
|||||||
@@ -142,6 +142,8 @@ export const api = {
|
|||||||
return json<ImportResult>(request('POST', '/api/v1/import', { body }))
|
return json<ImportResult>(request('POST', '/api/v1/import', { body }))
|
||||||
},
|
},
|
||||||
|
|
||||||
|
exportFiles: (paths: string[]) => request('POST', '/api/v1/export', { json: { paths } }),
|
||||||
|
|
||||||
tree: async () => (await json<{ entries: Entry[] }>(request('GET', '/api/v1/tree'))).entries,
|
tree: async () => (await json<{ entries: Entry[] }>(request('GET', '/api/v1/tree'))).entries,
|
||||||
search: async (q: string) => (await json<{ results: SearchResult[] }>(request('GET', '/api/v1/search?q=' + encodeURIComponent(q)))).results,
|
search: async (q: string) => (await json<{ results: SearchResult[] }>(request('GET', '/api/v1/search?q=' + encodeURIComponent(q)))).results,
|
||||||
|
|
||||||
|
|||||||
@@ -546,3 +546,5 @@ button {
|
|||||||
.rm-shortcut-bindings input { width: 130px; padding: 8px; color: var(--rm-text); background: var(--rm-bg); border: 1px solid var(--rm-accent); }
|
.rm-shortcut-bindings input { width: 130px; padding: 8px; color: var(--rm-text); background: var(--rm-bg); border: 1px solid var(--rm-accent); }
|
||||||
|
|
||||||
.rm-context-menu { position: fixed; right: auto; width: max-content; }
|
.rm-context-menu { position: fixed; right: auto; width: max-content; }
|
||||||
|
|
||||||
|
.rm-tree-row.selected { background: var(--rm-active); box-shadow: inset 2px 0 var(--rm-accent); }
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { api } from './api'
|
||||||
|
import { basename, joinPath } from './paths'
|
||||||
|
import { minimalPaths } from './selection'
|
||||||
|
import { flush, move, refreshTree, remove, toast, ws } from './workspace'
|
||||||
|
|
||||||
|
export async function batchFiles(action: 'move' | 'delete', paths: string[], destination = '') {
|
||||||
|
const subjects = minimalPaths(paths)
|
||||||
|
if (action === 'delete' && !confirm(`将选中的 ${subjects.length} 项移入回收站?文件夹内的内容也会一起删除。`)) return
|
||||||
|
const failures: string[] = []
|
||||||
|
let succeeded = 0
|
||||||
|
for (const path of subjects) {
|
||||||
|
try {
|
||||||
|
const result = action === 'move' ? await move(path, joinPath(destination, basename(path))) : await remove(path)
|
||||||
|
if (result.ok) succeeded++
|
||||||
|
else failures.push(`${path}:${result.error}`)
|
||||||
|
} catch (cause) { failures.push(`${path}:${cause instanceof Error ? cause.message : String(cause)}`) }
|
||||||
|
}
|
||||||
|
await refreshTree().catch((cause) => toast('刷新目录失败:' + String(cause)))
|
||||||
|
toast(`${action === 'move' ? '移动' : '删除'}完成:成功 ${succeeded} 项,失败 ${failures.length} 项${failures.length ? ';' + failures.join(';') : ''}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportFiles(paths: string[]) {
|
||||||
|
const subjects = minimalPaths(paths)
|
||||||
|
const tabs = ws.tabs.filter((tab) => subjects.some((path) => tab.path === path || tab.path.startsWith(path + '/')))
|
||||||
|
try {
|
||||||
|
await Promise.all(tabs.map((tab) => flush(tab.path)))
|
||||||
|
if (tabs.some((tab) => tab.dirty || tab.status !== 'ready')) throw new Error('请先保存笔记并解决冲突,再导出')
|
||||||
|
const response = await api.exportFiles(subjects)
|
||||||
|
const blob = await response.blob()
|
||||||
|
const disposition = response.headers.get('Content-Disposition') ?? ''
|
||||||
|
const encoded = /filename\*=utf-8''([^;]+)/i.exec(disposition)?.[1]
|
||||||
|
const plain = /filename="([^"]+)"|filename=([^;]+)/i.exec(disposition)
|
||||||
|
const filename = encoded ? decodeURIComponent(encoded) : plain?.[1] ?? plain?.[2] ?? '导出.zip'
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const link = Object.assign(document.createElement('a'), { href: url, download: filename })
|
||||||
|
document.body.append(link)
|
||||||
|
link.click()
|
||||||
|
link.remove()
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||||
|
} catch (cause) { toast('导出失败:' + (cause instanceof Error ? cause.message : String(cause))) }
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { expect, it } from 'vitest'
|
||||||
|
import { minimalPaths, rangePaths } from './selection'
|
||||||
|
|
||||||
|
it('消除重叠主体,不受相似目录排序干扰', () => {
|
||||||
|
expect(minimalPaths(['a/b.md', 'a-b.md', 'a', 'a', 'z.md'])).toEqual(['a', 'a-b.md', 'z.md'])
|
||||||
|
})
|
||||||
|
it('Shift 范围只使用可见项,允许向上选择和丢失锚点', () => {
|
||||||
|
expect(rangePaths(['a', 'a/x.md', 'b'], 'b', 'a')).toEqual(['a', 'a/x.md', 'b'])
|
||||||
|
expect(rangePaths(['a', 'b'], 'removed', 'b')).toEqual(['b'])
|
||||||
|
})
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
// 父目录已经选中时不重复操作其内部项。
|
||||||
|
export function minimalPaths(paths: Iterable<string>): string[] {
|
||||||
|
const out: string[] = []
|
||||||
|
const roots = new Set<string>()
|
||||||
|
for (const path of [...new Set(paths)].sort()) {
|
||||||
|
let parent = path
|
||||||
|
let covered = false
|
||||||
|
while (parent.includes('/')) {
|
||||||
|
parent = parent.slice(0, parent.lastIndexOf('/'))
|
||||||
|
if (roots.has(parent)) { covered = true; break }
|
||||||
|
}
|
||||||
|
if (!covered) { out.push(path); roots.add(path) }
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rangePaths(visible: string[], anchor: string | null, target: string): string[] {
|
||||||
|
const from = anchor === null ? -1 : visible.indexOf(anchor)
|
||||||
|
const to = visible.indexOf(target)
|
||||||
|
return from < 0 || to < 0 ? [target] : visible.slice(Math.min(from, to), Math.max(from, to) + 1)
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { computed, reactive, watch } from 'vue'
|
import { computed, reactive, watch } from 'vue'
|
||||||
import type { Entry } from './api'
|
import type { Entry } from './api'
|
||||||
import { basename, dirname, hiddenAssetPath } from './paths'
|
import { basename, dirname, hiddenAssetPath } from './paths'
|
||||||
|
import { rangePaths } from './selection'
|
||||||
import { ws } from './workspace'
|
import { ws } from './workspace'
|
||||||
|
|
||||||
export interface TreeNode {
|
export interface TreeNode {
|
||||||
@@ -44,3 +45,35 @@ watch(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const selectedPaths = reactive(new Set<string>())
|
||||||
|
let anchor: string | null = null
|
||||||
|
export const visiblePaths = computed(() => {
|
||||||
|
const paths: string[] = []
|
||||||
|
const visit = (nodes: TreeNode[]) => { for (const node of nodes) { paths.push(node.path); if (expanded.has(node.path)) visit(node.children) } }
|
||||||
|
visit(tree.value)
|
||||||
|
return paths
|
||||||
|
})
|
||||||
|
watch(visiblePaths, (paths) => {
|
||||||
|
const visible = new Set(paths)
|
||||||
|
for (const path of selectedPaths) if (!visible.has(path)) selectedPaths.delete(path)
|
||||||
|
if (anchor && !visible.has(anchor)) anchor = null
|
||||||
|
})
|
||||||
|
|
||||||
|
export function selectPath(path: string, event?: Pick<MouseEvent, 'ctrlKey' | 'metaKey' | 'shiftKey'>) {
|
||||||
|
const additive = event?.ctrlKey || event?.metaKey
|
||||||
|
if (event?.shiftKey) {
|
||||||
|
const range = rangePaths(visiblePaths.value, anchor, path)
|
||||||
|
if (!additive) selectedPaths.clear()
|
||||||
|
range.forEach((item) => selectedPaths.add(item))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!additive) selectedPaths.clear()
|
||||||
|
if (additive && selectedPaths.has(path)) selectedPaths.delete(path)
|
||||||
|
else selectedPaths.add(path)
|
||||||
|
anchor = path
|
||||||
|
}
|
||||||
|
|
||||||
|
export function contextSelection(path: string) {
|
||||||
|
if (!selectedPaths.has(path)) selectPath(path)
|
||||||
|
}
|
||||||
+27
-22
@@ -63,7 +63,7 @@ export const ws = reactive({
|
|||||||
// 分享当前笔记的对话框(笔记路径)
|
// 分享当前笔记的对话框(笔记路径)
|
||||||
sharing: null as string | null,
|
sharing: null as string | null,
|
||||||
// 「移动到…」对话框(笔记或文件夹路径)
|
// 「移动到…」对话框(笔记或文件夹路径)
|
||||||
moving: null as string | null,
|
moving: null as string | string[] | null,
|
||||||
// 导入目标目录,空字符串为根目录
|
// 导入目标目录,空字符串为根目录
|
||||||
importing: null as string | null,
|
importing: null as string | null,
|
||||||
// 图片预览与管理,独立于笔记标签和草稿
|
// 图片预览与管理,独立于笔记标签和草稿
|
||||||
@@ -715,32 +715,34 @@ export function renameTarget(path: string, name: string): string | null {
|
|||||||
return joinPath(dirname(path), name)
|
return joinPath(dirname(path), name)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function move(from: string, to: string) {
|
export type FileResult = { ok: true } | { ok: false; error: string }
|
||||||
if (from === to) return
|
function operationFailed(error: string): FileResult {
|
||||||
if (creating.has(from) && !(await creating.get(from))) return
|
toast(error)
|
||||||
|
return { ok: false, error }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function move(from: string, to: string): Promise<FileResult> {
|
||||||
|
if (from === to) return { ok: true }
|
||||||
|
if (creating.has(from) && !(await creating.get(from))) return operationFailed('笔记尚未创建成功')
|
||||||
if (ws.entries.some((e) => e.path === to)) {
|
if (ws.entries.some((e) => e.path === to)) {
|
||||||
toast(`“${basename(to)}”已存在`)
|
return operationFailed(`“${basename(to)}”已存在`)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
await withOp(async () => {
|
const result = await withOp<FileResult>(async () => {
|
||||||
// 图片改名会更新仓库中的引用,先保存已打开笔记,沿用版本冲突保护。
|
// 图片改名会更新仓库中的引用,先保存已打开笔记,沿用版本冲突保护。
|
||||||
const imageMove = isImage(from) && ws.entries.some((e) => e.path === from && !e.dir)
|
const imageMove = isImage(from) && ws.entries.some((e) => e.path === from && !e.dir)
|
||||||
const impacted = () => imageMove ? ws.tabs.map((t) => t.path) : affected(from)
|
const impacted = () => imageMove ? ws.tabs.map((t) => t.path) : affected(from)
|
||||||
const busy = () => impacted().some((p) => slots.get(p)?.uploads || slots.get(p)?.paused)
|
const busy = () => impacted().some((p) => slots.get(p)?.uploads || slots.get(p)?.paused)
|
||||||
if (busy()) {
|
if (busy()) {
|
||||||
toast('笔记正在处理粘贴、上传或移动,请完成后再移动')
|
return operationFailed('笔记正在处理粘贴、上传或移动,请完成后再移动')
|
||||||
return
|
|
||||||
}
|
}
|
||||||
// 先保存待保存的修改,确保移动的是最新内容;移动期间暂停保存,避免写到旧路径
|
// 先保存待保存的修改,确保移动的是最新内容;移动期间暂停保存,避免写到旧路径
|
||||||
await Promise.all(impacted().map((p) => flush(p)))
|
await Promise.all(impacted().map((p) => flush(p)))
|
||||||
// 等待保存时仍可能开始粘贴,须在取得移动占用前再次检查。
|
// 等待保存时仍可能开始粘贴,须在取得移动占用前再次检查。
|
||||||
if (busy()) {
|
if (busy()) {
|
||||||
toast('笔记正在处理粘贴、上传或移动,请完成后再移动')
|
return operationFailed('笔记正在处理粘贴、上传或移动,请完成后再移动')
|
||||||
return
|
|
||||||
}
|
}
|
||||||
if (imageMove && ws.tabs.some((t) => t.dirty || t.status !== 'ready')) {
|
if (impacted().some((p) => { const tab = tabOf(p); return tab && (tab.dirty || tab.status !== 'ready') })) {
|
||||||
toast('请先保存笔记并解决冲突,再修改图片名称或位置')
|
return operationFailed('请先保存笔记并解决冲突,再移动文件')
|
||||||
return
|
|
||||||
}
|
}
|
||||||
const paused = impacted().map(slot)
|
const paused = impacted().map(slot)
|
||||||
paused.forEach((s) => s.paused++)
|
paused.forEach((s) => s.paused++)
|
||||||
@@ -770,8 +772,7 @@ export async function move(from: string, to: string) {
|
|||||||
try { await drafts.move(to, from) }
|
try { await drafts.move(to, from) }
|
||||||
catch (cause) { toast('恢复原路径草稿失败:' + (cause instanceof Error ? cause.message : errorText(cause))) }
|
catch (cause) { toast('恢复原路径草稿失败:' + (cause instanceof Error ? cause.message : errorText(cause))) }
|
||||||
}
|
}
|
||||||
toast('移动失败:' + (err instanceof Error ? err.message : errorText(err)))
|
return operationFailed('移动失败:' + (err instanceof Error ? err.message : errorText(err)))
|
||||||
return
|
|
||||||
} finally {
|
} finally {
|
||||||
paused.forEach((s) => s.paused--)
|
paused.forEach((s) => s.paused--)
|
||||||
}
|
}
|
||||||
@@ -784,21 +785,24 @@ export async function move(from: string, to: string) {
|
|||||||
const { rev } = await api.readFile(p)
|
const { rev } = await api.readFile(p)
|
||||||
if (rev !== tab.rev) Object.assign(tab, { status: 'conflict', conflictRev: rev })
|
if (rev !== tab.rev) Object.assign(tab, { status: 'conflict', conflictRev: rev })
|
||||||
} else await syncOpenTab(tab)
|
} else await syncOpenTab(tab)
|
||||||
}))
|
})).catch((err) => toast('文件已移动,但同步笔记失败:' + errorText(err)))
|
||||||
|
return { ok: true }
|
||||||
})
|
})
|
||||||
void refreshTree().catch((err) => toast('刷新目录失败:' + errorText(err)))
|
void refreshTree().catch((err) => toast('刷新目录失败:' + errorText(err)))
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function remove(path: string) {
|
export async function remove(path: string): Promise<FileResult> {
|
||||||
const entry = ws.entries.find((e) => e.path === path)
|
const entry = ws.entries.find((e) => e.path === path)
|
||||||
if (!entry) return
|
if (!entry) return operationFailed('文件已不存在')
|
||||||
await withOp(async () => {
|
return withOp<FileResult>(async () => {
|
||||||
const paths = affected(path)
|
const paths = affected(path)
|
||||||
await Promise.all(paths.map((p) => flush(p)))
|
await Promise.all(paths.map((p) => flush(p)))
|
||||||
|
if (paths.some((p) => { const tab = tabOf(p); return tab && (tab.dirty || tab.status !== 'ready') })) return operationFailed('请先保存笔记并解决冲突,再删除文件')
|
||||||
const snapshot = ws.entries
|
const snapshot = ws.entries
|
||||||
ws.entries = ws.entries.filter((e) => remapPath(e.path, path, path) === null) // 去掉自身及其下所有条目
|
ws.entries = ws.entries.filter((e) => remapPath(e.path, path, path) === null) // 去掉自身及其下所有条目
|
||||||
for (const p of paths) {
|
for (const p of paths) {
|
||||||
if (!(await closeTab(p))) { ws.entries = snapshot; return }
|
if (!(await closeTab(p))) { ws.entries = snapshot; return operationFailed('关闭笔记失败,未删除文件') }
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const item = await api.deleteFile(path)
|
const item = await api.deleteFile(path)
|
||||||
@@ -807,9 +811,10 @@ export async function remove(path: string) {
|
|||||||
label: '撤销',
|
label: '撤销',
|
||||||
run: () => void restore(item.id),
|
run: () => void restore(item.id),
|
||||||
})
|
})
|
||||||
|
return { ok: true }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
ws.entries = snapshot
|
ws.entries = snapshot
|
||||||
toast('删除失败:' + errorText(err))
|
return operationFailed('删除失败:' + errorText(err))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user