@lapis-notes/history 0.1.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,435 @@
1
+ import { dirname, md5, normalizePath, Plugin, TFile, } from "@lapis-notes/api";
2
+ import { consumeSuppressedHash, shouldReplaceLatestModify, } from "./history-capture";
3
+ import { mergeHistorySettings, patchHistorySettings, } from "./history-settings";
4
+ import { HistorySettingsTab } from "./history-settings-tab";
5
+ import { registerHistorySettings } from "./register-history-settings";
6
+ import { isHistoryTrackedFile } from "./history-tracking";
7
+ import { HistoryCompareView } from "./history-compare-view";
8
+ import { HistoryView } from "./history-view";
9
+ import { HistoryCompareViewType, HistoryViewType, } from "./history-view-type";
10
+ import manifestSpec from "../../manifest.json";
11
+ export const HISTORY_PLUGIN_ID = "history";
12
+ function orderNewestFirst(revisions) {
13
+ return [...revisions].sort((left, right) => right.createdAt - left.createdAt);
14
+ }
15
+ function toPluginHistory(history) {
16
+ if (!history)
17
+ return null;
18
+ return {
19
+ file: {
20
+ id: history.file.fileId,
21
+ path: history.file.currentPath,
22
+ deleted: history.file.deleted,
23
+ },
24
+ revisions: orderNewestFirst(history.revisions.map((revision) => ({
25
+ revisionId: revision.revisionId,
26
+ path: revision.currentPath,
27
+ previousPath: revision.eventType === "rename" ? revision.capturedPath : undefined,
28
+ eventType: revision.eventType,
29
+ createdAt: revision.createdAt,
30
+ sourceMtime: revision.sourceMtime,
31
+ sourceSize: revision.sourceSize,
32
+ contentHash: revision.contentHash,
33
+ content: revision.content,
34
+ }))),
35
+ };
36
+ }
37
+ export class HistoryPlugin extends Plugin {
38
+ settings = mergeHistorySettings(null);
39
+ currentFilePath = null;
40
+ compareAnchor = null;
41
+ pendingCaptures = new Map();
42
+ lastModifyStoredAt = new Map();
43
+ suppressedHashes = new Map();
44
+ listeners = new Set();
45
+ constructor(app, manifest = manifestSpec) {
46
+ super(app, manifest);
47
+ }
48
+ async onload() {
49
+ this.settings = mergeHistorySettings(await this.loadData());
50
+ this.addSettingTab(new HistorySettingsTab(this.app, this));
51
+ registerHistorySettings(this);
52
+ this.registerSidebarView(HistoryViewType, (leaf) => new HistoryView(leaf), { side: "right", title: "History", icon: "history" }, {
53
+ kind: "command",
54
+ command: {
55
+ id: "open-file-history",
56
+ name: "Open file history",
57
+ callback: () => void this.openHistoryView(),
58
+ },
59
+ });
60
+ this.registerView(HistoryCompareViewType, (leaf) => new HistoryCompareView(this, leaf), { kind: "internal" });
61
+ this.registerEvent(this.app.workspace.on("file-open", (file) => {
62
+ if (file instanceof TFile) {
63
+ this.setCurrentFilePath(file.path);
64
+ void this.ensureBaselineForFile(file);
65
+ }
66
+ }));
67
+ this.registerEvent(this.app.workspace.on("active-leaf-change", () => {
68
+ const file = this.getFocusedFile();
69
+ if (file)
70
+ this.setCurrentFilePath(file.path);
71
+ }));
72
+ this.registerEvent(this.app.vault.on("create", (file) => {
73
+ if (file instanceof TFile)
74
+ this.scheduleCapture(file, "create");
75
+ }));
76
+ this.registerEvent(this.app.vault.on("modify", (file) => {
77
+ if (file instanceof TFile)
78
+ this.scheduleCapture(file, "modify");
79
+ }));
80
+ this.registerEvent(this.app.vault.on("rename", (file, oldPath) => {
81
+ if (!(file instanceof TFile))
82
+ return;
83
+ if (this.currentFilePath === oldPath)
84
+ this.setCurrentFilePath(file.path);
85
+ if (this.compareAnchor?.filePath === oldPath) {
86
+ this.compareAnchor = {
87
+ filePath: file.path,
88
+ revisionId: this.compareAnchor.revisionId,
89
+ };
90
+ }
91
+ void this.captureRename(file, oldPath);
92
+ }));
93
+ this.registerEvent(this.app.vault.on("delete", (file) => {
94
+ if (file instanceof TFile)
95
+ void this.captureDelete(file);
96
+ }));
97
+ const initialFile = this.getFocusedFile();
98
+ if (initialFile) {
99
+ this.setCurrentFilePath(initialFile.path);
100
+ void this.ensureBaselineForFile(initialFile);
101
+ }
102
+ this.register(() => {
103
+ for (const timer of this.pendingCaptures.values())
104
+ clearTimeout(timer);
105
+ this.pendingCaptures.clear();
106
+ });
107
+ }
108
+ getSettings() {
109
+ return mergeHistorySettings(this.settings);
110
+ }
111
+ async updateSettings(patch) {
112
+ this.settings = patchHistorySettings(this.settings, patch);
113
+ await this.saveData(this.settings);
114
+ this.notify();
115
+ }
116
+ onHistoryChanged(listener) {
117
+ this.listeners.add(listener);
118
+ return () => this.listeners.delete(listener);
119
+ }
120
+ getCompareAnchor() {
121
+ return this.compareAnchor;
122
+ }
123
+ setCompareAnchor(anchor) {
124
+ this.compareAnchor = anchor;
125
+ this.notify();
126
+ }
127
+ toggleCompareAnchor(filePath, revisionId) {
128
+ if (this.compareAnchor?.filePath === filePath &&
129
+ this.compareAnchor.revisionId === revisionId) {
130
+ this.setCompareAnchor(null);
131
+ return;
132
+ }
133
+ this.setCompareAnchor({ filePath, revisionId });
134
+ }
135
+ async openHistoryView(filePath) {
136
+ const file = filePath
137
+ ? this.app.vault.getFileByPath(normalizePath(filePath))
138
+ : this.getFocusedFile();
139
+ if (file instanceof TFile) {
140
+ this.setCurrentFilePath(file.path);
141
+ await this.ensureBaselineForFile(file);
142
+ }
143
+ else if (filePath) {
144
+ this.setCurrentFilePath(filePath);
145
+ }
146
+ const existing = this.app.workspace.getLeavesOfType(HistoryViewType)[0];
147
+ const leaf = existing ?? this.app.workspace.ensureSideLeaf(HistoryViewType, "right");
148
+ await leaf.setViewState({ type: HistoryViewType });
149
+ this.app.workspace.activateLeaf(leaf, {
150
+ focusRootHost: false,
151
+ source: "api",
152
+ operation: "open-file-history",
153
+ });
154
+ await this.app.workspace.revealLeaf(leaf);
155
+ }
156
+ async openHistoryCompareView(state) {
157
+ const leaves = this.app.workspace.getLeavesOfType(HistoryCompareViewType);
158
+ const leaf = leaves[0] ?? this.app.workspace.getLeaf("tab");
159
+ await leaf.setViewState({
160
+ type: HistoryCompareViewType,
161
+ state: {
162
+ ...state,
163
+ sourceLeafId: state.sourceLeafId ?? this.app.workspace.activeLeaf?.id ?? undefined,
164
+ },
165
+ });
166
+ this.app.workspace.activateLeaf(leaf, {
167
+ focusRootHost: false,
168
+ source: "api",
169
+ operation: "open-history-compare",
170
+ });
171
+ await this.app.workspace.revealLeaf(leaf);
172
+ }
173
+ closeHistoryCompareView(sourceLeafId) {
174
+ const sourceLeaf = sourceLeafId
175
+ ? this.app.workspace.getLeafById(sourceLeafId)
176
+ : null;
177
+ if (sourceLeaf) {
178
+ this.app.workspace.activateLeaf(sourceLeaf, {
179
+ focusRootHost: false,
180
+ source: "api",
181
+ operation: "close-history-compare",
182
+ });
183
+ void this.app.workspace.revealLeaf(sourceLeaf);
184
+ }
185
+ this.app.workspace.getLeavesOfType(HistoryCompareViewType)[0]?.detach();
186
+ }
187
+ async getHistoryViewModel() {
188
+ const file = this.getFocusedFile();
189
+ if (file) {
190
+ this.setCurrentFilePath(file.path);
191
+ await this.ensureBaselineForFile(file);
192
+ const [rawHistory, currentContent] = await Promise.all([
193
+ this.app.appDatabase.getFileHistory(file.path),
194
+ this.app.vault.cachedRead(file).catch(() => ""),
195
+ ]);
196
+ return {
197
+ filePath: file.path,
198
+ history: toPluginHistory(rawHistory),
199
+ currentContent,
200
+ fileExists: true,
201
+ };
202
+ }
203
+ if (!this.currentFilePath) {
204
+ return {
205
+ filePath: null,
206
+ history: null,
207
+ currentContent: "",
208
+ fileExists: false,
209
+ };
210
+ }
211
+ return {
212
+ filePath: this.currentFilePath,
213
+ history: toPluginHistory(await this.app.appDatabase.getFileHistory(this.currentFilePath)),
214
+ currentContent: "",
215
+ fileExists: false,
216
+ };
217
+ }
218
+ async getHistoryComparisonModel(state) {
219
+ const filePath = normalizePath(state.filePath);
220
+ const history = toPluginHistory(await this.app.appDatabase.getFileHistory(filePath));
221
+ if (!history)
222
+ throw new Error("No stored history found for this file");
223
+ const selectedRevision = history.revisions.find((revision) => revision.revisionId === state.revisionId);
224
+ if (!selectedRevision) {
225
+ throw new Error("Selected revision is no longer available");
226
+ }
227
+ const selectedIndex = history.revisions.findIndex((revision) => revision.revisionId === selectedRevision.revisionId);
228
+ const previousRevision = selectedIndex >= 0 ? (history.revisions[selectedIndex + 1] ?? null) : null;
229
+ const otherRevision = state.otherRevisionId
230
+ ? (history.revisions.find((revision) => revision.revisionId === state.otherRevisionId) ?? null)
231
+ : null;
232
+ const file = this.app.vault.getFileByPath(filePath);
233
+ const fileExists = file instanceof TFile;
234
+ const currentContent = fileExists
235
+ ? await this.app.vault.cachedRead(file).catch(() => "")
236
+ : "";
237
+ const compareMode = state.compareMode === "selected" && otherRevision
238
+ ? "selected"
239
+ : state.compareMode === "previous" && previousRevision
240
+ ? "previous"
241
+ : "current";
242
+ const pair = compareMode === "selected" && otherRevision
243
+ ? [selectedRevision, otherRevision].sort((left, right) => left.createdAt - right.createdAt)
244
+ : compareMode === "previous" && previousRevision
245
+ ? [previousRevision, selectedRevision]
246
+ : [selectedRevision, { content: currentContent, createdAt: Date.now() }];
247
+ return {
248
+ filePath,
249
+ history,
250
+ currentContent,
251
+ fileExists,
252
+ compareMode,
253
+ selectedRevision,
254
+ previousRevision,
255
+ otherRevision,
256
+ compareBaseText: pair[0]?.content ?? "",
257
+ compareNewText: pair[1]?.content ?? "",
258
+ beforeLabel: compareMode === "current"
259
+ ? "Selected revision"
260
+ : formatRevisionLabel(pair[0]?.createdAt),
261
+ afterLabel: compareMode === "current"
262
+ ? fileExists
263
+ ? "Current file"
264
+ : "Latest"
265
+ : formatRevisionLabel(pair[1]?.createdAt),
266
+ };
267
+ }
268
+ async restoreRevision(filePath, revision) {
269
+ const normalizedPath = normalizePath(filePath);
270
+ this.suppressedHashes.set(normalizedPath, revision.contentHash);
271
+ const existingFile = this.app.vault.getFileByPath(normalizedPath);
272
+ if (existingFile instanceof TFile) {
273
+ await this.app.vault.modify(existingFile, revision.content);
274
+ }
275
+ else {
276
+ await this.app.vault.mkpath(dirname(normalizedPath));
277
+ await this.app.vault.create(normalizedPath, revision.content);
278
+ }
279
+ const nextFile = this.app.vault.getFileByPath(normalizedPath);
280
+ await this.app.appDatabase.storeFileHistoryRevision({
281
+ path: normalizedPath,
282
+ eventType: "restore",
283
+ createdAt: Date.now(),
284
+ sourceMtime: nextFile instanceof TFile ? nextFile.stat.mtime : undefined,
285
+ sourceSize: nextFile instanceof TFile
286
+ ? nextFile.stat.size
287
+ : revision.content.length,
288
+ contentHash: revision.contentHash,
289
+ content: revision.content,
290
+ maxRevisions: this.settings.retentionCount,
291
+ });
292
+ this.setCurrentFilePath(normalizedPath);
293
+ this.notify();
294
+ }
295
+ async saveCurrentFileContent(filePath, content) {
296
+ const normalizedPath = normalizePath(filePath);
297
+ const existingFile = this.app.vault.getFileByPath(normalizedPath);
298
+ if (existingFile instanceof TFile) {
299
+ const current = await this.app.vault.cachedRead(existingFile).catch(() => null);
300
+ if (current === content)
301
+ return;
302
+ await this.app.vault.modify(existingFile, content);
303
+ }
304
+ else {
305
+ await this.app.vault.mkpath(dirname(normalizedPath));
306
+ await this.app.vault.create(normalizedPath, content);
307
+ }
308
+ this.setCurrentFilePath(normalizedPath);
309
+ this.notify();
310
+ }
311
+ getFocusedFile() {
312
+ const activeLeafFile = this.app.workspace.activeLeaf?.view?.file;
313
+ if (activeLeafFile instanceof TFile)
314
+ return activeLeafFile;
315
+ const activeFile = this.app.workspace.getActiveFile?.();
316
+ return activeFile instanceof TFile ? activeFile : null;
317
+ }
318
+ setCurrentFilePath(path) {
319
+ const normalizedPath = path ? normalizePath(path) : null;
320
+ if (this.currentFilePath === normalizedPath)
321
+ return;
322
+ this.currentFilePath = normalizedPath;
323
+ if (this.compareAnchor && this.compareAnchor.filePath !== normalizedPath) {
324
+ this.compareAnchor = null;
325
+ }
326
+ this.notify();
327
+ }
328
+ scheduleCapture(file, eventType) {
329
+ if (!isHistoryTrackedFile(file, this.settings))
330
+ return;
331
+ const path = normalizePath(file.path);
332
+ const existing = this.pendingCaptures.get(path);
333
+ if (existing)
334
+ clearTimeout(existing);
335
+ const timer = setTimeout(() => {
336
+ this.pendingCaptures.delete(path);
337
+ void this.captureSnapshot(file, eventType);
338
+ }, this.settings.debounceMs);
339
+ this.pendingCaptures.set(path, timer);
340
+ }
341
+ async ensureBaselineForFile(file) {
342
+ if (!isHistoryTrackedFile(file, this.settings))
343
+ return;
344
+ const existing = await this.app.appDatabase.getFileHistory(file.path);
345
+ if (existing)
346
+ return;
347
+ await this.captureSnapshot(file, "baseline");
348
+ }
349
+ async captureSnapshot(file, eventType) {
350
+ if (!isHistoryTrackedFile(file, this.settings))
351
+ return;
352
+ let content;
353
+ try {
354
+ content = await this.app.vault.cachedRead(file);
355
+ }
356
+ catch {
357
+ return;
358
+ }
359
+ const path = normalizePath(file.path);
360
+ const contentHash = md5(content);
361
+ if (consumeSuppressedHash(this.suppressedHashes, path, contentHash)) {
362
+ return;
363
+ }
364
+ const existing = await this.app.appDatabase.getFileHistory(path);
365
+ const latest = existing?.revisions.at(-1);
366
+ const replaceLatest = shouldReplaceLatestModify(eventType, latest?.eventType, this.lastModifyStoredAt.get(path), this.settings.mergeWindowMs);
367
+ const result = await this.app.appDatabase.storeFileHistoryRevision({
368
+ path,
369
+ eventType,
370
+ createdAt: Date.now(),
371
+ sourceMtime: file.stat.mtime,
372
+ sourceSize: file.stat.size,
373
+ contentHash,
374
+ content,
375
+ maxRevisions: this.settings.retentionCount,
376
+ replaceLatest,
377
+ });
378
+ if (result.stored) {
379
+ if (eventType === "modify")
380
+ this.lastModifyStoredAt.set(path, Date.now());
381
+ this.notify();
382
+ }
383
+ }
384
+ async captureRename(file, oldPath) {
385
+ const hadHistory = await this.app.appDatabase.getFileHistory(oldPath);
386
+ if (!hadHistory && !isHistoryTrackedFile(file, this.settings))
387
+ return;
388
+ let content = hadHistory?.revisions.at(-1)?.content ?? "";
389
+ try {
390
+ if (isHistoryTrackedFile(file, this.settings)) {
391
+ content = await this.app.vault.cachedRead(file);
392
+ }
393
+ }
394
+ catch {
395
+ return;
396
+ }
397
+ const result = await this.app.appDatabase.storeFileHistoryRevision({
398
+ path: file.path,
399
+ previousPath: oldPath,
400
+ eventType: "rename",
401
+ createdAt: Date.now(),
402
+ sourceMtime: file.stat.mtime,
403
+ sourceSize: file.stat.size,
404
+ contentHash: content ? md5(content) : hadHistory?.revisions.at(-1)?.contentHash,
405
+ content,
406
+ maxRevisions: this.settings.retentionCount,
407
+ });
408
+ if (result.stored)
409
+ this.notify();
410
+ }
411
+ async captureDelete(file) {
412
+ const path = normalizePath(file.path);
413
+ const existing = await this.app.appDatabase.getFileHistory(path);
414
+ if (!existing)
415
+ return;
416
+ const result = await this.app.appDatabase.storeFileHistoryRevision({
417
+ path,
418
+ eventType: "delete",
419
+ createdAt: Date.now(),
420
+ sourceMtime: file.stat.mtime,
421
+ sourceSize: file.stat.size,
422
+ maxRevisions: this.settings.retentionCount,
423
+ });
424
+ if (result.stored)
425
+ this.notify();
426
+ }
427
+ notify() {
428
+ for (const listener of this.listeners)
429
+ listener();
430
+ }
431
+ }
432
+ function formatRevisionLabel(value) {
433
+ return value ? new Date(value).toLocaleString() : "Revision";
434
+ }
435
+ export default HistoryPlugin;
@@ -0,0 +1,9 @@
1
+ import { PluginSettingTab, type App } from "@lapis-notes/api";
2
+ import type { HistoryPlugin } from "./history-plugin";
3
+ export declare class HistorySettingsTab extends PluginSettingTab {
4
+ private readonly historyPlugin;
5
+ constructor(app: App, historyPlugin: HistoryPlugin);
6
+ display(): void;
7
+ private addNumber;
8
+ private update;
9
+ }
@@ -0,0 +1,72 @@
1
+ import { PluginSettingTab, Setting } from "@lapis-notes/api";
2
+ export class HistorySettingsTab extends PluginSettingTab {
3
+ historyPlugin;
4
+ constructor(app, historyPlugin) {
5
+ super(app, historyPlugin);
6
+ this.historyPlugin = historyPlugin;
7
+ }
8
+ display() {
9
+ const settings = this.historyPlugin.getSettings();
10
+ this.containerEl.empty();
11
+ this.addNumber("Revisions per file", "Maximum stored snapshots for one path.", settings.retentionCount, 1, 200, 1, (retentionCount) => ({ retentionCount }));
12
+ this.addNumber("Maximum file size (KiB)", "Files larger than this are not snapshotted.", Math.round(settings.maxFileSizeBytes / 1024), 1, 2048, 1, (kib) => ({ maxFileSizeBytes: kib * 1024 }));
13
+ this.addNumber("Merge window (seconds)", "Replace the latest modify for the same path when another modify arrives inside this window.", Math.round(settings.mergeWindowMs / 1000), 0, 120, 1, (seconds) => ({ mergeWindowMs: seconds * 1000 }));
14
+ this.addNumber("Capture debounce (ms)", "Delay before create and modify snapshots are stored.", settings.debounceMs, 0, 5000, 50, (debounceMs) => ({ debounceMs }));
15
+ new Setting(this.containerEl)
16
+ .setName("Exclude globs")
17
+ .setDesc("Comma-separated globs that skip capture. Defaults include .obsidian, .lapis, .git, and .jj.")
18
+ .addText((text) => {
19
+ text
20
+ .setPlaceholder(".obsidian/**, .lapis/**")
21
+ .setValue(settings.excludeGlobs.join(", "))
22
+ .onChange((value) => {
23
+ void this.update({
24
+ excludeGlobs: value.split(","),
25
+ });
26
+ });
27
+ });
28
+ new Setting(this.containerEl)
29
+ .setName("Include globs")
30
+ .setDesc("Optional path allowlist. Leave empty to snapshot remaining paths after excludes.")
31
+ .addText((text) => {
32
+ text
33
+ .setPlaceholder("Notes/**, Projects/**")
34
+ .setValue(settings.includeGlobs.join(", "))
35
+ .onChange((value) => {
36
+ void this.update({
37
+ includeGlobs: value.split(","),
38
+ });
39
+ });
40
+ });
41
+ new Setting(this.containerEl)
42
+ .setName("Tracked extensions")
43
+ .setDesc("Optional allowlist. Leave empty to snapshot remaining UTF-8 text under the size cap.")
44
+ .addText((text) => {
45
+ text
46
+ .setPlaceholder("md, json, txt")
47
+ .setValue(settings.trackedExtensions.join(", "))
48
+ .onChange((value) => {
49
+ void this.update({
50
+ trackedExtensions: value.split(","),
51
+ });
52
+ });
53
+ });
54
+ }
55
+ addNumber(name, description, value, min, max, step, patch) {
56
+ new Setting(this.containerEl)
57
+ .setName(name)
58
+ .setDesc(description)
59
+ .addSlider((slider) => {
60
+ slider
61
+ .setDynamicTooltip()
62
+ .setLimits(min, max, step)
63
+ .setValue(value)
64
+ .onChange((next) => void this.update(patch(Number(next)), false));
65
+ });
66
+ }
67
+ async update(patch, redisplay = true) {
68
+ await this.historyPlugin.updateSettings(patch);
69
+ if (redisplay)
70
+ this.display();
71
+ }
72
+ }
@@ -0,0 +1,14 @@
1
+ export interface HistoryPluginSettings {
2
+ retentionCount: number;
3
+ debounceMs: number;
4
+ mergeWindowMs: number;
5
+ maxFileSizeBytes: number;
6
+ excludeGlobs: string[];
7
+ includeGlobs: string[];
8
+ trackedExtensions: string[];
9
+ }
10
+ export type HistoryPluginSettingsPatch = Partial<HistoryPluginSettings>;
11
+ export declare const DEFAULT_HISTORY_EXCLUDE_GLOBS: readonly [".obsidian/**", ".lapis/**", "**/.git/**", "**/.jj/**"];
12
+ export declare const DEFAULT_HISTORY_SETTINGS: HistoryPluginSettings;
13
+ export declare function mergeHistorySettings(stored: Partial<HistoryPluginSettings> | null | undefined): HistoryPluginSettings;
14
+ export declare function patchHistorySettings(current: HistoryPluginSettings, patch: HistoryPluginSettingsPatch): HistoryPluginSettings;
@@ -0,0 +1,47 @@
1
+ export const DEFAULT_HISTORY_EXCLUDE_GLOBS = [
2
+ ".obsidian/**",
3
+ ".lapis/**",
4
+ "**/.git/**",
5
+ "**/.jj/**",
6
+ ];
7
+ export const DEFAULT_HISTORY_SETTINGS = {
8
+ retentionCount: 50,
9
+ debounceMs: 500,
10
+ mergeWindowMs: 10_000,
11
+ maxFileSizeBytes: 256 * 1024,
12
+ excludeGlobs: [...DEFAULT_HISTORY_EXCLUDE_GLOBS],
13
+ includeGlobs: [],
14
+ trackedExtensions: [],
15
+ };
16
+ function normalizeStringList(values) {
17
+ return [
18
+ ...new Set((values ?? [])
19
+ .flatMap((value) => value.split(","))
20
+ .map((value) => value.trim())
21
+ .filter(Boolean)),
22
+ ];
23
+ }
24
+ function normalizeExtensions(values) {
25
+ return [
26
+ ...new Set(normalizeStringList(values).map((value) => value.toLowerCase().replace(/^\.+/u, ""))),
27
+ ];
28
+ }
29
+ export function mergeHistorySettings(stored) {
30
+ return {
31
+ retentionCount: Math.max(1, Math.round(stored?.retentionCount ?? DEFAULT_HISTORY_SETTINGS.retentionCount)),
32
+ debounceMs: Math.max(0, Math.round(stored?.debounceMs ?? DEFAULT_HISTORY_SETTINGS.debounceMs)),
33
+ mergeWindowMs: Math.max(0, Math.round(stored?.mergeWindowMs ?? DEFAULT_HISTORY_SETTINGS.mergeWindowMs)),
34
+ maxFileSizeBytes: Math.max(1, Math.round(stored?.maxFileSizeBytes ?? DEFAULT_HISTORY_SETTINGS.maxFileSizeBytes)),
35
+ excludeGlobs: normalizeStringList(stored?.excludeGlobs).length > 0
36
+ ? normalizeStringList(stored?.excludeGlobs)
37
+ : [...DEFAULT_HISTORY_SETTINGS.excludeGlobs],
38
+ includeGlobs: normalizeStringList(stored?.includeGlobs),
39
+ trackedExtensions: normalizeExtensions(stored?.trackedExtensions),
40
+ };
41
+ }
42
+ export function patchHistorySettings(current, patch) {
43
+ return mergeHistorySettings({
44
+ ...current,
45
+ ...patch,
46
+ });
47
+ }
@@ -0,0 +1,4 @@
1
+ import { type TFile } from "@lapis-notes/api";
2
+ import type { HistoryPluginSettings } from "./history-settings";
3
+ export declare const HISTORY_BINARY_EXTENSIONS: Set<string>;
4
+ export declare function isHistoryTrackedFile(file: TFile, settings: HistoryPluginSettings): boolean;
@@ -0,0 +1,49 @@
1
+ import { isLapisInternalPath, matchesEditorAssociationGlob, normalizePath, } from "@lapis-notes/api";
2
+ export const HISTORY_BINARY_EXTENSIONS = new Set([
3
+ "png",
4
+ "jpg",
5
+ "jpeg",
6
+ "gif",
7
+ "webp",
8
+ "avif",
9
+ "bmp",
10
+ "ico",
11
+ "pdf",
12
+ "zip",
13
+ "gz",
14
+ "tgz",
15
+ "7z",
16
+ "mp3",
17
+ "mp4",
18
+ "mov",
19
+ "wav",
20
+ "ogg",
21
+ "woff",
22
+ "woff2",
23
+ "ttf",
24
+ "otf",
25
+ ]);
26
+ export function isHistoryTrackedFile(file, settings) {
27
+ const path = normalizePath(file.path);
28
+ if (isLapisInternalPath(path)) {
29
+ return false;
30
+ }
31
+ if (file.stat.size > settings.maxFileSizeBytes) {
32
+ return false;
33
+ }
34
+ const extension = file.extension.toLowerCase();
35
+ if (HISTORY_BINARY_EXTENSIONS.has(extension)) {
36
+ return false;
37
+ }
38
+ if (settings.excludeGlobs.some((pattern) => matchesEditorAssociationGlob(pattern, path))) {
39
+ return false;
40
+ }
41
+ if (settings.includeGlobs.length > 0 &&
42
+ !settings.includeGlobs.some((pattern) => matchesEditorAssociationGlob(pattern, path))) {
43
+ return false;
44
+ }
45
+ if (settings.trackedExtensions.length === 0) {
46
+ return true;
47
+ }
48
+ return settings.trackedExtensions.includes(extension);
49
+ }
@@ -0,0 +1,2 @@
1
+ export declare const HistoryViewType = "history";
2
+ export declare const HistoryCompareViewType = "history-compare";
@@ -0,0 +1,2 @@
1
+ export const HistoryViewType = "history";
2
+ export const HistoryCompareViewType = "history-compare";
@@ -0,0 +1,12 @@
1
+ import { View, type WorkspaceLeaf } from "@lapis-notes/api";
2
+ export declare class HistoryView extends View {
3
+ private component;
4
+ constructor(leaf: WorkspaceLeaf);
5
+ getViewType(): string;
6
+ getDisplayText(): string;
7
+ getIcon(): string;
8
+ protected onOpen(): Promise<void>;
9
+ protected onClose(): Promise<void>;
10
+ onload(): void;
11
+ onunload(): void;
12
+ }