mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-05 13:52:40 +02:00
feat: deactivate legacy Obsidian connectors and implement partial unique index for improved upsert handling
This commit is contained in:
parent
4d3406341d
commit
2d90ed0fec
8 changed files with 683 additions and 145 deletions
|
|
@ -1,11 +1,14 @@
|
|||
import { Notice, requestUrl, type RequestUrlParam, type RequestUrlResponse } from "obsidian";
|
||||
import type {
|
||||
ConnectResponse,
|
||||
DeleteAck,
|
||||
HealthResponse,
|
||||
ManifestResponse,
|
||||
NotePayload,
|
||||
RenameAck,
|
||||
RenameItem,
|
||||
SearchSpace,
|
||||
SyncAck,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
|
|
@ -119,26 +122,31 @@ export class SurfSenseApiClient {
|
|||
);
|
||||
}
|
||||
|
||||
/** POST /sync — `failed[]` are paths whose `status === "error"` for retry. */
|
||||
async syncBatch(input: {
|
||||
vaultId: string;
|
||||
notes: NotePayload[];
|
||||
}): Promise<{ accepted: number; rejected: string[] }> {
|
||||
const resp = await this.request<{ accepted?: number; rejected?: string[] }>(
|
||||
}): Promise<{ indexed: number; failed: string[] }> {
|
||||
const resp = await this.request<SyncAck>(
|
||||
"POST",
|
||||
"/api/v1/obsidian/sync",
|
||||
{ vault_id: input.vaultId, notes: input.notes }
|
||||
);
|
||||
return {
|
||||
accepted: typeof resp.accepted === "number" ? resp.accepted : input.notes.length,
|
||||
rejected: Array.isArray(resp.rejected) ? resp.rejected : [],
|
||||
};
|
||||
const failed = resp.items
|
||||
.filter((it) => it.status === "error")
|
||||
.map((it) => it.path);
|
||||
return { indexed: resp.indexed, failed };
|
||||
}
|
||||
|
||||
/** POST /rename — `"missing"` counts as success; only `"error"` is retried. */
|
||||
async renameBatch(input: {
|
||||
vaultId: string;
|
||||
renames: Pick<RenameItem, "oldPath" | "newPath">[];
|
||||
}): Promise<{ renamed: number }> {
|
||||
const resp = await this.request<{ renamed?: number }>(
|
||||
}): Promise<{
|
||||
renamed: number;
|
||||
failed: Array<{ oldPath: string; newPath: string }>;
|
||||
}> {
|
||||
const resp = await this.request<RenameAck>(
|
||||
"POST",
|
||||
"/api/v1/obsidian/rename",
|
||||
{
|
||||
|
|
@ -149,19 +157,26 @@ export class SurfSenseApiClient {
|
|||
})),
|
||||
}
|
||||
);
|
||||
return { renamed: typeof resp.renamed === "number" ? resp.renamed : 0 };
|
||||
const failed = resp.items
|
||||
.filter((it) => it.status === "error")
|
||||
.map((it) => ({ oldPath: it.old_path, newPath: it.new_path }));
|
||||
return { renamed: resp.renamed, failed };
|
||||
}
|
||||
|
||||
/** DELETE /notes — `"missing"` counts as success; only `"error"` is retried. */
|
||||
async deleteBatch(input: {
|
||||
vaultId: string;
|
||||
paths: string[];
|
||||
}): Promise<{ deleted: number }> {
|
||||
const resp = await this.request<{ deleted?: number }>(
|
||||
}): Promise<{ deleted: number; failed: string[] }> {
|
||||
const resp = await this.request<DeleteAck>(
|
||||
"DELETE",
|
||||
"/api/v1/obsidian/notes",
|
||||
{ vault_id: input.vaultId, paths: input.paths }
|
||||
);
|
||||
return { deleted: typeof resp.deleted === "number" ? resp.deleted : 0 };
|
||||
const failed = resp.items
|
||||
.filter((it) => it.status === "error")
|
||||
.map((it) => it.path);
|
||||
return { deleted: resp.deleted, failed };
|
||||
}
|
||||
|
||||
async getManifest(vaultId: string): Promise<ManifestResponse> {
|
||||
|
|
@ -225,11 +240,16 @@ export class SurfSenseApiClient {
|
|||
}
|
||||
|
||||
function parseJson<T>(resp: RequestUrlResponse): T {
|
||||
if (resp.text === undefined || resp.text === "") return undefined as unknown as T;
|
||||
// Plugin endpoints always return JSON; non-JSON 2xx is usually a
|
||||
// captive portal or CDN page — surface as transient so we back off.
|
||||
const text = resp.text ?? "";
|
||||
try {
|
||||
return JSON.parse(resp.text) as T;
|
||||
return JSON.parse(text) as T;
|
||||
} catch {
|
||||
return undefined as unknown as T;
|
||||
throw new TransientError(
|
||||
resp.status,
|
||||
`Invalid JSON from server (got: ${text.slice(0, 80)})`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -246,13 +246,20 @@ export class SyncEngine {
|
|||
const dropped: QueueItem[] = [];
|
||||
|
||||
// Renames first so paths line up server-side before content upserts.
|
||||
// Per-item server errors go to retry; "missing" is treated as success.
|
||||
if (renames.length > 0) {
|
||||
try {
|
||||
await this.deps.apiClient.renameBatch({
|
||||
const resp = await this.deps.apiClient.renameBatch({
|
||||
vaultId: settings.vaultId,
|
||||
renames: renames.map((r) => ({ oldPath: r.oldPath, newPath: r.newPath })),
|
||||
});
|
||||
acked.push(...renames);
|
||||
const failed = new Set(
|
||||
resp.failed.map((f) => `${f.oldPath}\u0000${f.newPath}`),
|
||||
);
|
||||
for (const r of renames) {
|
||||
if (failed.has(`${r.oldPath}\u0000${r.newPath}`)) retry.push(r);
|
||||
else acked.push(r);
|
||||
}
|
||||
} catch (err) {
|
||||
if (await this.handleVaultNotRegistered(err)) {
|
||||
retry.push(...renames);
|
||||
|
|
@ -267,11 +274,15 @@ export class SyncEngine {
|
|||
|
||||
if (deletes.length > 0) {
|
||||
try {
|
||||
await this.deps.apiClient.deleteBatch({
|
||||
const resp = await this.deps.apiClient.deleteBatch({
|
||||
vaultId: settings.vaultId,
|
||||
paths: deletes.map((d) => d.path),
|
||||
});
|
||||
acked.push(...deletes);
|
||||
const failed = new Set(resp.failed);
|
||||
for (const d of deletes) {
|
||||
if (failed.has(d.path)) retry.push(d);
|
||||
else acked.push(d);
|
||||
}
|
||||
} catch (err) {
|
||||
if (await this.handleVaultNotRegistered(err)) {
|
||||
retry.push(...deletes);
|
||||
|
|
@ -310,10 +321,11 @@ export class SyncEngine {
|
|||
vaultId: settings.vaultId,
|
||||
notes: payloads,
|
||||
});
|
||||
const rejected = new Set(resp.rejected ?? []);
|
||||
// Per-note failures retry; the queue's maxAttempts eventually drops poison pills.
|
||||
const failed = new Set(resp.failed);
|
||||
for (const item of upserts) {
|
||||
if (retry.find((r) => r === item)) continue;
|
||||
if (rejected.has(item.path)) dropped.push(item);
|
||||
if (failed.has(item.path)) retry.push(item);
|
||||
else acked.push(item);
|
||||
}
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -129,6 +129,49 @@ export interface ManifestResponse {
|
|||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** Per-item ack shapes — mirror `app/schemas/obsidian_plugin.py` 1:1. */
|
||||
export interface SyncAckItem {
|
||||
path: string;
|
||||
status: "ok" | "error";
|
||||
document_id?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface SyncAck {
|
||||
vault_id: string;
|
||||
indexed: number;
|
||||
failed: number;
|
||||
items: SyncAckItem[];
|
||||
}
|
||||
|
||||
export interface RenameAckItem {
|
||||
old_path: string;
|
||||
new_path: string;
|
||||
status: "ok" | "error" | "missing";
|
||||
document_id?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface RenameAck {
|
||||
vault_id: string;
|
||||
renamed: number;
|
||||
missing: number;
|
||||
items: RenameAckItem[];
|
||||
}
|
||||
|
||||
export interface DeleteAckItem {
|
||||
path: string;
|
||||
status: "ok" | "error" | "missing";
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface DeleteAck {
|
||||
vault_id: string;
|
||||
deleted: number;
|
||||
missing: number;
|
||||
items: DeleteAckItem[];
|
||||
}
|
||||
|
||||
export type StatusKind =
|
||||
| "idle"
|
||||
| "syncing"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue