@techie_doubts/tui.notes.2026 1.0.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/LICENSE +14 -0
- package/README.md +69 -0
- package/bin/tui-notes-2026.js +148 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/dist/assets/_basePickBy-5vIZeqrx.js +1 -0
- package/dist/assets/_baseUniq-BKVPFJfn.js +1 -0
- package/dist/assets/arc-C9kP97l6.js +1 -0
- package/dist/assets/architectureDiagram-VXUJARFQ-Byi0I8ZG.js +36 -0
- package/dist/assets/blockDiagram-VD42YOAC-BJdE0zMh.js +122 -0
- package/dist/assets/c4Diagram-YG6GDRKO-S6X4gJvg.js +10 -0
- package/dist/assets/channel-BL5Gk2pQ.js +1 -0
- package/dist/assets/chunk-4BX2VUAB-CHk_Kmuf.js +1 -0
- package/dist/assets/chunk-55IACEB6-CSiI5l4y.js +1 -0
- package/dist/assets/chunk-B4BG7PRW-Pfyytvw2.js +165 -0
- package/dist/assets/chunk-DI55MBZ5-CzuT73QX.js +220 -0
- package/dist/assets/chunk-FMBD7UC4-CbFQZB09.js +15 -0
- package/dist/assets/chunk-QN33PNHL-BGfqR6oI.js +1 -0
- package/dist/assets/chunk-QZHKN3VN-BiwVWS1N.js +1 -0
- package/dist/assets/chunk-TZMSLE5B-DGFvEdJ1.js +1 -0
- package/dist/assets/classDiagram-2ON5EDUG-zBqgTI2V.js +1 -0
- package/dist/assets/classDiagram-v2-WZHVMYZB-zBqgTI2V.js +1 -0
- package/dist/assets/clone-BXJ7lx2k.js +1 -0
- package/dist/assets/clone-Ck-f8mTd.js +1 -0
- package/dist/assets/cose-bilkent-S5V4N54A-BW6K25gi.js +1 -0
- package/dist/assets/cytoscape.esm-5J0xJHOV.js +321 -0
- package/dist/assets/dagre-6UL2VRFP-rAcrKl8P.js +4 -0
- package/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
- package/dist/assets/diagram-PSM6KHXK-CFW4LgFm.js +24 -0
- package/dist/assets/diagram-QEK2KX5R-iIAjQaRR.js +43 -0
- package/dist/assets/diagram-S2PKOQOG-B4sjOsw5.js +24 -0
- package/dist/assets/erDiagram-Q2GNP2WA-B0ZlvT_j.js +60 -0
- package/dist/assets/flowDiagram-NV44I4VS-CZpxVEIW.js +162 -0
- package/dist/assets/ganttDiagram-JELNMOA3-C5bV6Kyn.js +267 -0
- package/dist/assets/gitGraphDiagram-NY62KEGX-CKafq6Db.js +65 -0
- package/dist/assets/graph-CmWchtBt.js +1 -0
- package/dist/assets/index-Dru5s6C3.js +1622 -0
- package/dist/assets/index-Ds6rxr7w.css +1 -0
- package/dist/assets/infoDiagram-WHAUD3N6-CtuvmlxO.js +2 -0
- package/dist/assets/init-Gi6I4Gst.js +1 -0
- package/dist/assets/journeyDiagram-XKPGCS4Q-B2vQSg3r.js +139 -0
- package/dist/assets/kanban-definition-3W4ZIXB7-DGQcUq_x.js +89 -0
- package/dist/assets/katex-DhXJpUyf.js +261 -0
- package/dist/assets/layout-kmduQhmX.js +1 -0
- package/dist/assets/linear-pI_tPTfw.js +1 -0
- package/dist/assets/mindmap-definition-VGOIOE7T-BvU7po7V.js +68 -0
- package/dist/assets/ordinal-Cboi1Yqb.js +1 -0
- package/dist/assets/pieDiagram-ADFJNKIX-Cg7CEjT2.js +30 -0
- package/dist/assets/quadrantDiagram-AYHSOK5B-DkHvKj4E.js +7 -0
- package/dist/assets/requirementDiagram-UZGBJVZJ-m_XuIi3W.js +64 -0
- package/dist/assets/sankeyDiagram-TZEHDZUN-C8kEeWyG.js +10 -0
- package/dist/assets/sequenceDiagram-WL72ISMW-h_Zue6Cz.js +145 -0
- package/dist/assets/stateDiagram-FKZM4ZOC-C1RkikhL.js +1 -0
- package/dist/assets/stateDiagram-v2-4FDKWEC3-K7OViiQB.js +1 -0
- package/dist/assets/timeline-definition-IT6M3QCI-BC6jnS9b.js +61 -0
- package/dist/assets/treemap-KMMF4GRG-B48L_Dxb.js +235 -0
- package/dist/assets/xychartDiagram-PRI3JC2R-CnqXWGjs.js +7 -0
- package/dist/favicon-32x32.png +0 -0
- package/dist/favicon.ico +0 -0
- package/dist/favicon.png +0 -0
- package/dist/favicon.svg +19 -0
- package/dist/index.html +19 -0
- package/dist/safari-pinned-tab.svg +4 -0
- package/package.json +80 -0
- package/patches/@techie_doubts+editor-plugin-chart+3.0.1.patch +42 -0
- package/patches/@techie_doubts+tui.editor.2026+3.2.2.patch +36 -0
- package/server/index.js +104 -0
- package/server/store.js +996 -0
- package/tui-notes.config.example.json +3 -0
package/server/store.js
ADDED
|
@@ -0,0 +1,996 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
|
|
6
|
+
|
|
7
|
+
const APP_HOME_DIR = path.join(process.env.HOME || process.cwd(), ".tui.notes.2026");
|
|
8
|
+
const ROOT_CONFIG_FILE = path.join(process.cwd(), "tui-notes.config.json");
|
|
9
|
+
const GLOBAL_CONFIG_FILE = path.join(APP_HOME_DIR, "config.json");
|
|
10
|
+
const ROOT_DIR_ENV_NAME = "TUI_NOTES_ROOT_DIR";
|
|
11
|
+
const NOTES_DIR_ENV_NAME = "TUI_NOTES_NOTES_DIR";
|
|
12
|
+
const DEFAULT_NOTES_DIR = path.join(APP_HOME_DIR, "notes");
|
|
13
|
+
|
|
14
|
+
function expandHomePrefix(inputPath) {
|
|
15
|
+
if (typeof inputPath !== "string") {
|
|
16
|
+
return "";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const value = inputPath.trim();
|
|
20
|
+
if (!value) {
|
|
21
|
+
return "";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (value === "~") {
|
|
25
|
+
return process.env.HOME || value;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (value.startsWith("~/")) {
|
|
29
|
+
return path.join(process.env.HOME || "~", value.slice(2));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function safeReadJson(filePath) {
|
|
36
|
+
try {
|
|
37
|
+
if (!fs.existsSync(filePath)) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
const raw = fs.readFileSync(filePath, "utf8");
|
|
41
|
+
if (!raw.trim()) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
return JSON.parse(raw);
|
|
45
|
+
} catch (_error) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function createStoragePathsFromRootDir(rootDir) {
|
|
51
|
+
const appRootDir = path.resolve(rootDir);
|
|
52
|
+
return {
|
|
53
|
+
appRootDir,
|
|
54
|
+
notesDir: path.join(appRootDir, "notes"),
|
|
55
|
+
trashDir: path.join(appRootDir, "trash"),
|
|
56
|
+
stateFile: path.join(appRootDir, "state.json"),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function createStoragePathsFromNotesDir(notesDir) {
|
|
61
|
+
const resolvedNotesDir = path.resolve(notesDir);
|
|
62
|
+
const appRootDir = path.dirname(resolvedNotesDir);
|
|
63
|
+
return {
|
|
64
|
+
appRootDir,
|
|
65
|
+
notesDir: resolvedNotesDir,
|
|
66
|
+
trashDir: path.join(appRootDir, "trash"),
|
|
67
|
+
stateFile: path.join(appRootDir, "state.json"),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function resolveStoragePaths() {
|
|
72
|
+
const envNotesDir = expandHomePrefix(process.env[NOTES_DIR_ENV_NAME]);
|
|
73
|
+
if (envNotesDir) {
|
|
74
|
+
return createStoragePathsFromNotesDir(envNotesDir);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const envRootDir = expandHomePrefix(process.env[ROOT_DIR_ENV_NAME]);
|
|
78
|
+
if (envRootDir) {
|
|
79
|
+
return createStoragePathsFromRootDir(envRootDir);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const localConfig = safeReadJson(ROOT_CONFIG_FILE);
|
|
83
|
+
const globalConfig = safeReadJson(GLOBAL_CONFIG_FILE);
|
|
84
|
+
const config = {
|
|
85
|
+
...(globalConfig && typeof globalConfig === "object" ? globalConfig : {}),
|
|
86
|
+
...(localConfig && typeof localConfig === "object" ? localConfig : {}),
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const configNotesDir = expandHomePrefix(config?.notesDir);
|
|
90
|
+
if (configNotesDir) {
|
|
91
|
+
return createStoragePathsFromNotesDir(configNotesDir);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const configRootDir = expandHomePrefix(config?.notesRootDir);
|
|
95
|
+
if (configRootDir) {
|
|
96
|
+
return createStoragePathsFromRootDir(configRootDir);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return createStoragePathsFromNotesDir(DEFAULT_NOTES_DIR);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const STORAGE_PATHS = resolveStoragePaths();
|
|
103
|
+
const DATA_DIR = STORAGE_PATHS.appRootDir;
|
|
104
|
+
const NOTES_DIR = STORAGE_PATHS.notesDir;
|
|
105
|
+
const TRASH_DIR = STORAGE_PATHS.trashDir;
|
|
106
|
+
const STATE_FILE = STORAGE_PATHS.stateFile;
|
|
107
|
+
const INITIAL_STATE_REVISION = 1;
|
|
108
|
+
|
|
109
|
+
const starterContent = `# Welcome to TUI Notes 2026
|
|
110
|
+
|
|
111
|
+
This storage is persisted on disk.
|
|
112
|
+
|
|
113
|
+
- Notes are saved as .md files.
|
|
114
|
+
- Deleted notes move to Trash for 30 days.
|
|
115
|
+
`;
|
|
116
|
+
|
|
117
|
+
function createId() {
|
|
118
|
+
if (typeof crypto.randomUUID === "function") {
|
|
119
|
+
return crypto.randomUUID();
|
|
120
|
+
}
|
|
121
|
+
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function defaultNoteContent(title) {
|
|
125
|
+
const normalized = String(title || "Untitled").trim() || "Untitled";
|
|
126
|
+
return `# ${normalized}\n`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function ensureDataLayout() {
|
|
130
|
+
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
131
|
+
fs.mkdirSync(NOTES_DIR, { recursive: true });
|
|
132
|
+
fs.mkdirSync(TRASH_DIR, { recursive: true });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function writeJson(filePath, value) {
|
|
136
|
+
fs.writeFileSync(filePath, JSON.stringify(value, null, 2), "utf8");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function serializeStateSnapshot(state) {
|
|
140
|
+
return JSON.stringify({
|
|
141
|
+
folders: Array.isArray(state?.folders) ? state.folders : [],
|
|
142
|
+
notes: Array.isArray(state?.notes) ? state.notes : [],
|
|
143
|
+
ui: state?.ui && typeof state.ui === "object" ? state.ui : {},
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function ensureParentDir(filePath) {
|
|
148
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function toStringId(value) {
|
|
152
|
+
if (value == null) {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
const id = String(value).trim();
|
|
156
|
+
return id || null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function sanitizePathSegment(segment) {
|
|
160
|
+
const trimmed = String(segment || "").trim();
|
|
161
|
+
if (!trimmed || trimmed === "." || trimmed === "..") {
|
|
162
|
+
return "";
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const cleaned = trimmed.replace(/[<>:"|?*\u0000-\u001f]/g, "");
|
|
166
|
+
return cleaned || "";
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function sanitizeMdRelativePath(fileName, noteId) {
|
|
170
|
+
const fallback = `${noteId || "note"}.md`;
|
|
171
|
+
if (typeof fileName !== "string") {
|
|
172
|
+
return fallback;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const rawValue = fileName.trim().replaceAll("\\", "/");
|
|
176
|
+
if (!rawValue) {
|
|
177
|
+
return fallback;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const normalized = path.posix
|
|
181
|
+
.normalize(rawValue)
|
|
182
|
+
.replace(/^\/+/, "")
|
|
183
|
+
.replace(/^(\.\/)+/, "");
|
|
184
|
+
|
|
185
|
+
if (
|
|
186
|
+
!normalized ||
|
|
187
|
+
normalized === "." ||
|
|
188
|
+
normalized === ".." ||
|
|
189
|
+
normalized.startsWith("../")
|
|
190
|
+
) {
|
|
191
|
+
return fallback;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const parts = normalized.split("/").filter(Boolean);
|
|
195
|
+
const safeParts = parts.map(sanitizePathSegment).filter(Boolean);
|
|
196
|
+
if (!safeParts.length) {
|
|
197
|
+
return fallback;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const lastIndex = safeParts.length - 1;
|
|
201
|
+
if (!safeParts[lastIndex].toLowerCase().endsWith(".md")) {
|
|
202
|
+
safeParts[lastIndex] = `${safeParts[lastIndex]}.md`;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return safeParts.join("/");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function normalizeFileTitleBase(title) {
|
|
209
|
+
const raw = String(title || "")
|
|
210
|
+
.replaceAll("\\", " ")
|
|
211
|
+
.replaceAll("/", " ")
|
|
212
|
+
.replace(/\s+/g, " ")
|
|
213
|
+
.trim();
|
|
214
|
+
const withoutExt = raw.replace(/\.md$/i, "").trim();
|
|
215
|
+
const safe = sanitizePathSegment(withoutExt);
|
|
216
|
+
return safe || "Untitled";
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function createRandomFileSuffix(length = 6) {
|
|
220
|
+
const bytesLength = Number.isFinite(length) && length > 0 ? Math.ceil(length / 2) : 3;
|
|
221
|
+
return crypto.randomBytes(bytesLength).toString("hex").slice(0, Math.max(length, 1));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function resolveFolderPathSegments(state) {
|
|
225
|
+
const folderById = new Map(state.folders.map((folder) => [folder.id, folder]));
|
|
226
|
+
const cache = new Map();
|
|
227
|
+
|
|
228
|
+
function resolve(folderId) {
|
|
229
|
+
if (!folderId || !folderById.has(folderId)) {
|
|
230
|
+
return [];
|
|
231
|
+
}
|
|
232
|
+
if (cache.has(folderId)) {
|
|
233
|
+
return cache.get(folderId);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const parts = [];
|
|
237
|
+
const visited = new Set();
|
|
238
|
+
let current = folderById.get(folderId);
|
|
239
|
+
|
|
240
|
+
while (current) {
|
|
241
|
+
if (visited.has(current.id)) {
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
244
|
+
visited.add(current.id);
|
|
245
|
+
|
|
246
|
+
const segment = sanitizePathSegment(current.name);
|
|
247
|
+
if (segment) {
|
|
248
|
+
parts.push(segment);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (!current.parentId || !folderById.has(current.parentId)) {
|
|
252
|
+
break;
|
|
253
|
+
}
|
|
254
|
+
current = folderById.get(current.parentId);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const resolved = parts.reverse();
|
|
258
|
+
cache.set(folderId, resolved);
|
|
259
|
+
return resolved;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return resolve;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function joinRelativePath(dirPath, fileName) {
|
|
266
|
+
if (!dirPath) {
|
|
267
|
+
return fileName;
|
|
268
|
+
}
|
|
269
|
+
return `${dirPath}/${fileName}`;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function assignNoteFileNamesFromTitles(state) {
|
|
273
|
+
const resolveFolderSegments = resolveFolderPathSegments(state);
|
|
274
|
+
const usedPaths = new Set();
|
|
275
|
+
|
|
276
|
+
const notesOrdered = [...state.notes].sort((left, right) => {
|
|
277
|
+
const byCreatedAt = (left.createdAt || 0) - (right.createdAt || 0);
|
|
278
|
+
if (byCreatedAt !== 0) {
|
|
279
|
+
return byCreatedAt;
|
|
280
|
+
}
|
|
281
|
+
return String(left.id).localeCompare(String(right.id), undefined, { sensitivity: "base" });
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
for (const note of notesOrdered) {
|
|
285
|
+
const folderSegments = resolveFolderSegments(note.folderId);
|
|
286
|
+
const folderPath = folderSegments.join("/");
|
|
287
|
+
const baseName = normalizeFileTitleBase(note.title);
|
|
288
|
+
const legacyRelativePath = sanitizeMdRelativePath(note.fileName, note.id);
|
|
289
|
+
const legacyDirPath = path.posix.dirname(legacyRelativePath) === "." ? "" : path.posix.dirname(legacyRelativePath);
|
|
290
|
+
const legacyBaseName = path.posix.basename(legacyRelativePath, ".md");
|
|
291
|
+
const storageKeyPrefix = note.deletedAt ? "trash" : "notes";
|
|
292
|
+
|
|
293
|
+
const defaultCandidate = joinRelativePath(folderPath, `${baseName}.md`);
|
|
294
|
+
const defaultCandidateKey = `${storageKeyPrefix}:${defaultCandidate.toLowerCase()}`;
|
|
295
|
+
|
|
296
|
+
if (!usedPaths.has(defaultCandidateKey)) {
|
|
297
|
+
note.fileName = defaultCandidate;
|
|
298
|
+
usedPaths.add(defaultCandidateKey);
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const canReuseLegacyPath =
|
|
303
|
+
legacyDirPath === folderPath &&
|
|
304
|
+
legacyBaseName.toLowerCase().startsWith(baseName.toLowerCase());
|
|
305
|
+
const legacyCandidateKey = `${storageKeyPrefix}:${legacyRelativePath.toLowerCase()}`;
|
|
306
|
+
if (canReuseLegacyPath && !usedPaths.has(legacyCandidateKey)) {
|
|
307
|
+
note.fileName = legacyRelativePath;
|
|
308
|
+
usedPaths.add(legacyCandidateKey);
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
let attempt = 0;
|
|
313
|
+
while (attempt < 1000) {
|
|
314
|
+
const candidate = joinRelativePath(folderPath, `${baseName}-${createRandomFileSuffix()}.md`);
|
|
315
|
+
const candidateKey = `${storageKeyPrefix}:${candidate.toLowerCase()}`;
|
|
316
|
+
if (!usedPaths.has(candidateKey)) {
|
|
317
|
+
note.fileName = candidate;
|
|
318
|
+
usedPaths.add(candidateKey);
|
|
319
|
+
break;
|
|
320
|
+
}
|
|
321
|
+
attempt += 1;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (!note.fileName) {
|
|
325
|
+
note.fileName = joinRelativePath(folderPath, `${baseName}-${Date.now()}.md`);
|
|
326
|
+
usedPaths.add(`${storageKeyPrefix}:${note.fileName.toLowerCase()}`);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function toPosixRelativePath(baseDir, absolutePath) {
|
|
332
|
+
return path.relative(baseDir, absolutePath).split(path.sep).join("/");
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function ensureStateUiObject(state) {
|
|
336
|
+
if (!state.ui || typeof state.ui !== "object") {
|
|
337
|
+
state.ui = {};
|
|
338
|
+
}
|
|
339
|
+
if (!Array.isArray(state.ui.expandedFolderIds)) {
|
|
340
|
+
state.ui.expandedFolderIds = [];
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function createDefaultStatePayload() {
|
|
345
|
+
const now = Date.now();
|
|
346
|
+
const workId = createId();
|
|
347
|
+
const personalId = createId();
|
|
348
|
+
const noteId = createId();
|
|
349
|
+
|
|
350
|
+
const state = {
|
|
351
|
+
folders: [
|
|
352
|
+
{ id: workId, name: "Work", parentId: null, createdAt: now, updatedAt: now },
|
|
353
|
+
{ id: personalId, name: "Personal", parentId: null, createdAt: now + 1, updatedAt: now + 1 },
|
|
354
|
+
],
|
|
355
|
+
notes: [
|
|
356
|
+
{
|
|
357
|
+
id: noteId,
|
|
358
|
+
title: "Welcome",
|
|
359
|
+
folderId: null,
|
|
360
|
+
fileName: `${noteId}.md`,
|
|
361
|
+
createdAt: now,
|
|
362
|
+
updatedAt: now,
|
|
363
|
+
deletedAt: null,
|
|
364
|
+
},
|
|
365
|
+
],
|
|
366
|
+
ui: {
|
|
367
|
+
expandedFolderIds: [workId, personalId],
|
|
368
|
+
},
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
const noteContents = new Map([[noteId, starterContent]]);
|
|
372
|
+
return { state, noteContents };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function listMarkdownFilesRecursively(rootDir) {
|
|
376
|
+
if (!fs.existsSync(rootDir)) {
|
|
377
|
+
return [];
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const files = [];
|
|
381
|
+
const stack = [rootDir];
|
|
382
|
+
|
|
383
|
+
while (stack.length) {
|
|
384
|
+
const currentDir = stack.pop();
|
|
385
|
+
let entries = [];
|
|
386
|
+
|
|
387
|
+
try {
|
|
388
|
+
entries = fs.readdirSync(currentDir, { withFileTypes: true });
|
|
389
|
+
} catch (_error) {
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
entries.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" }));
|
|
394
|
+
|
|
395
|
+
for (const entry of entries) {
|
|
396
|
+
const absolutePath = path.join(currentDir, entry.name);
|
|
397
|
+
|
|
398
|
+
if (entry.isSymbolicLink()) {
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if (entry.isDirectory()) {
|
|
403
|
+
stack.push(absolutePath);
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if (!entry.isFile()) {
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (!entry.name.toLowerCase().endsWith(".md")) {
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
files.push(absolutePath);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
return files.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }));
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function folderNameEquals(left, right) {
|
|
423
|
+
return String(left || "").toLowerCase() === String(right || "").toLowerCase();
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function findFolderByParentAndName(state, parentId, name) {
|
|
427
|
+
const normalizedParentId = parentId || null;
|
|
428
|
+
for (const folder of state.folders) {
|
|
429
|
+
if ((folder.parentId || null) !== normalizedParentId) {
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
if (folderNameEquals(folder.name, name)) {
|
|
433
|
+
return folder;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
return null;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function ensureFolderPath(state, folderSegments) {
|
|
440
|
+
let parentId = null;
|
|
441
|
+
const folderChainIds = [];
|
|
442
|
+
|
|
443
|
+
for (const segment of folderSegments) {
|
|
444
|
+
const folderName = String(segment || "").trim();
|
|
445
|
+
if (!folderName) {
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
let folder = findFolderByParentAndName(state, parentId, folderName);
|
|
450
|
+
if (!folder) {
|
|
451
|
+
const now = Date.now();
|
|
452
|
+
folder = {
|
|
453
|
+
id: createId(),
|
|
454
|
+
name: folderName,
|
|
455
|
+
parentId,
|
|
456
|
+
createdAt: now,
|
|
457
|
+
updatedAt: now,
|
|
458
|
+
};
|
|
459
|
+
state.folders.push(folder);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
parentId = folder.id;
|
|
463
|
+
folderChainIds.push(folder.id);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
return { folderId: parentId, folderChainIds };
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function deriveTitleFromFileName(relativePath) {
|
|
470
|
+
const baseName = path.posix.basename(relativePath, ".md");
|
|
471
|
+
const readable = baseName.replace(/[-_]+/g, " ").trim();
|
|
472
|
+
return readable || "Untitled";
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function importMarkdownNotesFromDisk(state) {
|
|
476
|
+
ensureStateUiObject(state);
|
|
477
|
+
|
|
478
|
+
const activePathSet = new Set();
|
|
479
|
+
for (const note of state.notes) {
|
|
480
|
+
if (note.deletedAt) {
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
const relativePath = sanitizeMdRelativePath(note.fileName, note.id).toLowerCase();
|
|
484
|
+
activePathSet.add(relativePath);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const expandedFolderIds = new Set(
|
|
488
|
+
state.ui.expandedFolderIds
|
|
489
|
+
.map((folderId) => toStringId(folderId))
|
|
490
|
+
.filter((folderId) => folderId),
|
|
491
|
+
);
|
|
492
|
+
|
|
493
|
+
const files = listMarkdownFilesRecursively(NOTES_DIR);
|
|
494
|
+
for (const absolutePath of files) {
|
|
495
|
+
const relativeRawPath = toPosixRelativePath(NOTES_DIR, absolutePath);
|
|
496
|
+
const tempId = createId();
|
|
497
|
+
const relativePath = sanitizeMdRelativePath(relativeRawPath, tempId);
|
|
498
|
+
const pathKey = relativePath.toLowerCase();
|
|
499
|
+
|
|
500
|
+
if (activePathSet.has(pathKey)) {
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const rawSegments = relativeRawPath.replaceAll("\\", "/").split("/").filter(Boolean);
|
|
505
|
+
const folderSegments = rawSegments.slice(0, -1);
|
|
506
|
+
const { folderId, folderChainIds } = ensureFolderPath(state, folderSegments);
|
|
507
|
+
|
|
508
|
+
for (const folderChainId of folderChainIds) {
|
|
509
|
+
expandedFolderIds.add(folderChainId);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
let modifiedAt = Date.now();
|
|
513
|
+
try {
|
|
514
|
+
const stat = fs.statSync(absolutePath);
|
|
515
|
+
modifiedAt = Number(stat.mtimeMs) || modifiedAt;
|
|
516
|
+
} catch (_error) {
|
|
517
|
+
// Keep default timestamp.
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
state.notes.push({
|
|
521
|
+
id: tempId,
|
|
522
|
+
title: deriveTitleFromFileName(relativePath),
|
|
523
|
+
folderId,
|
|
524
|
+
fileName: relativePath,
|
|
525
|
+
createdAt: modifiedAt,
|
|
526
|
+
updatedAt: modifiedAt,
|
|
527
|
+
deletedAt: null,
|
|
528
|
+
});
|
|
529
|
+
activePathSet.add(pathKey);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
state.ui.expandedFolderIds = Array.from(expandedFolderIds);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function createImportedStatePayload() {
|
|
536
|
+
const state = {
|
|
537
|
+
folders: [],
|
|
538
|
+
notes: [],
|
|
539
|
+
ui: {
|
|
540
|
+
expandedFolderIds: [],
|
|
541
|
+
},
|
|
542
|
+
};
|
|
543
|
+
importMarkdownNotesFromDisk(state);
|
|
544
|
+
return { state, noteContents: new Map() };
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function normalizeStatePayload(rawPayload) {
|
|
548
|
+
const payload = rawPayload && typeof rawPayload === "object" ? rawPayload : {};
|
|
549
|
+
|
|
550
|
+
const foldersInput = Array.isArray(payload.folders) ? payload.folders : [];
|
|
551
|
+
const normalizedFolders = [];
|
|
552
|
+
const folderIds = new Set();
|
|
553
|
+
|
|
554
|
+
for (const rawFolder of foldersInput) {
|
|
555
|
+
if (!rawFolder || rawFolder.id == null || rawFolder.name == null) {
|
|
556
|
+
continue;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const id = toStringId(rawFolder.id);
|
|
560
|
+
if (!id || folderIds.has(id)) {
|
|
561
|
+
continue;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const name = String(rawFolder.name).trim() || "Untitled Folder";
|
|
565
|
+
normalizedFolders.push({
|
|
566
|
+
id,
|
|
567
|
+
name,
|
|
568
|
+
parentId: rawFolder.parentId == null ? null : toStringId(rawFolder.parentId),
|
|
569
|
+
createdAt: Number(rawFolder.createdAt) || Date.now(),
|
|
570
|
+
updatedAt: Number(rawFolder.updatedAt) || Number(rawFolder.createdAt) || Date.now(),
|
|
571
|
+
});
|
|
572
|
+
folderIds.add(id);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const folderById = new Map(normalizedFolders.map((folder) => [folder.id, folder]));
|
|
576
|
+
|
|
577
|
+
for (const folder of normalizedFolders) {
|
|
578
|
+
if (!folder.parentId || folder.parentId === folder.id || !folderById.has(folder.parentId)) {
|
|
579
|
+
folder.parentId = null;
|
|
580
|
+
continue;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const seen = new Set([folder.id]);
|
|
584
|
+
let nextParentId = folder.parentId;
|
|
585
|
+
let hasCycle = false;
|
|
586
|
+
|
|
587
|
+
while (nextParentId) {
|
|
588
|
+
if (seen.has(nextParentId)) {
|
|
589
|
+
hasCycle = true;
|
|
590
|
+
break;
|
|
591
|
+
}
|
|
592
|
+
seen.add(nextParentId);
|
|
593
|
+
nextParentId = folderById.get(nextParentId)?.parentId || null;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
if (hasCycle) {
|
|
597
|
+
folder.parentId = null;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
const validFolderIds = new Set(normalizedFolders.map((folder) => folder.id));
|
|
602
|
+
|
|
603
|
+
const notesInput = Array.isArray(payload.notes) ? payload.notes : [];
|
|
604
|
+
const normalizedNotes = [];
|
|
605
|
+
const noteIds = new Set();
|
|
606
|
+
const noteContents = new Map();
|
|
607
|
+
|
|
608
|
+
for (const rawNote of notesInput) {
|
|
609
|
+
if (!rawNote || rawNote.id == null) {
|
|
610
|
+
continue;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
const id = toStringId(rawNote.id);
|
|
614
|
+
if (!id || noteIds.has(id)) {
|
|
615
|
+
continue;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
const title = String(rawNote.title || "Untitled").trim() || "Untitled";
|
|
619
|
+
const folderIdCandidate = rawNote.folderId == null ? null : toStringId(rawNote.folderId);
|
|
620
|
+
const folderId = folderIdCandidate && validFolderIds.has(folderIdCandidate) ? folderIdCandidate : null;
|
|
621
|
+
const createdAt = Number(rawNote.createdAt) || Date.now();
|
|
622
|
+
const updatedAt = Number(rawNote.updatedAt) || createdAt;
|
|
623
|
+
const deletedAt = rawNote.deletedAt ? Number(rawNote.deletedAt) || null : null;
|
|
624
|
+
const fileNameInput =
|
|
625
|
+
typeof rawNote.fileName === "string"
|
|
626
|
+
? rawNote.fileName
|
|
627
|
+
: typeof rawNote.filePath === "string"
|
|
628
|
+
? rawNote.filePath
|
|
629
|
+
: "";
|
|
630
|
+
const fileName = sanitizeMdRelativePath(fileNameInput, id);
|
|
631
|
+
|
|
632
|
+
normalizedNotes.push({
|
|
633
|
+
id,
|
|
634
|
+
title,
|
|
635
|
+
folderId,
|
|
636
|
+
fileName,
|
|
637
|
+
createdAt,
|
|
638
|
+
updatedAt,
|
|
639
|
+
deletedAt,
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
if (typeof rawNote.content === "string") {
|
|
643
|
+
noteContents.set(id, rawNote.content);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
noteIds.add(id);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
let uiExpanded = [];
|
|
650
|
+
if (payload.ui && typeof payload.ui === "object" && Array.isArray(payload.ui.expandedFolderIds)) {
|
|
651
|
+
uiExpanded = payload.ui.expandedFolderIds
|
|
652
|
+
.map((folderId) => toStringId(folderId))
|
|
653
|
+
.filter((folderId) => folderId && validFolderIds.has(folderId));
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
const state = {
|
|
657
|
+
folders: normalizedFolders,
|
|
658
|
+
notes: normalizedNotes,
|
|
659
|
+
ui: {
|
|
660
|
+
expandedFolderIds: [...new Set(uiExpanded)],
|
|
661
|
+
},
|
|
662
|
+
};
|
|
663
|
+
|
|
664
|
+
return { state, noteContents };
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function normalizeStateMeta(rawMeta, fallbackRevision = INITIAL_STATE_REVISION) {
|
|
668
|
+
const now = Date.now();
|
|
669
|
+
const parsedRevision = Number(rawMeta?.revision);
|
|
670
|
+
const revision =
|
|
671
|
+
Number.isSafeInteger(parsedRevision) && parsedRevision >= 0
|
|
672
|
+
? parsedRevision
|
|
673
|
+
: fallbackRevision;
|
|
674
|
+
|
|
675
|
+
const parsedUpdatedAt = Number(rawMeta?.updatedAt);
|
|
676
|
+
const updatedAt = Number.isFinite(parsedUpdatedAt) && parsedUpdatedAt > 0 ? parsedUpdatedAt : now;
|
|
677
|
+
|
|
678
|
+
return { revision, updatedAt };
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function parseBaseRevision(rawPayload) {
|
|
682
|
+
if (!rawPayload || typeof rawPayload !== "object") {
|
|
683
|
+
return null;
|
|
684
|
+
}
|
|
685
|
+
const parsed = Number(rawPayload?._meta?.baseRevision);
|
|
686
|
+
if (!Number.isSafeInteger(parsed) || parsed < 0) {
|
|
687
|
+
return null;
|
|
688
|
+
}
|
|
689
|
+
return parsed;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function createConflictError(expectedRevision, actualRevision, statePayload) {
|
|
693
|
+
const error = new Error("State revision mismatch.");
|
|
694
|
+
error.status = 409;
|
|
695
|
+
error.payload = {
|
|
696
|
+
expectedRevision,
|
|
697
|
+
actualRevision,
|
|
698
|
+
state: statePayload,
|
|
699
|
+
};
|
|
700
|
+
return error;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function getNoteRelativePath(note) {
|
|
704
|
+
const relativePath = sanitizeMdRelativePath(note.fileName, note.id);
|
|
705
|
+
note.fileName = relativePath;
|
|
706
|
+
return relativePath;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function getNoteFilePath(note) {
|
|
710
|
+
const relativePath = getNoteRelativePath(note);
|
|
711
|
+
const targetDir = note.deletedAt ? TRASH_DIR : NOTES_DIR;
|
|
712
|
+
return path.join(targetDir, ...relativePath.split("/"));
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function readNoteContentFromDisk(note) {
|
|
716
|
+
const filePath = getNoteFilePath(note);
|
|
717
|
+
if (!fs.existsSync(filePath)) {
|
|
718
|
+
const fallback = defaultNoteContent(note.title);
|
|
719
|
+
ensureParentDir(filePath);
|
|
720
|
+
fs.writeFileSync(filePath, fallback, "utf8");
|
|
721
|
+
return fallback;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
try {
|
|
725
|
+
return fs.readFileSync(filePath, "utf8");
|
|
726
|
+
} catch (_error) {
|
|
727
|
+
const fallback = defaultNoteContent(note.title);
|
|
728
|
+
ensureParentDir(filePath);
|
|
729
|
+
fs.writeFileSync(filePath, fallback, "utf8");
|
|
730
|
+
return fallback;
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
function readFileContentIfExists(filePath) {
|
|
735
|
+
if (!filePath || !fs.existsSync(filePath)) {
|
|
736
|
+
return null;
|
|
737
|
+
}
|
|
738
|
+
try {
|
|
739
|
+
return fs.readFileSync(filePath, "utf8");
|
|
740
|
+
} catch (_error) {
|
|
741
|
+
return null;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
function deleteNoteFileIfExists(filePath) {
|
|
746
|
+
try {
|
|
747
|
+
if (fs.existsSync(filePath)) {
|
|
748
|
+
fs.unlinkSync(filePath);
|
|
749
|
+
}
|
|
750
|
+
} catch (_error) {
|
|
751
|
+
// Ignore unlink errors to keep operations resilient.
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function deleteNoteFiles(note) {
|
|
756
|
+
const relativePath = sanitizeMdRelativePath(note.fileName, note.id);
|
|
757
|
+
const relativeParts = relativePath.split("/");
|
|
758
|
+
|
|
759
|
+
deleteNoteFileIfExists(path.join(NOTES_DIR, ...relativeParts));
|
|
760
|
+
deleteNoteFileIfExists(path.join(TRASH_DIR, ...relativeParts));
|
|
761
|
+
|
|
762
|
+
const legacyName = `${note.id}.md`;
|
|
763
|
+
deleteNoteFileIfExists(path.join(NOTES_DIR, legacyName));
|
|
764
|
+
deleteNoteFileIfExists(path.join(TRASH_DIR, legacyName));
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
function purgeExpiredNotes(state) {
|
|
768
|
+
const now = Date.now();
|
|
769
|
+
const keptNotes = [];
|
|
770
|
+
const removedNotes = [];
|
|
771
|
+
|
|
772
|
+
for (const note of state.notes) {
|
|
773
|
+
if (!note.deletedAt) {
|
|
774
|
+
keptNotes.push(note);
|
|
775
|
+
continue;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
if (now - note.deletedAt >= THIRTY_DAYS_MS) {
|
|
779
|
+
removedNotes.push(note);
|
|
780
|
+
continue;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
keptNotes.push(note);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
state.notes = keptNotes;
|
|
787
|
+
return removedNotes;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
function ensureMissingFilesFromPayload(state, noteContents) {
|
|
791
|
+
for (const note of state.notes) {
|
|
792
|
+
const filePath = getNoteFilePath(note);
|
|
793
|
+
if (fs.existsSync(filePath)) {
|
|
794
|
+
continue;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
const content = noteContents.has(note.id)
|
|
798
|
+
? noteContents.get(note.id)
|
|
799
|
+
: defaultNoteContent(note.title);
|
|
800
|
+
ensureParentDir(filePath);
|
|
801
|
+
fs.writeFileSync(filePath, content, "utf8");
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
function writeStateMetadata(state, meta) {
|
|
806
|
+
writeJson(STATE_FILE, {
|
|
807
|
+
folders: state.folders,
|
|
808
|
+
notes: state.notes,
|
|
809
|
+
ui: state.ui,
|
|
810
|
+
_meta: {
|
|
811
|
+
revision: Number(meta?.revision) || INITIAL_STATE_REVISION,
|
|
812
|
+
updatedAt: Number(meta?.updatedAt) || Date.now(),
|
|
813
|
+
},
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function loadStateFromDisk() {
|
|
818
|
+
ensureDataLayout();
|
|
819
|
+
|
|
820
|
+
const rawPayload = safeReadJson(STATE_FILE);
|
|
821
|
+
let payload;
|
|
822
|
+
let meta;
|
|
823
|
+
let previousSerializedState = null;
|
|
824
|
+
|
|
825
|
+
if (rawPayload) {
|
|
826
|
+
payload = normalizeStatePayload(rawPayload);
|
|
827
|
+
previousSerializedState = serializeStateSnapshot(payload.state);
|
|
828
|
+
meta = normalizeStateMeta(rawPayload?._meta, INITIAL_STATE_REVISION);
|
|
829
|
+
} else {
|
|
830
|
+
payload = createImportedStatePayload();
|
|
831
|
+
if (!payload.state.notes.length) {
|
|
832
|
+
payload = createDefaultStatePayload();
|
|
833
|
+
}
|
|
834
|
+
meta = normalizeStateMeta(null, INITIAL_STATE_REVISION);
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
const { state, noteContents } = payload;
|
|
838
|
+
|
|
839
|
+
importMarkdownNotesFromDisk(state);
|
|
840
|
+
|
|
841
|
+
const removedNotes = purgeExpiredNotes(state);
|
|
842
|
+
for (const note of removedNotes) {
|
|
843
|
+
deleteNoteFiles(note);
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
ensureMissingFilesFromPayload(state, noteContents);
|
|
847
|
+
const nextSerializedState = serializeStateSnapshot(state);
|
|
848
|
+
const stateMutated =
|
|
849
|
+
previousSerializedState == null || previousSerializedState !== nextSerializedState;
|
|
850
|
+
|
|
851
|
+
if (stateMutated) {
|
|
852
|
+
meta = {
|
|
853
|
+
revision: rawPayload ? meta.revision + 1 : meta.revision,
|
|
854
|
+
updatedAt: Date.now(),
|
|
855
|
+
};
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
writeStateMetadata(state, meta);
|
|
859
|
+
|
|
860
|
+
return { state, meta };
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
function moveFileSafely(sourcePath, targetPath) {
|
|
864
|
+
if (sourcePath === targetPath || !fs.existsSync(sourcePath)) {
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
ensureParentDir(targetPath);
|
|
869
|
+
|
|
870
|
+
try {
|
|
871
|
+
fs.renameSync(sourcePath, targetPath);
|
|
872
|
+
} catch (_error) {
|
|
873
|
+
const content = fs.readFileSync(sourcePath, "utf8");
|
|
874
|
+
fs.writeFileSync(targetPath, content, "utf8");
|
|
875
|
+
deleteNoteFileIfExists(sourcePath);
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
function saveIncomingState(rawPayload) {
|
|
880
|
+
const previousEnvelope = loadStateFromDisk();
|
|
881
|
+
const previousState = previousEnvelope.state;
|
|
882
|
+
const previousMeta = previousEnvelope.meta;
|
|
883
|
+
const previousNotesById = new Map(previousState.notes.map((note) => [note.id, note]));
|
|
884
|
+
const previousSerializedState = serializeStateSnapshot(previousState);
|
|
885
|
+
|
|
886
|
+
const baseRevision = parseBaseRevision(rawPayload);
|
|
887
|
+
if (baseRevision != null && baseRevision !== previousMeta.revision) {
|
|
888
|
+
throw createConflictError(
|
|
889
|
+
baseRevision,
|
|
890
|
+
previousMeta.revision,
|
|
891
|
+
hydrateState(previousState, previousMeta),
|
|
892
|
+
);
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
const { state, noteContents } = normalizeStatePayload(rawPayload);
|
|
896
|
+
|
|
897
|
+
const removedNotes = purgeExpiredNotes(state);
|
|
898
|
+
for (const note of removedNotes) {
|
|
899
|
+
deleteNoteFiles(note);
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
assignNoteFileNamesFromTitles(state);
|
|
903
|
+
|
|
904
|
+
for (const note of state.notes) {
|
|
905
|
+
const previousNote = previousNotesById.get(note.id);
|
|
906
|
+
const previousPath = previousNote ? getNoteFilePath(previousNote) : null;
|
|
907
|
+
const targetPath = getNoteFilePath(note);
|
|
908
|
+
const previousUpdatedAt = previousNote ? Number(previousNote.updatedAt) || 0 : 0;
|
|
909
|
+
const nextUpdatedAt = Number(note.updatedAt) || 0;
|
|
910
|
+
const targetExistsBeforeWrite = fs.existsSync(targetPath);
|
|
911
|
+
const pathChanged = Boolean(previousPath && previousPath !== targetPath);
|
|
912
|
+
|
|
913
|
+
if (
|
|
914
|
+
previousNote &&
|
|
915
|
+
!pathChanged &&
|
|
916
|
+
previousUpdatedAt === nextUpdatedAt &&
|
|
917
|
+
targetExistsBeforeWrite
|
|
918
|
+
) {
|
|
919
|
+
continue;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
const content = noteContents.has(note.id)
|
|
923
|
+
? noteContents.get(note.id)
|
|
924
|
+
: previousPath && fs.existsSync(previousPath)
|
|
925
|
+
? fs.readFileSync(previousPath, "utf8")
|
|
926
|
+
: fs.existsSync(targetPath)
|
|
927
|
+
? fs.readFileSync(targetPath, "utf8")
|
|
928
|
+
: defaultNoteContent(note.title);
|
|
929
|
+
|
|
930
|
+
if (pathChanged) {
|
|
931
|
+
moveFileSafely(previousPath, targetPath);
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
const existingTargetContent = readFileContentIfExists(targetPath);
|
|
935
|
+
const shouldWriteFile = existingTargetContent == null || existingTargetContent !== content;
|
|
936
|
+
|
|
937
|
+
if (shouldWriteFile) {
|
|
938
|
+
ensureParentDir(targetPath);
|
|
939
|
+
fs.writeFileSync(targetPath, content, "utf8");
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
const nextSerializedState = serializeStateSnapshot(state);
|
|
944
|
+
const stateMutated = previousSerializedState !== nextSerializedState;
|
|
945
|
+
const nextMeta = stateMutated
|
|
946
|
+
? {
|
|
947
|
+
revision: previousMeta.revision + 1,
|
|
948
|
+
updatedAt: Date.now(),
|
|
949
|
+
}
|
|
950
|
+
: previousMeta;
|
|
951
|
+
|
|
952
|
+
writeStateMetadata(state, nextMeta);
|
|
953
|
+
|
|
954
|
+
return { state, meta: nextMeta };
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
function hydrateState(state, meta) {
|
|
958
|
+
return {
|
|
959
|
+
folders: state.folders.map((folder) => ({ ...folder })),
|
|
960
|
+
notes: state.notes.map((note) => ({
|
|
961
|
+
...note,
|
|
962
|
+
content: readNoteContentFromDisk(note),
|
|
963
|
+
})),
|
|
964
|
+
ui: {
|
|
965
|
+
expandedFolderIds: Array.isArray(state.ui?.expandedFolderIds)
|
|
966
|
+
? [...state.ui.expandedFolderIds]
|
|
967
|
+
: [],
|
|
968
|
+
},
|
|
969
|
+
_meta: {
|
|
970
|
+
revision: Number(meta?.revision) || INITIAL_STATE_REVISION,
|
|
971
|
+
updatedAt: Number(meta?.updatedAt) || Date.now(),
|
|
972
|
+
},
|
|
973
|
+
};
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
export function getHydratedState() {
|
|
977
|
+
const envelope = loadStateFromDisk();
|
|
978
|
+
return hydrateState(envelope.state, envelope.meta);
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
export function replaceState(rawPayload) {
|
|
982
|
+
const envelope = saveIncomingState(rawPayload);
|
|
983
|
+
return hydrateState(envelope.state, envelope.meta);
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
export function getStorageRootDir() {
|
|
987
|
+
return DATA_DIR;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
export function getNotesDir() {
|
|
991
|
+
return NOTES_DIR;
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
export function getTrashDir() {
|
|
995
|
+
return TRASH_DIR;
|
|
996
|
+
}
|