Apr 11, 2026 5.3 KB typescript of v8
1import { ItemView, Notice, WorkspaceLeaf } from "obsidian";
2import type SublimatedPlugin from "./main";
3import { SublimatedApi, 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 // public files can be fetched anonymously; private/shared files require a token
62 const isPublic = meta.visibility === "public";
63 if (!this.plugin.settings.apiToken && !isPublic) {
64 contentEl.createEl("p", {
65 text: "API token required for version history.",
66 cls: "sbl-version-empty",
67 });
68 contentEl.createEl("p", {
69 text: "Configure in Settings → sublimated",
70 cls: "sbl-version-hint",
71 });
72 return;
73 }
74
75 contentEl.createEl("h4", {
76 text: activeFile.name,
77 cls: "sbl-version-filename",
78 });
79
80 const loading = contentEl.createEl("p", {
81 text: "Loading versions...",
82 cls: "sbl-version-loading",
83 });
84
85 try {
86 const api = new SublimatedApi(this.plugin.settings.apiToken);
87 const versions = await api.getVersions(meta.space, meta.id);
88 loading.remove();
89
90 if (versions.length === 0) {
91 contentEl.createEl("p", {
92 text: "No versions found.",
93 cls: "sbl-version-empty",
94 });
95 return;
96 }
97
98 const list = contentEl.createEl("div", {
99 cls: "sbl-version-list",
100 });
101 const groups = groupByDay(versions);
102 for (const group of groups) {
103 list.createEl("div", {
104 text: group.label,
105 cls: "sbl-version-day",
106 });
107 for (const v of group.versions) {
108 this.renderVersion(list, v, meta.url);
109 }
110 }
111 } catch (e) {
112 loading.remove();
113 contentEl.createEl("p", {
114 text: `Error: ${e instanceof Error ? e.message : "Unknown"}`,
115 cls: "sbl-version-error",
116 });
117 }
118 }
119
120 private renderVersion(
121 container: HTMLElement,
122 v: Version,
123 fileUrl: string | null
124 ): void {
125 const item = container.createDiv({ cls: "sbl-version-item" });
126
127 const header = item.createDiv({ cls: "sbl-version-header" });
128 header.createEl("span", {
129 text: `v${v.number}`,
130 cls: "sbl-version-number",
131 });
132 header.createEl("span", {
133 text: v.authorName,
134 cls: "sbl-version-author",
135 });
136
137 const time = item.createDiv({ cls: "sbl-version-time" });
138 time.createEl("span", {
139 text: formatRelativeTime(v.createdAt),
140 attr: { title: new Date(v.createdAt).toLocaleString() },
141 });
142
143 if (v.message) {
144 item.createDiv({
145 text: v.message,
146 cls: "sbl-version-message",
147 });
148 }
149
150 if (fileUrl) {
151 const webUrl = `https://${fileUrl}/~versions/v${v.number}`;
152 const link = item.createEl("a", {
153 text: "view on web",
154 cls: "sbl-version-web-link",
155 href: webUrl,
156 });
157 link.addEventListener("click", (e) => {
158 e.preventDefault();
159 window.open(webUrl);
160 });
161 }
162 }
163
164 async onClose(): Promise<void> {
165 // cleanup handled by obsidian
166 }
167}
168
169interface DayGroup {
170 label: string;
171 versions: Version[];
172}
173
174function groupByDay(versions: Version[]): DayGroup[] {
175 const groups = new Map<string, Version[]>();
176 const order: string[] = [];
177
178 for (const v of versions) {
179 const label = formatDayLabel(v.createdAt);
180 if (!groups.has(label)) {
181 groups.set(label, []);
182 order.push(label);
183 }
184 groups.get(label)!.push(v);
185 }
186
187 return order.map((label) => ({
188 label,
189 versions: groups.get(label)!,
190 }));
191}
192
193function formatDayLabel(iso: string): string {
194 const now = new Date();
195 const date = new Date(iso);
196
197 const nowDay = new Date(now.getFullYear(), now.getMonth(), now.getDate());
198 const thenDay = new Date(
199 date.getFullYear(),
200 date.getMonth(),
201 date.getDate()
202 );
203 const days = Math.round(
204 (nowDay.getTime() - thenDay.getTime()) / (1000 * 60 * 60 * 24)
205 );
206
207 if (days === 0) return "Today";
208 if (days === 1) return "Yesterday";
209 if (days < 7) return `${days} days ago`;
210 return date.toLocaleDateString("en-US", {
211 month: "short",
212 day: "numeric",
213 });
214}
215
216function formatRelativeTime(iso: string): string {
217 const now = Date.now();
218 const then = new Date(iso).getTime();
219 const diff = now - then;
220 const seconds = Math.floor(diff / 1000);
221 const minutes = Math.floor(seconds / 60);
222 const hours = Math.floor(minutes / 60);
223 const days = Math.floor(hours / 24);
224
225 if (days > 30) return new Date(iso).toLocaleDateString();
226 if (days > 0) return `${days}d ago`;
227 if (hours > 0) return `${hours}h ago`;
228 if (minutes > 0) return `${minutes}m ago`;
229 return "just now";
230}