dsh-tiddlywiki 0.2.1 → 0.3.1

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/lib/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createRequire } from "node:module";
2
2
  import { existsSync, watch } from "node:fs";
3
- import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
4
- import { dirname, join, resolve } from "node:path";
3
+ import { access, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
4
+ import { basename, dirname, extname, join, resolve } from "node:path";
5
5
  import { execFile, spawn } from "node:child_process";
6
6
  import { promisify } from "node:util";
7
7
  import { createServer } from "node:net";
@@ -199,8 +199,16 @@ var GitFace = class {
199
199
  ...behind !== void 0 ? { behind } : {}
200
200
  };
201
201
  }
202
- /** `git pull --rebase --autostash`; on conflict: abort + report files. */
202
+ /** `git pull --rebase --autostash`; on conflict: abort + report files.
203
+ * On success, `changed: true` means HEAD actually moved (files came in /
204
+ * commits were replayed) — callers use it to decide whether a running TW
205
+ * child needs a restart to drop its stale in-memory snapshot. */
203
206
  async pull(dir) {
207
+ const before = await this.exec(["rev-parse", "HEAD"], {
208
+ cwd: dir,
209
+ timeout: QUICK_TIMEOUT_MS
210
+ });
211
+ const beforeHead = before.ok ? before.stdout.trim() : "";
204
212
  const r = await this.exec([
205
213
  "pull",
206
214
  "--rebase",
@@ -209,10 +217,19 @@ var GitFace = class {
209
217
  cwd: dir,
210
218
  timeout: HEAVY_TIMEOUT_MS
211
219
  });
212
- if (r.ok) return {
213
- ok: true,
214
- message: r.stdout.trim() || "pull ok"
215
- };
220
+ if (r.ok) {
221
+ const after = await this.exec(["rev-parse", "HEAD"], {
222
+ cwd: dir,
223
+ timeout: QUICK_TIMEOUT_MS
224
+ });
225
+ const afterHead = after.ok ? after.stdout.trim() : "";
226
+ const changed = beforeHead.length > 0 && beforeHead !== afterHead;
227
+ return {
228
+ ok: true,
229
+ message: r.stdout.trim() || "pull ok",
230
+ ...changed ? { changed: true } : {}
231
+ };
232
+ }
216
233
  const conflictFiles = await this.unmergedFiles(dir);
217
234
  await this.exec(["rebase", "--abort"], {
218
235
  cwd: dir,
@@ -621,6 +638,12 @@ const ROUTE_PREFIX = PATH_PREFIX;
621
638
  const MAX_BODY_BYTES = 2 * 1024 * 1024;
622
639
  /** Max passthrough body (tiddler content can be large). */
623
640
  const MAX_PROXY_BODY_BYTES = 16 * 1024 * 1024;
641
+ /** Max uploaded file body. */
642
+ const MAX_UPLOAD_BYTES = 64 * 1024 * 1024;
643
+ /** Tiddler type for quick-notes: Markdown, so the uploaded images/links and
644
+ * any Markdown in the note actually render in TW (a type-less tiddler is
645
+ * treated as plain wiki text and shows raw `![..]`/`[..]` instead). */
646
+ const NOTE_TYPE = "text/markdown";
624
647
  function readBody$1(req, limit = MAX_BODY_BYTES) {
625
648
  return new Promise((resolveP, rejectP) => {
626
649
  let size = 0;
@@ -638,6 +661,35 @@ function readBody$1(req, limit = MAX_BODY_BYTES) {
638
661
  req.on("error", rejectP);
639
662
  });
640
663
  }
664
+ /** Read a raw (binary-safe) request body up to `limit` bytes. */
665
+ function readBodyBuffer(req, limit = MAX_UPLOAD_BYTES) {
666
+ return new Promise((resolveP, rejectP) => {
667
+ let size = 0;
668
+ const chunks = [];
669
+ req.on("data", (chunk) => {
670
+ size += chunk.length;
671
+ if (size > limit) {
672
+ rejectP(/* @__PURE__ */ new Error("file too large"));
673
+ req.destroy();
674
+ return;
675
+ }
676
+ chunks.push(chunk);
677
+ });
678
+ req.on("end", () => resolveP(Buffer.concat(chunks)));
679
+ req.on("error", rejectP);
680
+ });
681
+ }
682
+ /**
683
+ * Sanitize an uploaded filename into a safe bare name (no path separators,
684
+ * no `..`, no control characters). Returns '' when nothing usable remains.
685
+ */
686
+ function sanitizeUploadName(input) {
687
+ if (typeof input !== "string") return "";
688
+ const name = basename(input.trim().replace(/[\\/]+/g, "/")).replace(/[\u0000-\u001f\u007f]/g, "").replace(/[<>:"|?*]/g, "_").replace(/^\.+/, "").trim();
689
+ if (name.length === 0 || name === "." || name === "..") return "";
690
+ if (name.length > 160) return name.slice(0, 160);
691
+ return name;
692
+ }
641
693
  function json$1(res, payload, status = 200) {
642
694
  const body = JSON.stringify(payload);
643
695
  res.writeHead(status, {
@@ -655,16 +707,20 @@ function timestampTitle(date = /* @__PURE__ */ new Date()) {
655
707
  }
656
708
  /**
657
709
  * Open a tiddler in TW's NATIVE editor: save the tiddler (when text is
658
- * non-empty), reuse or create a DRAFT tiddler carrying `draft.of`/`draft.title`
659
- * (TW's story view renders drafts with the EditTemplate — list.js:
660
- * `isDraft && editTemplate`), and return the draft title so the client can
661
- * navigate the panel iframe to `#<draftTitle>`.
710
+ * non-empty) as Markdown, reuse or create a DRAFT tiddler carrying
711
+ * `draft.of`/`draft.title` (TW's story view renders drafts with the
712
+ * EditTemplate — list.js: `isDraft && editTemplate`), and return the draft
713
+ * title so the client can navigate the panel iframe to `#<draftTitle>`.
714
+ * The draft carries the same `text/markdown` type as the note so saving it in
715
+ * TW keeps Markdown (a draft without a matching type would overwrite the
716
+ * note's type back to plain wiki text).
662
717
  */
663
718
  async function openInTwEditor(client, title, text, tags) {
664
719
  if (text.trim().length > 0) await client.put({
665
720
  title,
666
721
  text,
667
- tags
722
+ tags,
723
+ type: NOTE_TYPE
668
724
  });
669
725
  let draftText = text;
670
726
  if (draftText.trim().length === 0) draftText = (await client.get(title))?.text ?? "";
@@ -682,7 +738,7 @@ async function openInTwEditor(client, title, text, tags) {
682
738
  text: draftText,
683
739
  "draft.of": title,
684
740
  "draft.title": title,
685
- type: "text/vnd.tiddlywiki"
741
+ type: NOTE_TYPE
686
742
  });
687
743
  return {
688
744
  title,
@@ -740,7 +796,8 @@ function registerRoutes(ctx, deps) {
740
796
  await client.put({
741
797
  title,
742
798
  text,
743
- tags
799
+ tags,
800
+ type: NOTE_TYPE
744
801
  });
745
802
  deps.autoCommit();
746
803
  json$1(res, {
@@ -748,7 +805,8 @@ function registerRoutes(ctx, deps) {
748
805
  title,
749
806
  tag: tags.join(" "),
750
807
  tags,
751
- text
808
+ text,
809
+ type: NOTE_TYPE
752
810
  });
753
811
  } catch (err) {
754
812
  json$1(res, {
@@ -823,6 +881,120 @@ function registerRoutes(ctx, deps) {
823
881
  }, 500);
824
882
  }
825
883
  };
884
+ /** One-click git sync for the floating button / settings page: pull →
885
+ * commit → push, then return the fresh status. Mirrors the agent tool's
886
+ * `action=sync` (design doc §7 conflict policy — rebase conflict aborts).
887
+ * When the pull actually changed the working tree, the running TW child
888
+ * still holds the old in-memory snapshot — restart it (same port) so the
889
+ * UI reflects the pulled files instead of looking stale. */
890
+ const handleSync = async (_req, res) => {
891
+ const dir = deps.getWikiPath();
892
+ const status = async () => {
893
+ try {
894
+ return await deps.git.status(dir);
895
+ } catch {
896
+ return null;
897
+ }
898
+ };
899
+ try {
900
+ const pulled = await deps.git.pull(dir);
901
+ if (!pulled.ok) {
902
+ json$1(res, {
903
+ ok: false,
904
+ action: "sync",
905
+ message: pulled.message,
906
+ ...pulled.conflictFiles !== void 0 ? { conflictFiles: pulled.conflictFiles } : {},
907
+ status: await status()
908
+ }, 409);
909
+ return;
910
+ }
911
+ let restarted = false;
912
+ let restartError;
913
+ if (pulled.changed === true) try {
914
+ await deps.server.restart();
915
+ restarted = true;
916
+ } catch (err) {
917
+ restartError = err instanceof Error ? err.message : String(err);
918
+ }
919
+ const committed = await deps.git.commit(dir, `sync ${(/* @__PURE__ */ new Date()).toISOString()}`);
920
+ const pushed = await deps.git.push(dir);
921
+ const fresh = await status();
922
+ json$1(res, {
923
+ ok: pushed.ok,
924
+ action: "sync",
925
+ message: pushed.ok ? "同步完成" : pushed.message,
926
+ pull: "ok",
927
+ ...pulled.changed === true ? { changed: true } : {},
928
+ restarted,
929
+ ...restartError !== void 0 ? { restartError } : {},
930
+ commit: committed.message,
931
+ push: pushed.message,
932
+ status: fresh,
933
+ lastSync: (/* @__PURE__ */ new Date()).toISOString()
934
+ }, pushed.ok ? 200 : 502);
935
+ } catch (err) {
936
+ json$1(res, {
937
+ ok: false,
938
+ error: err instanceof Error ? err.message : String(err)
939
+ }, 500);
940
+ }
941
+ };
942
+ /**
943
+ * Save an uploaded file into the wiki's `files/` folder (git-tracked; TW's
944
+ * core server serves it at `/files/<name>`, get-file.js — no restart
945
+ * needed). Body is the raw file; the name arrives in `X-Filename`. A
946
+ * collision appends `-1`, `-2`, … so nothing is ever overwritten.
947
+ */
948
+ const handleUpload = async (req, res) => {
949
+ try {
950
+ const buf = await readBodyBuffer(req);
951
+ let name = sanitizeUploadName(new URL(req.url ?? "/", "http://127.0.0.1").searchParams.get("name") ?? "");
952
+ if (name.length === 0 && typeof req.headers["x-filename"] === "string") {
953
+ let decoded = "";
954
+ try {
955
+ decoded = decodeURIComponent(req.headers["x-filename"]);
956
+ } catch {
957
+ decoded = req.headers["x-filename"];
958
+ }
959
+ name = sanitizeUploadName(decoded);
960
+ }
961
+ if (name.length === 0) {
962
+ json$1(res, {
963
+ ok: false,
964
+ error: "missing or invalid filename"
965
+ }, 400);
966
+ return;
967
+ }
968
+ const filesDir = join(deps.getWikiPath(), "files");
969
+ await mkdir(filesDir, { recursive: true });
970
+ const ext = extname(name);
971
+ const stem = ext.length > 0 ? name.slice(0, -ext.length) : name;
972
+ let candidate = name;
973
+ for (let i = 1;; i++) {
974
+ try {
975
+ await access(join(filesDir, candidate));
976
+ } catch {
977
+ break;
978
+ }
979
+ candidate = `${stem}-${i}${ext}`;
980
+ }
981
+ await writeFile(join(filesDir, candidate), buf);
982
+ deps.autoCommit();
983
+ json$1(res, {
984
+ ok: true,
985
+ name: candidate,
986
+ path: `files/${candidate}`,
987
+ url: `/files/${encodeURIComponent(candidate)}`,
988
+ size: buf.length,
989
+ type: req.headers["content-type"] ?? "application/octet-stream"
990
+ });
991
+ } catch (err) {
992
+ json$1(res, {
993
+ ok: false,
994
+ error: err instanceof Error ? err.message : String(err)
995
+ }, err instanceof Error && /too large/.test(err.message) ? 413 : 500);
996
+ }
997
+ };
826
998
  /** Passthrough /dsh-tiddlywiki/api/<rest> → TW root /<rest>. */
827
999
  const handleApiProxy = async (req, res) => {
828
1000
  if (deps.getClient() === void 0) {
@@ -889,6 +1061,20 @@ function registerRoutes(ctx, deps) {
889
1061
  handleTags(req, res);
890
1062
  }
891
1063
  }),
1064
+ ctx.webServer.register({
1065
+ kind: "exact",
1066
+ path: `${ROUTE_PREFIX}/sync`,
1067
+ handler: (req, res) => {
1068
+ handleSync(req, res);
1069
+ }
1070
+ }),
1071
+ ctx.webServer.register({
1072
+ kind: "exact",
1073
+ path: `${ROUTE_PREFIX}/upload`,
1074
+ handler: (req, res) => {
1075
+ handleUpload(req, res);
1076
+ }
1077
+ }),
892
1078
  ctx.webServer.register({
893
1079
  kind: "exact",
894
1080
  path: `${ROUTE_PREFIX}/restart`,
@@ -1888,14 +2074,27 @@ function registerTiddlywikiTools(ctx, deps) {
1888
2074
  },
1889
2075
  execute: async (args) => {
1890
2076
  const dir = deps.wikiPath();
2077
+ /** Restart TW after a pull that changed the tree (stale snapshot drop). */
2078
+ const restartIfChanged = async (pulled) => {
2079
+ if (pulled.changed !== true || deps.restartWiki === void 0) return {};
2080
+ try {
2081
+ await deps.restartWiki();
2082
+ return { restarted: true };
2083
+ } catch (err) {
2084
+ return { restartError: err instanceof Error ? err.message : String(err) };
2085
+ }
2086
+ };
1891
2087
  switch (args.action) {
1892
2088
  case "pull": {
1893
2089
  const r = await deps.git.pull(dir);
2090
+ const restart = await restartIfChanged(r);
1894
2091
  return {
1895
2092
  action: args.action,
1896
2093
  ok: r.ok,
1897
2094
  message: r.message,
1898
- ...r.conflictFiles !== void 0 ? { conflictFiles: r.conflictFiles } : {}
2095
+ ...r.conflictFiles !== void 0 ? { conflictFiles: r.conflictFiles } : {},
2096
+ ...r.changed === true ? { changed: true } : {},
2097
+ ...restart
1899
2098
  };
1900
2099
  }
1901
2100
  case "push": {
@@ -1914,6 +2113,7 @@ function registerTiddlywikiTools(ctx, deps) {
1914
2113
  message: pulled.message,
1915
2114
  ...pulled.conflictFiles !== void 0 ? { conflictFiles: pulled.conflictFiles } : {}
1916
2115
  };
2116
+ const restart = await restartIfChanged(pulled);
1917
2117
  const committed = await deps.git.commit(dir, args.message ?? `sync ${(/* @__PURE__ */ new Date()).toISOString()}`);
1918
2118
  const pushed = await deps.git.push(dir);
1919
2119
  const status = await deps.git.status(dir);
@@ -1922,6 +2122,8 @@ function registerTiddlywikiTools(ctx, deps) {
1922
2122
  ok: pushed.ok,
1923
2123
  message: pushed.ok ? "同步完成" : pushed.message,
1924
2124
  pull: "ok",
2125
+ ...pulled.changed === true ? { changed: true } : {},
2126
+ ...restart,
1925
2127
  commit: committed.message,
1926
2128
  push: pushed.message,
1927
2129
  status
@@ -1942,6 +2144,8 @@ function renderSync(value) {
1942
2144
  }
1943
2145
  if (value.commit !== void 0) lines.push(`本地 commit: ${value.commit}`);
1944
2146
  if (value.push !== void 0) lines.push(`远端 push: ${value.push}`);
2147
+ if (value.changed === true) lines.push(value.restarted === true ? "本次 pull 拉取了新内容,TW 服务已自动重启(同端口),读取/搜索均为最新快照。" : "本次 pull 拉取了新内容,但 TW 服务未能自动重启(如需最新快照,请手动重启 TW)。");
2148
+ if (value.restartError !== void 0) lines.push(`TW 重启失败: ${value.restartError}`);
1945
2149
  if (value.status !== void 0) {
1946
2150
  const s = value.status;
1947
2151
  const bits = [`分支 ${s.branch}`];
@@ -1996,7 +2200,8 @@ const DEFAULTS = {
1996
2200
  note: { tag: "inbox" },
1997
2201
  ui: {
1998
2202
  showQuickNote: true,
1999
- showPanelStatus: true
2203
+ showPanelStatus: true,
2204
+ showSyncButton: true
2000
2205
  },
2001
2206
  auth: {
2002
2207
  username: "",
@@ -2051,7 +2256,9 @@ const PROMPT_TEXT = `## TiddlyWiki 持久知识库
2051
2256
  2. 收工 commit + push:\`tiddlywiki_git_sync action=sync\`(pull → commit → push)。
2052
2257
  3. 插件会自动防抖 commit(默认 60s),手动同步用上面的工具。
2053
2258
 
2054
- 把 wiki 当作长期记忆与知识沉淀的地方:会议纪要、决策记录、调研笔记、随手的想法都可存成独立 tiddler(tag 建议用 inbox/meeting/decision 等便于检索)。`;
2259
+ 把 wiki 当作长期记忆与知识沉淀的地方:会议纪要、决策记录、调研笔记、随手的想法都可存成独立 tiddler(tag 建议用 inbox/meeting/decision 等便于检索)。
2260
+
2261
+ 用本插件自动创建笔记时,除了业务性 tag 外,请把「当前工作区(项目)的名字」也作为标签之一加上去(例如 \`tiddlywiki_put\` 的 tags 里带上当前 workspace 名),这样笔记能按项目归集、检索。`;
2055
2262
  /**
2056
2263
  * Mount the host half.
2057
2264
  * @param ctx - the plugin context (tools + systemPrompt injected).
@@ -2093,7 +2300,8 @@ function apply(ctx, rawConfig = {}) {
2093
2300
  };
2094
2301
  const effectiveUi = () => ({
2095
2302
  showQuickNote: eff().ui?.showQuickNote !== false,
2096
- showPanelStatus: eff().ui?.showPanelStatus !== false
2303
+ showPanelStatus: eff().ui?.showPanelStatus !== false,
2304
+ showSyncButton: eff().ui?.showSyncButton !== false
2097
2305
  });
2098
2306
  const disposers = [];
2099
2307
  const disposeAll = () => {
@@ -2159,7 +2367,10 @@ function apply(ctx, rawConfig = {}) {
2159
2367
  git,
2160
2368
  wikiPath: () => wikiPath,
2161
2369
  noteTag: effectiveNoteTag,
2162
- autoCommit: () => committer?.touch()
2370
+ autoCommit: () => committer?.touch(),
2371
+ restartWiki: async () => {
2372
+ await server.restart();
2373
+ }
2163
2374
  };
2164
2375
  disposers.push(...registerTiddlywikiTools(ctx, toolsDeps));
2165
2376
  (async () => {
@@ -2221,6 +2432,6 @@ function apply(ctx, rawConfig = {}) {
2221
2432
  }, "dsh-tiddlywiki: host teardown");
2222
2433
  }
2223
2434
  //#endregion
2224
- export { AutoCommitter, ConfigStore, DOC_NOTE_TAG, DOC_NOTE_TEXT, DOC_NOTE_TITLE, GitFace, PATH_PREFIX, TiddlyWebClient, WikiServer, apply, bundledCatalog, deepMerge, defineTool, dshHomePath, ensureLanguage, inject, name, normalizeThemes, openInTwEditor, readWikiInfo, registerAdminRoutes, resolveTwRoot, seedDocNote, writeWikiInfo };
2435
+ export { AutoCommitter, ConfigStore, DOC_NOTE_TAG, DOC_NOTE_TEXT, DOC_NOTE_TITLE, GitFace, PATH_PREFIX, TiddlyWebClient, WikiServer, apply, bundledCatalog, deepMerge, defineTool, dshHomePath, ensureLanguage, inject, name, normalizeThemes, openInTwEditor, readWikiInfo, registerAdminRoutes, registerRoutes, resolveTwRoot, seedDocNote, writeWikiInfo };
2225
2436
 
2226
2437
  //# sourceMappingURL=index.js.map