dsh-obsidian-notes 0.0.0-stage → 0.5.0

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.
@@ -0,0 +1,146 @@
1
+ /**
2
+ * 会话日志的带外维护(host 侧)。
3
+ *
4
+ * DSH 的持久化层**不提供**删除或保留接口 ——
5
+ * `dsh-session-persistence` README 原文:「无删除或保留接口——剪枝已存储会话
6
+ * 属于带外后端维护」。所以插件要支持「彻底删除会话」,只能在文件系统层面
7
+ * 复刻官方 jsonl 后端的目录布局:
8
+ *
9
+ * $DSH_HOME/sessions/<projectKey(cwd)>/<encodeSegment(id)>/session.vN.jsonl.zstd
10
+ *
11
+ * 因此这里镜像了 `dsh-session-persistence-jsonl` 的两个纯函数:
12
+ * - `encodeSegment`:id → 单个安全路径段(`.`/`..` 特判,其余非
13
+ * `[A-Za-z0-9._-]` 字符转 `~XXXX`);
14
+ * - 项目目录名不进代码:删除按「扫描 sessions/* 下名为该段的目录」发现,
15
+ * 于是不必知道 `projectKey` 的有损规则,也能覆盖 `_no-cwd` 等分组。
16
+ *
17
+ * 同时清掉对应的投影缓存 `storages/session_projcache/sessions/<id>.json`,
18
+ * 否则重启后冷读缓存里仍会残留一条指向已删日志的记录。
19
+ */
20
+ import { lstat, readdir, rm } from "node:fs/promises";
21
+ import { join } from "node:path";
22
+ import { dshHome } from "./vault-store.js";
23
+
24
+ /** 会话 id 的合法形态:官方 id 只由 `[A-Za-z0-9._-]` 组成。 */
25
+ export const SESSION_ID_PATTERN = /^[A-Za-z0-9._-]{1,128}$/;
26
+
27
+ /** 一次请求允许删除的会话数上限(父会话 + 子代理闭包远小于此)。 */
28
+ export const SESSION_DELETE_LIMIT = 500;
29
+
30
+ /**
31
+ * 镜像 `dsh-session-persistence-jsonl` 的 `encodeSegment`。
32
+ * @param raw - 待编码的非空字符串。
33
+ * @returns 可作为单个路径段的转义结果。
34
+ */
35
+ export function encodeSegment(raw) {
36
+ if (typeof raw !== "string" || raw.length === 0) {
37
+ throw new Error("cannot encode an empty path segment");
38
+ }
39
+ if (raw === ".") return "~002E";
40
+ if (raw === "..") return "~002E~002E";
41
+ let out = "";
42
+ for (let i = 0; i < raw.length; i++) {
43
+ const code = raw.charCodeAt(i);
44
+ const ch = String.fromCharCode(code);
45
+ if (ch !== "~" && /^[A-Za-z0-9._-]$/.test(ch)) out += ch;
46
+ else out += `~${code.toString(16).toUpperCase().padStart(4, "0")}`;
47
+ }
48
+ return out;
49
+ }
50
+
51
+ /**
52
+ * 判断一个字符串是否是可安全删除的会话 id。
53
+ * @param id - 待校验的值。
54
+ * @returns 合法(且不是 `.` / `..`)时为 true。
55
+ */
56
+ export function isValidSessionId(id) {
57
+ return typeof id === "string"
58
+ && SESSION_ID_PATTERN.test(id)
59
+ && id !== "."
60
+ && id !== "..";
61
+ }
62
+
63
+ /** 会话日志根目录:$DSH_HOME/sessions。 */
64
+ export function sessionsRoot() {
65
+ return join(dshHome(), "sessions");
66
+ }
67
+
68
+ /** 某个会话的投影缓存文件路径。 */
69
+ export function projectionCachePath(id) {
70
+ return join(dshHome(), "storages", "session_projcache", "sessions", `${encodeSegment(id)}.json`);
71
+ }
72
+
73
+ /**
74
+ * 定位一个会话 id 在磁盘上的全部会话目录。
75
+ * 官方目录名就是 `encodeSegment(id)`,所以按该段名扫描每个项目分组即可,
76
+ * 无需知道会话的 cwd,也无需知道 `projectKey` 的转义规则。
77
+ * @param id - 已校验的会话 id。
78
+ * @returns 扫描到的会话目录(通常 0 或 1 个)与本次使用的根目录。
79
+ */
80
+ export async function findSessionDirs(id) {
81
+ const root = sessionsRoot();
82
+ const segment = encodeSegment(id);
83
+ let groups;
84
+ try {
85
+ groups = await readdir(root, { withFileTypes: true });
86
+ } catch {
87
+ return { root, dirs: [] };
88
+ }
89
+ const dirs = [];
90
+ for (const group of groups) {
91
+ if (!group.isDirectory()) continue;
92
+ const candidate = join(root, group.name, segment);
93
+ try {
94
+ const st = await lstat(candidate);
95
+ // 只删真实目录:符号链接一律跳过,避免借链接删到根目录之外。
96
+ if (st.isDirectory() && !st.isSymbolicLink()) dirs.push(candidate);
97
+ } catch {
98
+ // 该分组下没有这个会话,正常情况。
99
+ }
100
+ }
101
+ return { root, dirs };
102
+ }
103
+
104
+ /**
105
+ * 删除后校验:该会话在磁盘上是否真的什么都不剩。
106
+ *
107
+ * 删除接口只把「确实不存在了」的 id 报为成功,所以必须回读确认 —— 仅凭
108
+ * `rm` 没有抛错不足以证明删除彻底(权限、占用、只读挂载都可能让它静默留下)。
109
+ * @param id - 已校验的会话 id。
110
+ * @returns `present` 为是否存在残留,`reason` 指出残留种类。
111
+ */
112
+ export async function sessionArtifactsPresent(id) {
113
+ const { dirs } = await findSessionDirs(id);
114
+ if (dirs.length > 0) return { present: true, reason: "session directory" };
115
+ try {
116
+ await lstat(projectionCachePath(id));
117
+ return { present: true, reason: "projection cache" };
118
+ } catch {
119
+ return { present: false, reason: null };
120
+ }
121
+ }
122
+
123
+ /**
124
+ * 彻底删除一个会话的磁盘痕迹:会话目录(含其中的 lock 与全部日志代)
125
+ * 加上它的投影缓存。调用方须再用 {@link sessionArtifactsPresent} 回读确认。
126
+ * @param id - 已校验的会话 id。
127
+ * @returns 删除结果(`{ root, dirs, cacheRemoved }`);找不到不算错误。
128
+ */
129
+ export async function purgeSession(id) {
130
+ const { root, dirs } = await findSessionDirs(id);
131
+ const removed = [];
132
+ for (const dir of dirs) {
133
+ await rm(dir, { recursive: true, force: true });
134
+ removed.push(dir);
135
+ }
136
+ const cache = projectionCachePath(id);
137
+ let cacheRemoved = false;
138
+ try {
139
+ await lstat(cache);
140
+ await rm(cache, { force: true });
141
+ cacheRemoved = true;
142
+ } catch {
143
+ // 缓存缺失属正常(该会话可能从未写入投影缓存)。
144
+ }
145
+ return { root, dirs: removed, cacheRemoved };
146
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Vault 根目录与附件行为的持久化管理(host 侧):
3
+ * 设置文件位于 $DSH_HOME/dsh-obsidian-vault.json(DSH_HOME 缺省 ~/.dsh),
4
+ * 默认 Vault 根 = $DSH_WORKSPACE/obsidian_vault(DSH_WORKSPACE 缺省 process.cwd())。
5
+ *
6
+ * 附件设置对齐 Obsidian「Files & Links」的两组**独立**选项:
7
+ * · 位置(attachmentMode):root(Vault 根)/ fixed(固定目录)/ note(与笔记同目录)
8
+ * / sub(笔记目录下的子目录)
9
+ * · 链接格式:固定用 Obsidian 默认的「shortest path when possible」,由 index.js
10
+ * 的 embedTargetFor 决定,**与位置设置解耦**(位置只管存哪里,链接只管怎么写)。
11
+ */
12
+ import { readFile, writeFile, rename, mkdir, realpath, stat } from "node:fs/promises";
13
+ import { homedir } from "node:os";
14
+ import { dirname, isAbsolute, join, resolve } from "node:path";
15
+
16
+ const SETTINGS_FILENAME = "dsh-obsidian-vault.json";
17
+
18
+ /** 附件落盘位置模式(对应 Obsidian 的「Default location for new attachments」)。 */
19
+ export const ATTACHMENT_MODES = new Set(["root", "fixed", "note", "sub"]);
20
+
21
+ /** DSH 主目录:$DSH_HOME(缺省 ~/.dsh)。会话日志、投影缓存等都在其下。 */
22
+ export function dshHome() {
23
+ return process.env.DSH_HOME && process.env.DSH_HOME.trim() !== ""
24
+ ? process.env.DSH_HOME
25
+ : join(homedir(), ".dsh");
26
+ }
27
+
28
+ export function settingsPath() {
29
+ return join(dshHome(), SETTINGS_FILENAME);
30
+ }
31
+
32
+ /** 默认 Vault 根:$DSH_WORKSPACE/obsidian_vault,缺省 process.cwd()/obsidian_vault。 */
33
+ export function defaultVaultRoot() {
34
+ const ws = process.env.DSH_WORKSPACE;
35
+ if (ws && ws.trim() !== "") return join(ws, "obsidian_vault");
36
+ return join(process.cwd(), "obsidian_vault");
37
+ }
38
+
39
+ /**
40
+ * 读设置。缺省值刻意保持**向后兼容**:老设置文件只有 `attachmentDir`,此时按
41
+ * 「空 → Vault 根 / 非空 → 固定目录」还原出等价行为,既有用户语义不变。
42
+ */
43
+ export async function readSettings() {
44
+ let data = {};
45
+ try {
46
+ const parsed = JSON.parse(await readFile(settingsPath(), "utf8"));
47
+ if (parsed && typeof parsed === "object") data = parsed;
48
+ } catch {
49
+ // 设置文件不存在或损坏:回到默认值。
50
+ }
51
+ const attachmentDir = typeof data.attachmentDir === "string" ? data.attachmentDir : "attachments";
52
+ const vaultRoot = typeof data.vaultRoot === "string" && data.vaultRoot.trim() !== "" ? data.vaultRoot : null;
53
+ return {
54
+ vaultRoot,
55
+ attachmentDir,
56
+ attachmentMode: typeof data.attachmentMode === "string" && ATTACHMENT_MODES.has(data.attachmentMode)
57
+ ? data.attachmentMode
58
+ : (attachmentDir === "" ? "root" : "fixed"),
59
+ attachmentSubfolder: typeof data.attachmentSubfolder === "string" && data.attachmentSubfolder.trim() !== ""
60
+ ? data.attachmentSubfolder.trim()
61
+ : "attachments",
62
+ // 目录树里隐藏附件目录(默认开;Obsidian 不隐藏,这里保留为可关闭的降噪选项)
63
+ hideAttachmentDir: data.hideAttachmentDir !== false,
64
+ // 保存笔记时自动删除孤儿附件(默认**关**:Obsidian 不自动删,误删不可恢复)
65
+ deleteOrphanAttachments: data.deleteOrphanAttachments === true,
66
+ };
67
+ }
68
+
69
+ export async function writeSettings(settings) {
70
+ const path = settingsPath();
71
+ await mkdir(dirname(path), { recursive: true });
72
+ const tmp = `${path}.${process.pid}.tmp`;
73
+ await writeFile(tmp, JSON.stringify(settings, null, 2) + "\n", "utf8");
74
+ await rename(tmp, path);
75
+ }
76
+
77
+ /** 合并式写入:只覆盖 patch 里出现的字段,其余保持当前值。 */
78
+ export async function updateSettings(patch = {}) {
79
+ const cur = await readSettings();
80
+ const vaultRoot = patch.vaultRoot !== undefined ? patch.vaultRoot : cur.vaultRoot;
81
+ const next = {
82
+ ...(vaultRoot ? { vaultRoot } : {}),
83
+ attachmentDir: patch.attachmentDir !== undefined ? patch.attachmentDir : cur.attachmentDir,
84
+ attachmentMode: patch.attachmentMode !== undefined ? patch.attachmentMode : cur.attachmentMode,
85
+ attachmentSubfolder: patch.attachmentSubfolder !== undefined ? patch.attachmentSubfolder : cur.attachmentSubfolder,
86
+ hideAttachmentDir: patch.hideAttachmentDir !== undefined ? patch.hideAttachmentDir : cur.hideAttachmentDir,
87
+ deleteOrphanAttachments: patch.deleteOrphanAttachments !== undefined
88
+ ? patch.deleteOrphanAttachments
89
+ : cur.deleteOrphanAttachments,
90
+ };
91
+ await writeSettings(next);
92
+ return { ...next, vaultRoot: vaultRoot ?? null };
93
+ }
94
+
95
+ /**
96
+ * 设置固定附件目录(POSIX "/" 分隔;"" 表示 Vault 根)。
97
+ * 同时把位置模式切到 fixed / root —— 手填目录本身就表达了「用固定目录」。
98
+ */
99
+ export async function setAttachmentDir(dir) {
100
+ const next = await updateSettings({ attachmentDir: dir, attachmentMode: dir === "" ? "root" : "fixed" });
101
+ return next.attachmentDir;
102
+ }
103
+
104
+ /**
105
+ * 解析当前生效的 Vault 根:优先用户配置,否则默认值。
106
+ * 返回 realpath(若目录存在);目录不存在时返回 resolve 后的默认路径。
107
+ */
108
+ export async function resolveVaultRoot() {
109
+ const settings = await readSettings();
110
+ const candidates = settings.vaultRoot ? [settings.vaultRoot] : [defaultVaultRoot()];
111
+ for (const candidate of candidates) {
112
+ if (!isAbsolute(candidate)) continue;
113
+ try {
114
+ const real = await realpath(candidate);
115
+ const st = await stat(real);
116
+ if (st.isDirectory()) return real;
117
+ } catch {
118
+ // 不存在或不可访问,继续尝试下一个候选。
119
+ }
120
+ }
121
+ return resolve(defaultVaultRoot());
122
+ }
123
+
124
+ /** 校验并保存用户选择的 Vault 根,返回 realpath。 */
125
+ export async function setVaultRoot(input) {
126
+ if (typeof input !== "string" || input.trim() === "" || !isAbsolute(input)) {
127
+ throw Object.assign(new Error("vault root must be an absolute path"), { status: 400 });
128
+ }
129
+ let real;
130
+ try {
131
+ real = await realpath(input);
132
+ } catch {
133
+ throw Object.assign(new Error(`directory does not exist: ${input}`), { status: 400 });
134
+ }
135
+ const st = await stat(real);
136
+ if (!st.isDirectory()) {
137
+ throw Object.assign(new Error(`not a directory: ${input}`), { status: 400 });
138
+ }
139
+ const next = await updateSettings({ vaultRoot: real });
140
+ return next.vaultRoot ?? real;
141
+ }