mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-11-03 23:08:18 +00:00
Merge completed fixes before shared runtime integration
This commit is contained in:
commit
9222852834
9 files changed
+233
-34
No files matched your search
@@ -87,8 +87,8 @@ livesync-cli [database-path] [command] [args...]
|
||||
- `rebuild`: Rebuild the local and remote databases from the selected Vault. This replaces existing local and remote database state and requires `--force`.
|
||||
- The selected local database suffix is saved automatically, so later commands use the reconstructed database.
|
||||
- `fetch`: Replace the local database with the configured remote state, then reflect fetched files to the selected Vault. Existing local database state is discarded, so this command requires `--force`.
|
||||
- Differing local files can remain and be preserved as conflict branches alongside the fetched content. For recovery from an intact remote, preserve the existing Vault separately and Fetch into an empty Vault; `--force` does not force file overwriting.
|
||||
- The selected local database suffix is saved automatically, including when `--write-settings` is omitted.
|
||||
- Differing local files can remain and be preserved as conflict branches alongside the fetched content. For recovery from an intact remote, preserve the existing Vault separately and Fetch into an empty Vault; `--force` does not force file overwriting.
|
||||
- `ls [prefix]`: List files currently stored in the local database.
|
||||
- `push <src> <dst>`: Push a local file `<src>` into the database at path `<dst>`.
|
||||
- `pull <src> <dst>`: Pull a file `<src>` from the database into local file `<dst>`.
|
||||
|
||||
@@ -16,6 +16,43 @@ declare module "obsidian" {
|
||||
}
|
||||
}
|
||||
|
||||
const cacheReuseDirectives = new Set(["max-age", "max-stale", "min-fresh", "only-if-cached"]);
|
||||
|
||||
function splitHeaderDirectives(value: string): string[] {
|
||||
const directives: string[] = [];
|
||||
let start = 0;
|
||||
let inQuotes = false;
|
||||
let escaped = false;
|
||||
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
const character = value[index];
|
||||
if (inQuotes && escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (inQuotes && character === "\\") {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (character === '"') {
|
||||
inQuotes = !inQuotes;
|
||||
} else if (character === "," && !inQuotes) {
|
||||
const directive = value.slice(start, index).trim();
|
||||
if (directive) directives.push(directive);
|
||||
start = index + 1;
|
||||
}
|
||||
}
|
||||
|
||||
const lastDirective = value.slice(start).trim();
|
||||
if (lastDirective) directives.push(lastDirective);
|
||||
return directives;
|
||||
}
|
||||
|
||||
function getDirectiveName(directive: string): string {
|
||||
const equalsIndex = directive.indexOf("=");
|
||||
return (equalsIndex === -1 ? directive : directive.slice(0, equalsIndex)).trim().toLowerCase();
|
||||
}
|
||||
|
||||
export class ObsidianAPIService extends InjectableAPIService<ObsidianServiceContext> {
|
||||
_customHandler: ObsHttpHandler | undefined;
|
||||
_confirmInstance: Confirm;
|
||||
@@ -182,6 +219,30 @@ export class ObsidianAPIService extends InjectableAPIService<ObsidianServiceCont
|
||||
delete transformedHeaders["Host"];
|
||||
delete transformedHeaders["content-length"];
|
||||
delete transformedHeaders["Content-Length"];
|
||||
const cacheMode = opts?.cache ?? (req instanceof Request ? req.cache : undefined);
|
||||
if (cacheMode === "no-store" && ["GET", "HEAD"].includes(method.toUpperCase())) {
|
||||
// requestUrl has no cache option; no-store alone can reuse an existing cached response.
|
||||
const cacheControlKeys = Object.keys(transformedHeaders).filter(
|
||||
(key) => key.toLowerCase() === "cache-control"
|
||||
);
|
||||
const cacheControlDirectives = cacheControlKeys
|
||||
.flatMap((key) => splitHeaderDirectives(transformedHeaders[key]))
|
||||
.filter((directive) => {
|
||||
const name = getDirectiveName(directive);
|
||||
return !cacheReuseDirectives.has(name) && name !== "no-cache" && name !== "no-store";
|
||||
});
|
||||
const cacheControlKey = cacheControlKeys[0] ?? "cache-control";
|
||||
for (const key of cacheControlKeys) delete transformedHeaders[key];
|
||||
transformedHeaders[cacheControlKey] = [...cacheControlDirectives, "no-cache", "no-store"].join(", ");
|
||||
|
||||
const pragmaKeys = Object.keys(transformedHeaders).filter((key) => key.toLowerCase() === "pragma");
|
||||
const pragmaDirectives = pragmaKeys
|
||||
.flatMap((key) => splitHeaderDirectives(transformedHeaders[key]))
|
||||
.filter((directive) => getDirectiveName(directive) !== "no-cache");
|
||||
const pragmaKey = pragmaKeys[0] ?? "pragma";
|
||||
for (const key of pragmaKeys) delete transformedHeaders[key];
|
||||
transformedHeaders[pragmaKey] = [...pragmaDirectives, "no-cache"].join(", ");
|
||||
}
|
||||
const contentType =
|
||||
transformedHeaders["content-type"] ?? transformedHeaders["Content-Type"] ?? "application/json";
|
||||
const requestParam: RequestUrlParam = {
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { RequestUrlParam } from "@/deps";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
requestUrl: vi.fn<
|
||||
(request: RequestUrlParam) => Promise<{
|
||||
status: number;
|
||||
headers: Record<string, string>;
|
||||
arrayBuffer: ArrayBuffer;
|
||||
}>
|
||||
>(),
|
||||
platform: {
|
||||
isMobile: false,
|
||||
},
|
||||
@@ -8,12 +16,12 @@ const mocks = vi.hoisted(() => ({
|
||||
|
||||
vi.mock("@/deps.ts", () => ({
|
||||
Platform: mocks.platform,
|
||||
requestUrl: vi.fn(),
|
||||
requestUrl: mocks.requestUrl,
|
||||
}));
|
||||
|
||||
vi.mock("@/deps", () => ({
|
||||
Platform: mocks.platform,
|
||||
requestUrl: vi.fn(),
|
||||
requestUrl: mocks.requestUrl,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/essentialObsidian/APILib/ObsHttpHandler", () => ({
|
||||
@@ -36,6 +44,8 @@ function createService(workspace: Record<string, unknown>, isMobile = false): Ob
|
||||
beforeEach(() => {
|
||||
mocks.platform.isMobile = false;
|
||||
vi.clearAllMocks();
|
||||
mocks.requestUrl.mockReset();
|
||||
mocks.requestUrl.mockResolvedValue({ status: 200, headers: {}, arrayBuffer: new ArrayBuffer(0) });
|
||||
});
|
||||
|
||||
describe("ObsidianAPIService.showWindowOnRight", () => {
|
||||
@@ -65,3 +75,115 @@ describe("ObsidianAPIService.showWindowOnRight", () => {
|
||||
expect(workspace.revealLeaf).toHaveBeenCalledWith(rightLeaf);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ObsidianAPIService.nativeFetch cache control", () => {
|
||||
const url = "https://couchdb.example.test/vault/f%3Ametadata";
|
||||
|
||||
function sentHeaders(): Headers {
|
||||
expect(mocks.requestUrl).toHaveBeenCalledOnce();
|
||||
return new Headers(mocks.requestUrl.mock.calls[0][0].headers);
|
||||
}
|
||||
|
||||
it.each(["GET", "HEAD", "get", "head"])("bypasses cached responses for an explicit no-store %s", async (method) => {
|
||||
const headers = {
|
||||
Authorization: "Basic fixture",
|
||||
"Cache-Control": "public, max-age=3600",
|
||||
"cAcHe-CoNtRoL": "max-stale",
|
||||
Pragma: "cache",
|
||||
};
|
||||
|
||||
await createService({}).nativeFetch(url, { method, cache: "no-store", headers });
|
||||
|
||||
expect(sentHeaders().get("cache-control")).toBe("public, no-cache, no-store");
|
||||
expect(sentHeaders().get("pragma")).toBe("cache, no-cache");
|
||||
expect(sentHeaders().get("authorization")).toBe("Basic fixture");
|
||||
expect(headers["Cache-Control"]).toBe("public, max-age=3600");
|
||||
expect(mocks.requestUrl.mock.calls[0][0]).toMatchObject({ url, method });
|
||||
});
|
||||
|
||||
it("preserves non-conflicting cache directives when enforcing no-store", async () => {
|
||||
const headers = {
|
||||
"Cache-Control": 'max-age=3600, no-transform, custom="a,b"',
|
||||
"cAcHe-CoNtRoL": 'max-stale=30, min-fresh=5, only-if-cached, custom-two="max-age=1, only-if-cached"',
|
||||
Pragma: 'legacy="a,b", no-cache',
|
||||
"X-Custom-Header": "preserved",
|
||||
};
|
||||
const originalHeaders = { ...headers };
|
||||
|
||||
await createService({}).nativeFetch(url, { cache: "no-store", headers });
|
||||
|
||||
const sent = mocks.requestUrl.mock.calls[0][0].headers ?? {};
|
||||
expect(sent["Cache-Control"]).toBe(
|
||||
'no-transform, custom="a,b", custom-two="max-age=1, only-if-cached", no-cache, no-store'
|
||||
);
|
||||
expect(sent["cAcHe-CoNtRoL"]).toBeUndefined();
|
||||
expect(sent.Pragma).toBe('legacy="a,b", no-cache');
|
||||
expect(sent["X-Custom-Header"]).toBe("preserved");
|
||||
expect(headers).toEqual(originalHeaders);
|
||||
});
|
||||
|
||||
it("uses the Request cache mode when the options do not override it", async () => {
|
||||
const request = new Request(url, {
|
||||
method: "HEAD",
|
||||
cache: "no-store",
|
||||
headers: { "X-Fixture": "request" },
|
||||
});
|
||||
const headers = new Headers({ "X-Other": "options" });
|
||||
|
||||
await createService({}).nativeFetch(request, { headers });
|
||||
|
||||
expect(sentHeaders().get("cache-control")).toBe("no-cache, no-store");
|
||||
expect(sentHeaders().get("pragma")).toBe("no-cache");
|
||||
expect(sentHeaders().get("x-fixture")).toBe("request");
|
||||
expect(sentHeaders().get("x-other")).toBe("options");
|
||||
expect(headers.has("cache-control")).toBe(false);
|
||||
expect(request.headers.has("cache-control")).toBe(false);
|
||||
});
|
||||
|
||||
it("lets the options request no-store for an otherwise cacheable Request", async () => {
|
||||
await createService({}).nativeFetch(new Request(url, { cache: "force-cache" }), { cache: "no-store" });
|
||||
|
||||
expect(sentHeaders().get("cache-control")).toBe("no-cache, no-store");
|
||||
});
|
||||
|
||||
it("lets an explicit default cache mode override a no-store Request", async () => {
|
||||
await createService({}).nativeFetch(new Request(url, { cache: "no-store" }), { cache: "default" });
|
||||
|
||||
expect(sentHeaders().has("cache-control")).toBe(false);
|
||||
expect(sentHeaders().has("pragma")).toBe(false);
|
||||
});
|
||||
|
||||
it.each([undefined, "default", "no-cache", "reload", "force-cache"] as const)(
|
||||
"preserves caller headers for cache mode %s",
|
||||
async (cache) => {
|
||||
const headers = { "Cache-Control": "max-age=60", Pragma: "fixture" };
|
||||
|
||||
await createService({}).nativeFetch(url, { cache, headers });
|
||||
|
||||
expect(mocks.requestUrl.mock.calls[0][0].headers).toEqual(headers);
|
||||
}
|
||||
);
|
||||
|
||||
it("does not add cache directives to an ordinary request", async () => {
|
||||
await createService({}).nativeFetch(url);
|
||||
|
||||
expect(sentHeaders().has("cache-control")).toBe(false);
|
||||
expect(sentHeaders().has("pragma")).toBe(false);
|
||||
});
|
||||
|
||||
it.each(["POST", "PUT", "DELETE"])("preserves a no-store %s request", async (method) => {
|
||||
const headers = { "Cache-Control": "no-transform" };
|
||||
|
||||
await createService({}).nativeFetch(url, { method, cache: "no-store", headers });
|
||||
|
||||
expect(mocks.requestUrl.mock.calls[0][0]).toMatchObject({ url, method, headers });
|
||||
expect(sentHeaders().has("pragma")).toBe(false);
|
||||
});
|
||||
|
||||
it("uses the effective method from the options", async () => {
|
||||
await createService({}).nativeFetch(new Request(url, { cache: "no-store" }), { method: "POST" });
|
||||
|
||||
expect(sentHeaders().has("cache-control")).toBe(false);
|
||||
expect(sentHeaders().has("pragma")).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user