Apr 11, 2026 4.9 KB typescript of v8
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 contentEl.createEl("p", {
100 text: `Error: ${e instanceof Error ? e.message : "Unknown"}`,
101 cls: "sbl-version-error",
102 });
103 }
104 }
105
106 private renderVersion(
107 container: HTMLElement,
108 v: Version,
109 fileUrl: string | null
110 ): void {
111 const item = container.createDiv({ cls: "sbl-version-item" });
112
113 const header = item.createDiv({ cls: "sbl-version-header" });
114 header.createEl("span", {
115 text: `v${v.number}`,
116 cls: "sbl-version-number",
117 });
118 header.createEl("span", {
119 text: v.authorName,
120 cls: "sbl-version-author",
121 });
122
123 const time = item.createDiv({ cls: "sbl-version-time" });
124 time.createEl("span", {
125 text: formatRelativeTime(v.createdAt),
126 attr: { title: new Date(v.createdAt).toLocaleString() },
127 });
128
129 if (v.message) {
130 item.createDiv({
131 text: v.message,
132 cls: "sbl-version-message",
133 });
134 }
135
136 if (fileUrl) {
137 const webUrl = `https://${fileUrl}/~versions/v${v.number}`;
138 const link = item.createEl("a", {
139 text: "view on web",
140 cls: "sbl-version-web-link",
141 href: webUrl,
142 });
143 link.addEventListener("click", (e) => {
144 e.preventDefault();
145 window.open(webUrl);
146 });
147 }
148 }
149
150 async onClose(): Promise<void> {
151 // cleanup handled by obsidian
152 }
153}
154
155interface DayGroup {
156 label: string;
157 versions: Version[];
158}
159
160function groupByDay(versions: Version[]): DayGroup[] {
161 const groups = new Map<string, Version[]>();
162 const order: string[] = [];
163
164 for (const v of versions) {
165 const label = formatDayLabel(v.createdAt);
166 if (!groups.has(label)) {
167 groups.set(label, []);
168 order.push(label);
169 }
170 groups.get(label)!.push(v);
171 }
172
173 return order.map((label) => ({
174 label,
175 versions: groups.get(label)!,
176 }));
177}
178
179function formatDayLabel(iso: string): string {
180 const now = new Date();
181 const date = new Date(iso);
182
183 const nowDay = new Date(now.getFullYear(), now.getMonth(), now.getDate());
184 const thenDay = new Date(
185 date.getFullYear(),
186 date.getMonth(),
187 date.getDate()
188 );
189 const days = Math.round(
190 (nowDay.getTime() - thenDay.getTime()) / (1000 * 60 * 60 * 24)
191 );
192
193 if (days === 0) return "Today";
194 if (days === 1) return "Yesterday";
195 if (days < 7) return `${days} days ago`;
196 return date.toLocaleDateString("en-US", {
197 month: "short",
198 day: "numeric",
199 });
200}
201
202function formatRelativeTime(iso: string): string {
203 const now = Date.now();
204 const then = new Date(iso).getTime();
205 const diff = now - then;
206 const seconds = Math.floor(diff / 1000);
207 const minutes = Math.floor(seconds / 60);
208 const hours = Math.floor(minutes / 60);
209 const days = Math.floor(hours / 24);
210
211 if (days > 30) return new Date(iso).toLocaleDateString();
212 if (days > 0) return `${days}d ago`;
213 if (hours > 0) return `${hours}h ago`;
214 if (minutes > 0) return `${minutes}m ago`;
215 return "just now";
216}