Apr 11, 2026 5.2 KB typescript
1import { ItemView, Notice, WorkspaceLeaf } from "obsidian";
2import type SublimatedPlugin from "./main";
3import { SublimatedApi, AuthRequiredError, type Version } from "./api";
4
5export const VERSION_VIEW_TYPE = "sublimated-versions";
6
7export class VersionView extends ItemView {
8 plugin: SublimatedPlugin;
9
10 constructor(leaf: WorkspaceLeaf, plugin: SublimatedPlugin) {
11 super(leaf);
12 this.plugin = plugin;
13 }
14
15 getViewType(): string {
16 return VERSION_VIEW_TYPE;
17 }
18
19 getDisplayText(): string {
20 return "sublimated versions";
21 }
22
23 getIcon(): string {
24 return "history";
25 }
26
27 async onOpen(): Promise<void> {
28 await this.refresh();
29 // refresh when active file changes
30 this.registerEvent(
31 this.app.workspace.on("active-leaf-change", () => {
32 this.refresh();
33 })
34 );
35 }
36
37 async refresh(): Promise<void> {
38 const { contentEl } = this;
39 contentEl.empty();
40 contentEl.addClass("sbl-version-view");
41
42 const activeFile = this.app.workspace.getActiveFile();
43 if (!activeFile) {
44 contentEl.createEl("p", {
45 text: "No file open.",
46 cls: "sbl-version-empty",
47 });
48 return;
49 }
50
51 // read xattrs to get space + item ID
52 const meta = this.plugin.lastMetadata;
53 if (!meta?.space || !meta?.id) {
54 contentEl.createEl("p", {
55 text: "Not a sublimated file.",
56 cls: "sbl-version-empty",
57 });
58 return;
59 }
60
61 contentEl.createEl("h4", {
62 text: activeFile.name,
63 cls: "sbl-version-filename",
64 });
65
66 const loading = contentEl.createEl("p", {
67 text: "Loading versions...",
68 cls: "sbl-version-loading",
69 });
70
71 try {
72 const api = new SublimatedApi(this.plugin.settings.apiToken);
73 const versions = await api.getVersions(meta.space, meta.id);
74 loading.remove();
75
76 if (versions.length === 0) {
77 contentEl.createEl("p", {
78 text: "No versions found.",
79 cls: "sbl-version-empty",
80 });
81 return;
82 }
83
84 const list = contentEl.createEl("div", {
85 cls: "sbl-version-list",
86 });
87 const groups = groupByDay(versions);
88 for (const group of groups) {
89 list.createEl("div", {
90 text: group.label,
91 cls: "sbl-version-day",
92 });
93 for (const v of group.versions) {
94 this.renderVersion(list, v, meta.url);
95 }
96 }
97 } catch (e) {
98 loading.remove();
99 if (e instanceof AuthRequiredError) {
100 contentEl.createEl("p", {
101 text: "This file is private — add an API token to view version history.",
102 cls: "sbl-version-empty",
103 });
104 contentEl.createEl("p", {
105 text: "Configure in Settings → sublimated",
106 cls: "sbl-version-hint",
107 });
108 return;
109 }
110 contentEl.createEl("p", {
111 text: `Error: ${e instanceof Error ? e.message : "Unknown"}`,
112 cls: "sbl-version-error",
113 });
114 }
115 }
116
117 private renderVersion(
118 container: HTMLElement,
119 v: Version,
120 fileUrl: string | null
121 ): void {
122 const item = container.createDiv({ cls: "sbl-version-item" });
123
124 const header = item.createDiv({ cls: "sbl-version-header" });
125 header.createEl("span", {
126 text: `v${v.number}`,
127 cls: "sbl-version-number",
128 });
129 header.createEl("span", {
130 text: v.authorName,
131 cls: "sbl-version-author",
132 });
133
134 const time = item.createDiv({ cls: "sbl-version-time" });
135 time.createEl("span", {
136 text: formatRelativeTime(v.createdAt),
137 attr: { title: new Date(v.createdAt).toLocaleString() },
138 });
139
140 if (v.message) {
141 item.createDiv({
142 text: v.message,
143 cls: "sbl-version-message",
144 });
145 }
146
147 if (fileUrl) {
148 const webUrl = `https://${fileUrl}/~versions/v${v.number}`;
149 const link = item.createEl("a", {
150 text: "view on web",
151 cls: "sbl-version-web-link",
152 href: webUrl,
153 });
154 link.addEventListener("click", (e) => {
155 e.preventDefault();
156 window.open(webUrl);
157 });
158 }
159 }
160
161 async onClose(): Promise<void> {
162 // cleanup handled by obsidian
163 }
164}
165
166interface DayGroup {
167 label: string;
168 versions: Version[];
169}
170
171function groupByDay(versions: Version[]): DayGroup[] {
172 const groups = new Map<string, Version[]>();
173 const order: string[] = [];
174
175 for (const v of versions) {
176 const label = formatDayLabel(v.createdAt);
177 if (!groups.has(label)) {
178 groups.set(label, []);
179 order.push(label);
180 }
181 groups.get(label)!.push(v);
182 }
183
184 return order.map((label) => ({
185 label,
186 versions: groups.get(label)!,
187 }));
188}
189
190function formatDayLabel(iso: string): string {
191 const now = new Date();
192 const date = new Date(iso);
193
194 const nowDay = new Date(now.getFullYear(), now.getMonth(), now.getDate());
195 const thenDay = new Date(
196 date.getFullYear(),
197 date.getMonth(),
198 date.getDate()
199 );
200 const days = Math.round(
201 (nowDay.getTime() - thenDay.getTime()) / (1000 * 60 * 60 * 24)
202 );
203
204 if (days === 0) return "Today";
205 if (days === 1) return "Yesterday";
206 if (days < 7) return `${days} days ago`;
207 return date.toLocaleDateString("en-US", {
208 month: "short",
209 day: "numeric",
210 });
211}
212
213function formatRelativeTime(iso: string): string {
214 const now = Date.now();
215 const then = new Date(iso).getTime();
216 const diff = now - then;
217 const seconds = Math.floor(diff / 1000);
218 const minutes = Math.floor(seconds / 60);
219 const hours = Math.floor(minutes / 60);
220 const days = Math.floor(hours / 24);
221
222 if (days > 30) return new Date(iso).toLocaleDateString();
223 if (days > 0) return `${days}d ago`;
224 if (hours > 0) return `${hours}h ago`;
225 if (minutes > 0) return `${minutes}m ago`;
226 return "just now";
227}