Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 60 additions & 3 deletions main.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import { Plugin } from "obsidian";
import { PluginSettings, DEFAULT_SETTINGS } from "./src/types";
import { PluginSettings, DEFAULT_SETTINGS, SyncProgressSnapshot } from "./src/types";
import { RemarkableClient } from "./src/RemarkableClient";
import { SyncEngine } from "./src/SyncEngine";
import { RemarkableSyncSettingTab } from "./src/SettingsTab";
import { SyncProgressModal } from "./src/SyncProgressModal";
import { EpubView, EPUB_VIEW_TYPE } from "./src/EpubView";

export default class RemarkableSyncPlugin extends Plugin {
settings!: PluginSettings;
private client: RemarkableClient | null = null;
private syncEngine: SyncEngine | null = null;
private statusBarItemEl!: HTMLElement;
private syncProgressModal: SyncProgressModal | null = null;

async onload(): Promise<void> {
await this.loadPluginSettings();
Expand All @@ -25,9 +28,18 @@ export default class RemarkableSyncPlugin extends Plugin {

// Status bar
const statusBarItem = this.addStatusBarItem();
this.statusBarItemEl = statusBarItem;
statusBarItem.addClass("mod-clickable");
statusBarItem.setText("Slate");
statusBarItem.addEventListener("click", () => { void this.runSync(); });
statusBarItem.addEventListener("click", () => {
if (this.syncEngine?.syncing) {
this.showSyncProgress();
return;
}

void this.runSync();
});
this.updateStatusBar();
this.registerInterval(window.setInterval(() => this.updateStatusBar(), 1000));

// Commands
this.addCommand({
Expand Down Expand Up @@ -78,9 +90,54 @@ export default class RemarkableSyncPlugin extends Plugin {
if (!this.syncEngine) {
return;
}

if (this.syncEngine.syncing) {
this.showSyncProgress();
return;
}

this.showSyncProgress();
await this.syncEngine.sync();
}

getSyncProgress(): SyncProgressSnapshot | null {
return this.syncEngine?.getProgressSnapshot() ?? null;
}

private showSyncProgress(): void {
if (!this.syncProgressModal) {
this.syncProgressModal = new SyncProgressModal(this.app, this, () => {
this.syncProgressModal = null;
});
}

this.syncProgressModal.open();
}

private updateStatusBar(): void {
if (!this.statusBarItemEl) {
return;
}

const progress = this.getSyncProgress();
if (!this.syncEngine?.syncing || !progress) {
this.statusBarItemEl.setText("Slate");
return;
}

if (progress.phase === "Listing cloud items") {
this.statusBarItemEl.setText(`Slate ${progress.inspectedItemCount}/${progress.cloudItemCount}`);
return;
}

if (progress.documentCount > 0) {
this.statusBarItemEl.setText(`Slate ${progress.processedDocumentCount}/${progress.documentCount}`);
return;
}

this.statusBarItemEl.setText(`Slate ${progress.phase}`);
}

// ── Settings Persistence ────────────────────────────────────────────────

async loadPluginSettings(): Promise<void> {
Expand Down
10 changes: 0 additions & 10 deletions response.txt

This file was deleted.

10 changes: 0 additions & 10 deletions response2.txt

This file was deleted.

133 changes: 71 additions & 62 deletions src/RemarkableClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ import { requestUrl, RequestUrlResponse } from "obsidian";
import {
RawEntry,
EntriesFile,
ItemMetadata,
RemarkableItem,
RootHashResponse,
} from "./types";
import {
Expand All @@ -13,6 +11,9 @@ import {
} from "./constants";

const TAG = "[RemarkableSync]";
const MAX_RATE_LIMIT_RETRIES = 5;
const MAX_SERVER_RETRIES = 3;
const BASE_DELAY_MS = 1000;

async function rmRequest(opts: {
url: string;
Expand All @@ -23,13 +24,65 @@ async function rmRequest(opts: {
}): Promise<RequestUrlResponse> {
const { url, method = "GET", headers, body, contentType } = opts;

try {
return await requestUrl({ url, method, headers, body, contentType });
} catch (err: unknown) {
const statusCode = (err as { status?: number })?.status;
const shortUrl = url.split("?")[0].slice(0, 80);
console.error(`${TAG} ${method} ${shortUrl} → ${statusCode ?? "network_error"}`, err);
throw new RmApiError(statusCode ? String(statusCode) : "network_error", statusCode ?? 0);
for (let attempt = 0; ; attempt++) {
try {
return await requestUrl({ url, method, headers, body, contentType });
} catch (err: unknown) {
const statusCode = (err as { status?: number })?.status;
const shortUrl = url.split("?")[0].slice(0, 80);
const retryLimit = getRetryLimit(statusCode);

if (retryLimit > 0 && attempt < retryLimit) {
const delay = BASE_DELAY_MS * Math.pow(2, attempt);
console.warn(
`${TAG} ${method} ${shortUrl} → ${statusCode}, retry ${attempt + 1}/${retryLimit} in ${delay}ms`,
);
await sleep(delay);
continue;
}

console.error(`${TAG} ${method} ${shortUrl} → ${statusCode ?? "network_error"}`, err);
throw new RmApiError(statusCode ? String(statusCode) : "network_error", statusCode ?? 0);
}
}
}

function getRetryLimit(statusCode?: number): number {
if (statusCode === 429) {
return MAX_RATE_LIMIT_RETRIES;
}

if (statusCode === 500 || statusCode === 502 || statusCode === 503) {
return MAX_SERVER_RETRIES;
}

return 0;
}

function sleep(ms: number): Promise<void> {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}

function isHtmlResponse(text: string): boolean {
const normalized = text.trimStart().toLowerCase();
return normalized.startsWith("<html") || normalized.startsWith("<!doctype html");
}

async function getTextWithHtmlRetry(requestFn: () => Promise<RequestUrlResponse>, hash: string): Promise<string> {
for (let attempt = 0; ; attempt++) {
const response = await requestFn();
if (!isHtmlResponse(response.text)) {
return response.text;
}

if (attempt < MAX_SERVER_RETRIES) {
const delay = BASE_DELAY_MS * Math.pow(2, attempt);
console.warn(`${TAG} GET file/${hash.slice(0, 12)}... returned HTML, retry ${attempt + 1}/${MAX_SERVER_RETRIES} in ${delay}ms`);
await sleep(delay);
continue;
}

throw new RmApiError(`html_error_response:${hash}`, 500);
}
}

Expand Down Expand Up @@ -114,69 +167,25 @@ export class RemarkableClient {
}

async getEntries(hash: string): Promise<EntriesFile> {
const response = await this.authedRequest(`${RM_RAW_HOST}/sync/v3/files/${hash}`);
return parseEntriesText(response.text);
const text = await getTextWithHtmlRetry(
() => this.authedRequest(`${RM_RAW_HOST}/sync/v3/files/${hash}`),
hash,
);
return parseEntriesText(text);
}

async getTextByHash(hash: string): Promise<string> {
const response = await this.authedRequest(`${RM_RAW_HOST}/sync/v3/files/${hash}`);
return response.text;
return await getTextWithHtmlRetry(
() => this.authedRequest(`${RM_RAW_HOST}/sync/v3/files/${hash}`),
hash,
);
}

async getBinaryByHash(hash: string): Promise<ArrayBuffer> {
const response = await this.authedRequest(`${RM_RAW_HOST}/sync/v3/files/${hash}`);
return response.arrayBuffer;
}

async listItems(): Promise<RemarkableItem[]> {
if (!this.userToken) {
await this.refreshToken();
}

const root = await this.getRootHash();
const rootEntries = await this.getEntries(root.hash);

const items: RemarkableItem[] = [];

for (const entry of rootEntries.entries) {
try {
const itemEntries = await this.getEntries(entry.hash);
const fileEntries = itemEntries.entries;

const metaEntry = fileEntries.find(e => e.id.endsWith(".metadata"));
if (!metaEntry) {
console.warn(`${TAG} No metadata for entry ${entry.id}, skipping`);
continue;
}

const metaText = await this.getTextByHash(metaEntry.hash);
const metadata = JSON.parse(metaText) as ItemMetadata;

if (metadata.deleted) continue;

if (metadata.type !== "DocumentType" && metadata.type !== "CollectionType") {
continue;
}

items.push({
id: entry.id,
hash: entry.hash,
visibleName: metadata.visibleName,
lastModified: metadata.lastModified,
parent: metadata.parent,
pinned: metadata.pinned,
type: metadata.type,
fileEntries,
});
} catch (err) {
console.warn(`${TAG} Failed to read entry ${entry.id}:`, err);
}
}


return items;
}

get isRegistered(): boolean { return !!this.deviceToken; }
get isAuthenticated(): boolean { return !!this.userToken; }

Expand Down
Loading