vitepress-plugin-filelist 0.2.2 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,8 +5,8 @@
5
5
  ## 功能
6
6
 
7
7
  - 📁 树形目录,文件夹按需加载
8
- - ✅ 勾选文件;选中文件夹会连带其已加载的子项
9
- - 📦 批量打包 ZIP(基于 `fflate`),带进度
8
+ - ✅ 勾选文件;选中文件夹会自动加载并选中其下所有层级的文件
9
+ - 📦 批量打包 ZIP(基于 `fflate`),带进度;保留目录结构,不同目录的同名文件不会互相覆盖
10
10
  - ⬇️ 单文件下载与预览(`/d/`、`/p/`、`fs/get` 的 `raw_url`)
11
11
  - 🔐 目录密码输入,可选只读 token
12
12
  - 🔎 当前树过滤,按名称 / 大小 / 日期排序
@@ -99,8 +99,8 @@ export default {
99
99
 
100
100
  - 目录路径挂载为根文件夹,默认折叠;`expanded` 可为全局或单个 source 打开
101
101
  - 文件路径挂载为一条文件记录,直接预览/下载
102
- - 根目录复选框与勾选、全选效果一致:选中根目录会选中其已加载的子项
103
- - 每个 source 可单独设置 `title`、`password`
102
+ - 勾选根目录会加载并选中其下所有文件;ZIP 内以根节点名称(`title` 或路径末段)作为顶层文件夹,文件根保留原文件名
103
+ - 每个 source 可单独设置 `title`、`password`;未设置 `password` 的 source 使用组件的 `password` 或运行时输入的密码,遇到需要密码的目录会弹出密码输入框
104
104
  - 根节点顺序按 `sources` 顺序,子项仍按名称/大小/日期排序
105
105
 
106
106
  ```ts
@@ -181,7 +181,7 @@ Deno 可以安装 npm 依赖,但目前用 Deno 构建 VitePress 会在 rolldow
181
181
  ## 说明
182
182
 
183
183
  - 批量 ZIP 通过 `raw_url`(OpenList `/p/` 代理)下载,CORS 保持在 OpenList 源上
184
- - 全选只统计已加载的文件;页脚会显示还有多少文件夹未展开
184
+ - 表头全选只统计已加载的文件,页脚会显示还有多少文件夹未展开;勾选单个文件夹则会递归加载其全部子目录(目录很深或很多时会逐个请求,可能较慢)
185
185
  - 目录以 `per_page: 0` 请求全部条目;超大目录可能较慢,暂未实现分页
186
186
 
187
187
  ## 开发
@@ -190,7 +190,7 @@ Deno 可以安装 npm 依赖,但目前用 Deno 构建 VitePress 会在 rolldow
190
190
  pnpm install
191
191
  pnpm run build # 生成 dist/node、dist/client/ssr、dist/client/browser、style.css
192
192
  pnpm run typecheck
193
- pnpm test # Vitest:utils + locale helpers
193
+ pnpm test # Vitest:utils、locale helpers、FileList 组件
194
194
  pnpm run pack:check
195
195
  ```
196
196
 
@@ -171,6 +171,18 @@ export interface SortableNode {
171
171
  export type SortKey = 'name' | 'size' | 'modified';
172
172
  export declare function joinPath(parent: string, name: string): string;
173
173
  export declare function encodePath(path: string): string;
174
+ /**
175
+ * Make one path segment safe as a ZIP entry segment: no separators,
176
+ * no `.` / `..` traversal, no empty names.
177
+ */
178
+ export declare function zipSegment(name: string): string;
179
+ /** Join path segments into a ZIP entry name, sanitising each segment. */
180
+ export declare function zipEntryName(segments: string[]): string;
181
+ /**
182
+ * Return `name`, or `name (2)`, `name (3)`... when it is already in `used`.
183
+ * The suffix goes before the extension of the last segment. Adds the result to `used`.
184
+ */
185
+ export declare function uniqueEntryName(name: string, used: Set<string>): string;
174
186
  export declare function formatSize(size: number): string;
175
187
  export declare function formatDate(iso: string): string;
176
188
  export declare function extOf(name: string): string;
@@ -87,6 +87,41 @@ function joinPath(parent, name) {
87
87
  function encodePath(path) {
88
88
  return path.split("/").map(encodeURIComponent).join("/");
89
89
  }
90
+ /**
91
+ * Make one path segment safe as a ZIP entry segment: no separators,
92
+ * no `.` / `..` traversal, no empty names.
93
+ */
94
+ function zipSegment(name) {
95
+ const cleaned = name.replace(/[\\/]/g, "_").trim();
96
+ if (!cleaned || cleaned === "." || cleaned === "..") return "_";
97
+ return cleaned;
98
+ }
99
+ /** Join path segments into a ZIP entry name, sanitising each segment. */
100
+ function zipEntryName(segments) {
101
+ return segments.map(zipSegment).join("/");
102
+ }
103
+ /**
104
+ * Return `name`, or `name (2)`, `name (3)`... when it is already in `used`.
105
+ * The suffix goes before the extension of the last segment. Adds the result to `used`.
106
+ */
107
+ function uniqueEntryName(name, used) {
108
+ let candidate = name;
109
+ if (used.has(candidate)) {
110
+ const slash = name.lastIndexOf("/");
111
+ const dir = name.slice(0, slash + 1);
112
+ const base = name.slice(slash + 1);
113
+ const dot = base.lastIndexOf(".");
114
+ const stem = dot > 0 ? base.slice(0, dot) : base;
115
+ const ext = dot > 0 ? base.slice(dot) : "";
116
+ let index = 2;
117
+ do {
118
+ candidate = `${dir}${stem} (${index})${ext}`;
119
+ index += 1;
120
+ } while (used.has(candidate));
121
+ }
122
+ used.add(candidate);
123
+ return candidate;
124
+ }
90
125
  function formatSize(size) {
91
126
  if (!size || !Number.isFinite(size)) return "-";
92
127
  const units = [
@@ -258,6 +293,9 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
258
293
  function errorText(error) {
259
294
  return error instanceof Error ? error.message : String(error);
260
295
  }
296
+ function isPasswordError(error) {
297
+ return /password|密码/i.test(errorText(error));
298
+ }
261
299
  const DEMO_TREE = {
262
300
  "/": [
263
301
  {
@@ -472,7 +510,7 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
472
510
  nodes.sort((a, b) => compareNodes(a, b, sortKey.value, sortAsc.value));
473
511
  for (const node of nodes) if (node.children.length) sortTree(node.children);
474
512
  }
475
- function toNodes(items, parent, depth, password = "") {
513
+ function toNodes(items, parent, depth, password = "", zipParent = []) {
476
514
  return items.map((item) => ({
477
515
  ...item,
478
516
  path: joinPath(parent, item.name),
@@ -481,7 +519,8 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
481
519
  loaded: false,
482
520
  loading: false,
483
521
  children: [],
484
- password
522
+ password,
523
+ zipPath: [...zipParent, item.name]
485
524
  }));
486
525
  }
487
526
  function setSort(key) {
@@ -502,11 +541,12 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
502
541
  if (node.loaded) return true;
503
542
  node.loading = true;
504
543
  try {
505
- node.children = toNodes(await listDir(node.path, node.password), node.path, node.depth + 1, node.password);
544
+ node.children = toNodes(await listDir(node.path, node.password || activePassword.value), node.path, node.depth + 1, node.password, node.zipPath);
506
545
  sortTree(node.children);
507
546
  node.loaded = true;
508
547
  return true;
509
548
  } catch (error) {
549
+ if (isPasswordError(error)) needsPassword.value = true;
510
550
  showToast(t("folderFailed", { message: errorText(error) }));
511
551
  return false;
512
552
  } finally {
@@ -531,16 +571,17 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
531
571
  } else try {
532
572
  const data = await request("/api/fs/get", {
533
573
  path,
534
- password
574
+ password: password || activePassword.value
535
575
  });
536
576
  item = {
537
577
  ...item,
538
578
  ...data
539
579
  };
540
580
  } catch {}
581
+ const name = source.title || item.name || sourceName(path);
541
582
  return {
542
583
  ...item,
543
- name: source.title || item.name || sourceName(path),
584
+ name,
544
585
  path,
545
586
  depth: 0,
546
587
  expanded: source.expanded ?? props.expanded,
@@ -548,7 +589,8 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
548
589
  loading: false,
549
590
  children: [],
550
591
  password,
551
- isRoot: true
592
+ isRoot: true,
593
+ zipPath: [item.is_dir ? name : item.name || sourceName(path)]
552
594
  };
553
595
  }
554
596
  async function loadRoot() {
@@ -571,7 +613,7 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
571
613
  downloadCache.clear();
572
614
  } catch (error) {
573
615
  const message = errorText(error);
574
- if (/password|密码/i.test(message)) needsPassword.value = true;
616
+ if (isPasswordError(error)) needsPassword.value = true;
575
617
  else errorMsg.value = message;
576
618
  } finally {
577
619
  loadingRoot.value = false;
@@ -613,10 +655,27 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
613
655
  const hit = files.filter((file) => selected.value.has(file.path)).length;
614
656
  return hit > 0 && hit < files.length;
615
657
  }
616
- async function toggleCheck(node) {
617
- if (node.is_dir && !node.loaded) await toggleExpand(node);
658
+ /** Load a directory and all of its sub-directories. */
659
+ async function loadTree(node) {
660
+ if (!node.is_dir) return true;
661
+ if (!await loadChildren(node)) return false;
662
+ for (const child of node.children) if (child.is_dir && !await loadTree(child)) return false;
663
+ return true;
664
+ }
665
+ async function toggleCheck(node, event) {
666
+ const input = event?.target;
667
+ if (node.is_dir && !isChecked(node)) {
668
+ if (await loadTree(node)) node.expanded = true;
669
+ else {
670
+ if (input) input.checked = isChecked(node);
671
+ return;
672
+ }
673
+ }
618
674
  const files = descendantFiles(node);
619
- if (!files.length) return;
675
+ if (!files.length) {
676
+ if (input) input.checked = false;
677
+ return;
678
+ }
620
679
  const next = new Set(selected.value);
621
680
  if (files.every((file) => next.has(file.path))) files.forEach((file) => next.delete(file.path));
622
681
  else files.forEach((file) => next.add(file.path));
@@ -696,6 +755,7 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
696
755
  batchProgress.value = 0;
697
756
  try {
698
757
  const entries = {};
758
+ const usedNames = /* @__PURE__ */ new Set();
699
759
  const queue = [...files];
700
760
  let done = 0;
701
761
  const worker = async () => {
@@ -704,7 +764,7 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
704
764
  if (!file) break;
705
765
  const response = await fetch(await resolveUrl(file));
706
766
  if (!response.ok) throw new Error(`${file.name}:HTTP ${response.status}`);
707
- entries[file.name] = new Uint8Array(await response.arrayBuffer());
767
+ entries[uniqueEntryName(zipEntryName(file.zipPath), usedNames)] = new Uint8Array(await response.arrayBuffer());
708
768
  done += 1;
709
769
  batchProgress.value = Math.round(done / files.length * 90);
710
770
  }
@@ -816,7 +876,7 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
816
876
  type: "checkbox",
817
877
  checked: isChecked(node),
818
878
  ".indeterminate": isIndeterminate(node),
819
- onChange: ($event) => toggleCheck(node)
879
+ onChange: ($event) => toggleCheck(node, $event)
820
880
  }, null, 40, _hoisted_23)]),
821
881
  createElementVNode("span", {
822
882
  class: "fl-cell fl-cell-name",
@@ -906,4 +966,4 @@ function enhanceAppWithFileList({ app }) {
906
966
  if (!app._context.components.FileList) app.component("FileList", _sfc_main);
907
967
  if (!app._context.components.VPFileList) app.component("VPFileList", _sfc_main);
908
968
  }
909
- export { _sfc_main as FileList, _sfc_main as VPFileList, compareNodes, encodePath, enhanceAppWithFileList, extOf, fileListLocales, formatDate, formatLabel, formatSize, joinPath, resolveLabels };
969
+ export { _sfc_main as FileList, _sfc_main as VPFileList, compareNodes, encodePath, enhanceAppWithFileList, extOf, fileListLocales, formatDate, formatLabel, formatSize, joinPath, resolveLabels, uniqueEntryName, zipEntryName, zipSegment };
@@ -171,6 +171,18 @@ export interface SortableNode {
171
171
  export type SortKey = 'name' | 'size' | 'modified';
172
172
  export declare function joinPath(parent: string, name: string): string;
173
173
  export declare function encodePath(path: string): string;
174
+ /**
175
+ * Make one path segment safe as a ZIP entry segment: no separators,
176
+ * no `.` / `..` traversal, no empty names.
177
+ */
178
+ export declare function zipSegment(name: string): string;
179
+ /** Join path segments into a ZIP entry name, sanitising each segment. */
180
+ export declare function zipEntryName(segments: string[]): string;
181
+ /**
182
+ * Return `name`, or `name (2)`, `name (3)`... when it is already in `used`.
183
+ * The suffix goes before the extension of the last segment. Adds the result to `used`.
184
+ */
185
+ export declare function uniqueEntryName(name: string, used: Set<string>): string;
174
186
  export declare function formatSize(size: number): string;
175
187
  export declare function formatDate(iso: string): string;
176
188
  export declare function extOf(name: string): string;
@@ -87,6 +87,41 @@ function joinPath(parent, name) {
87
87
  function encodePath(path) {
88
88
  return path.split("/").map(encodeURIComponent).join("/");
89
89
  }
90
+ /**
91
+ * Make one path segment safe as a ZIP entry segment: no separators,
92
+ * no `.` / `..` traversal, no empty names.
93
+ */
94
+ function zipSegment(name) {
95
+ const cleaned = name.replace(/[\\/]/g, "_").trim();
96
+ if (!cleaned || cleaned === "." || cleaned === "..") return "_";
97
+ return cleaned;
98
+ }
99
+ /** Join path segments into a ZIP entry name, sanitising each segment. */
100
+ function zipEntryName(segments) {
101
+ return segments.map(zipSegment).join("/");
102
+ }
103
+ /**
104
+ * Return `name`, or `name (2)`, `name (3)`... when it is already in `used`.
105
+ * The suffix goes before the extension of the last segment. Adds the result to `used`.
106
+ */
107
+ function uniqueEntryName(name, used) {
108
+ let candidate = name;
109
+ if (used.has(candidate)) {
110
+ const slash = name.lastIndexOf("/");
111
+ const dir = name.slice(0, slash + 1);
112
+ const base = name.slice(slash + 1);
113
+ const dot = base.lastIndexOf(".");
114
+ const stem = dot > 0 ? base.slice(0, dot) : base;
115
+ const ext = dot > 0 ? base.slice(dot) : "";
116
+ let index = 2;
117
+ do {
118
+ candidate = `${dir}${stem} (${index})${ext}`;
119
+ index += 1;
120
+ } while (used.has(candidate));
121
+ }
122
+ used.add(candidate);
123
+ return candidate;
124
+ }
90
125
  function formatSize(size) {
91
126
  if (!size || !Number.isFinite(size)) return "-";
92
127
  const units = [
@@ -181,6 +216,9 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
181
216
  function errorText(error) {
182
217
  return error instanceof Error ? error.message : String(error);
183
218
  }
219
+ function isPasswordError(error) {
220
+ return /password|密码/i.test(errorText(error));
221
+ }
184
222
  const DEMO_TREE = {
185
223
  "/": [
186
224
  {
@@ -364,7 +402,7 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
364
402
  nodes.sort((a, b) => compareNodes(a, b, sortKey.value, sortAsc.value));
365
403
  for (const node of nodes) if (node.children.length) sortTree(node.children);
366
404
  }
367
- function toNodes(items, parent, depth, password = "") {
405
+ function toNodes(items, parent, depth, password = "", zipParent = []) {
368
406
  return items.map((item) => ({
369
407
  ...item,
370
408
  path: joinPath(parent, item.name),
@@ -373,7 +411,8 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
373
411
  loaded: false,
374
412
  loading: false,
375
413
  children: [],
376
- password
414
+ password,
415
+ zipPath: [...zipParent, item.name]
377
416
  }));
378
417
  }
379
418
  function sourceName(path) {
@@ -384,11 +423,12 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
384
423
  if (node.loaded) return true;
385
424
  node.loading = true;
386
425
  try {
387
- node.children = toNodes(await listDir(node.path, node.password), node.path, node.depth + 1, node.password);
426
+ node.children = toNodes(await listDir(node.path, node.password || activePassword.value), node.path, node.depth + 1, node.password, node.zipPath);
388
427
  sortTree(node.children);
389
428
  node.loaded = true;
390
429
  return true;
391
430
  } catch (error) {
431
+ if (isPasswordError(error)) needsPassword.value = true;
392
432
  showToast(t("folderFailed", { message: errorText(error) }));
393
433
  return false;
394
434
  } finally {
@@ -413,16 +453,17 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
413
453
  } else try {
414
454
  const data = await request("/api/fs/get", {
415
455
  path,
416
- password
456
+ password: password || activePassword.value
417
457
  });
418
458
  item = {
419
459
  ...item,
420
460
  ...data
421
461
  };
422
462
  } catch {}
463
+ const name = source.title || item.name || sourceName(path);
423
464
  return {
424
465
  ...item,
425
- name: source.title || item.name || sourceName(path),
466
+ name,
426
467
  path,
427
468
  depth: 0,
428
469
  expanded: source.expanded ?? props.expanded,
@@ -430,7 +471,8 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
430
471
  loading: false,
431
472
  children: [],
432
473
  password,
433
- isRoot: true
474
+ isRoot: true,
475
+ zipPath: [item.is_dir ? name : item.name || sourceName(path)]
434
476
  };
435
477
  }
436
478
  async function loadRoot() {
@@ -453,7 +495,7 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
453
495
  downloadCache.clear();
454
496
  } catch (error) {
455
497
  const message = errorText(error);
456
- if (/password|密码/i.test(message)) needsPassword.value = true;
498
+ if (isPasswordError(error)) needsPassword.value = true;
457
499
  else errorMsg.value = message;
458
500
  } finally {
459
501
  loadingRoot.value = false;
@@ -588,4 +630,4 @@ function enhanceAppWithFileList({ app }) {
588
630
  if (!app._context.components.FileList) app.component("FileList", _sfc_main);
589
631
  if (!app._context.components.VPFileList) app.component("VPFileList", _sfc_main);
590
632
  }
591
- export { _sfc_main as FileList, _sfc_main as VPFileList, compareNodes, encodePath, enhanceAppWithFileList, extOf, fileListLocales, formatDate, formatLabel, formatSize, joinPath, resolveLabels };
633
+ export { _sfc_main as FileList, _sfc_main as VPFileList, compareNodes, encodePath, enhanceAppWithFileList, extOf, fileListLocales, formatDate, formatLabel, formatSize, joinPath, resolveLabels, uniqueEntryName, zipEntryName, zipSegment };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vitepress-plugin-filelist",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "packageManager": "pnpm@11.25.0",
5
5
  "type": "module",
6
6
  "description": "OpenList file browser for VitePress: tree view, batch ZIP download, i18n, password and token support.",
@@ -54,6 +54,8 @@
54
54
  },
55
55
  "devDependencies": {
56
56
  "@types/node": "^22.10.0",
57
+ "@vue/test-utils": "^2.5.1",
58
+ "happy-dom": "^21.0.4",
57
59
  "tsdown": "^0.23.7",
58
60
  "typescript": "^5.6.0",
59
61
  "unplugin-vue": "^8.0.1",