Extract library from plugin

This commit is contained in:
Andras Schmelczer 2025-02-18 22:32:41 +00:00
commit ae3acb9e1e
No known key found for this signature in database
GPG key ID: FC8F2C3D3D1A718C
37 changed files with 61 additions and 77 deletions

View file

View file

@ -1,92 +0,0 @@
# Obsidian Sample Plugin
This is a sample plugin for Obsidian (https://obsidian.md).
This project uses TypeScript to provide type checking and documentation.
The repo depends on the latest plugin API (obsidian.d.ts) in TypeScript Definition format, which contains TSDoc comments describing what it does.
**Note:** The Obsidian API is still in early alpha and is subject to change at any time!
This sample plugin demonstrates some of the basic functionality the plugin API can do.
- Adds a ribbon icon, which shows a Notice when clicked.
- Adds a command "Open Sample Modal" which opens a Modal.
- Adds a plugin setting tab to the settings page.
- Registers a global click event and output 'click' to the console.
- Registers a global interval which logs 'setInterval' to the console.
## First time developing plugins?
Quick starting guide for new plugin devs:
- Check if [someone already developed a plugin for what you want](https://obsidian.md/plugins)! There might be an existing plugin similar enough that you can partner up with.
- Make a copy of this repo as a template with the "Use this template" button (login to GitHub if you don't see it).
- Clone your repo to a local development folder. For convenience, you can place this folder in your `.obsidian/plugins/your-plugin-name` folder.
- Install NodeJS, then run `npm i` in the command line under your repo folder.
- Run `npm run dev` to compile your plugin from `main.ts` to `main.js`.
- Make changes to `main.ts` (or create new `.ts` files). Those changes should be automatically compiled into `main.js`.
- Reload Obsidian to load the new version of your plugin.
- Enable plugin in settings window.
- For updates to the Obsidian API run `npm update` in the command line under your repo folder.
## Releasing new releases
- Update your `manifest.json` with your new version number, such as `1.0.1`, and the minimum Obsidian version required for your latest release.
- Update your `versions.json` file with `"new-plugin-version": "minimum-obsidian-version"` so older versions of Obsidian can download an older version of your plugin that's compatible.
- Create new GitHub release using your new version number as the "Tag version". Use the exact version number, don't include a prefix `v`. See here for an example: https://github.com/obsidianmd/obsidian-sample-plugin/releases
- Upload the files `manifest.json`, `main.js`, `styles.css` as binary attachments. Note: The manifest.json file must be in two places, first the root path of your repository and also in the release.
- Publish the release.
> You can simplify the version bump process by running `npm version patch`, `npm version minor` or `npm version major` after updating `minAppVersion` manually in `manifest.json`.
> The command will bump version in `manifest.json` and `package.json`, and add the entry for the new version to `versions.json`
## Adding your plugin to the community plugin list
- Check the [plugin guidelines](https://docs.obsidian.md/Plugins/Releasing/Plugin+guidelines).
- Publish an initial version.
- Make sure you have a `README.md` file in the root of your repo.
- Make a pull request at https://github.com/obsidianmd/obsidian-releases to add your plugin.
## How to use
- Clone this repo.
- Make sure your NodeJS is at least v16 (`node --version`).
- `npm i` or `yarn` to install dependencies.
- `npm run dev` to start compilation in watch mode.
## Manually installing the plugin
- Copy over `main.js`, `styles.css`, `manifest.json` to your vault `VaultFolder/.obsidian/plugins/your-plugin-id/`.
## Funding URL
You can include funding URLs where people who use your plugin can financially support it.
The simple way is to set the `fundingUrl` field to your link in your `manifest.json` file:
```json
{
"fundingUrl": "https://buymeacoffee.com"
}
```
If you have multiple URLs, you can also do:
```json
{
"fundingUrl": {
"Buy Me a Coffee": "https://buymeacoffee.com",
"GitHub Sponsor": "https://github.com/sponsors",
"Patreon": "https://www.patreon.com/"
}
}
```
## API Documentation
See https://github.com/obsidianmd/obsidian-api

View file

@ -1,3 +0,0 @@
module.exports = {
preset: "ts-jest/presets/js-with-babel-esm"
};

View file

@ -1,10 +0,0 @@
{
"id": "vault-link",
"name": "VaultLink",
"version": "0.0.30",
"minAppVersion": "0.0.0",
"description": "Self-hosted synchronization and collaboration for your Vault.",
"author": "Andras Schmelczer",
"authorUrl": "https://schmelczer.dev",
"isDesktopOnly": false
}

8430
plugin/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,58 +0,0 @@
{
"name": "vault-link-obsidian-plugin",
"version": "0.0.30",
"description": "This is a sample plugin for Obsidian (https://obsidian.md)",
"main": "main.js",
"scripts": {
"dev": "webpack watch --mode development",
"build": "webpack --mode production",
"test": "NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules\" jest",
"lint": "eslint --fix src && prettier --write \"src/**/*.(ts|scss|json|html)\"",
"version": "node version-bump.mjs"
},
"keywords": [],
"author": "",
"license": "MIT",
"prettier": {
"trailingComma": "none",
"tabWidth": 4,
"useTabs": true,
"endOfLine": "lf"
},
"devDependencies": {
"@types/jest": "^29.5.14",
"@types/node": "^16.11.6",
"builtin-modules": "3.3.0",
"byte-base64": "^1.1.0",
"css-loader": "^7.1.2",
"date-fns": "^4.1.0",
"dayjs": "^1.11.13",
"esbuild": "0.24.0",
"esbuild-plugin-wasm-pack": "^1.1.0",
"esbuild-sass-plugin": "^3.3.1",
"eslint": "9.17.0",
"eslint-plugin-unused-imports": "^4.1.4",
"fetch-retry": "^6.0.0",
"file-loader": "^6.2.0",
"fs-extra": "^11.2.0",
"virtual-scroller": "^1.13.1",
"jest": "^29.7.0",
"mini-css-extract-plugin": "^2.9.2",
"obsidian": "1.7.2",
"openapi-fetch": "0.13.3",
"openapi-typescript": "7.4.4",
"p-queue": "^8.0.1",
"prettier": "^3.4.2",
"resolve-url-loader": "^5.0.0",
"sass-loader": "^16.0.4",
"sync_lib": "file:../backend/sync_lib/pkg",
"terser-webpack-plugin": "^5.3.11",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.1",
"tslib": "2.4.0",
"typescript": "5.7.2",
"typescript-eslint": "8.18.0",
"webpack": "^5.97.1",
"webpack-cli": "^6.0.1"
}
}

View file

@ -1,186 +0,0 @@
import type { SyncSettings } from "./sync-settings";
import { DEFAULT_SETTINGS } from "./sync-settings";
import type {
DocumentId,
DocumentMetadata,
RelativePath,
VaultUpdateId
} from "./document-metadata";
import { Logger } from "src/tracing/logger";
interface StoredDatabase {
documents: Map<RelativePath, DocumentMetadata>;
settings: SyncSettings;
lastSeenUpdateId: VaultUpdateId | undefined;
}
// Todo: split it into settings and documents
export class Database {
private _documents = new Map<RelativePath, DocumentMetadata>();
private _settings: SyncSettings;
private _lastSeenUpdateId: VaultUpdateId | undefined;
private readonly onSettingsChangeHandlers: ((
newSettings: SyncSettings,
oldSettings: SyncSettings
) => void)[] = [];
public constructor(
initialState: Partial<StoredDatabase> | undefined,
private readonly saveData: (data: unknown) => Promise<void>
) {
initialState ??= {};
if (
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
Object.prototype.hasOwnProperty.call(initialState, "documents") &&
initialState.documents
) {
for (const [relativePath, metadata] of Object.entries(
initialState.documents
)) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
this._documents.set(relativePath, metadata as DocumentMetadata);
}
}
Logger.getInstance().debug(`Loaded ${this._documents.size} documents`);
this._settings = {
...DEFAULT_SETTINGS,
...(initialState.settings ?? {})
};
Logger.getInstance().debug(
`Loaded settings: ${JSON.stringify(this._settings, null, 2)}`
);
this._lastSeenUpdateId = initialState.lastSeenUpdateId;
Logger.getInstance().debug(
`Loaded last seen update id: ${this._lastSeenUpdateId}`
);
}
public getDocuments(): Map<RelativePath, DocumentMetadata> {
return this._documents;
}
public getSettings(): SyncSettings {
return this._settings;
}
public async setSettings(value: SyncSettings): Promise<void> {
const oldSettings = this._settings;
this._settings = value;
this.onSettingsChangeHandlers.forEach((handler) => {
handler(value, oldSettings);
});
await this.save();
}
public addOnSettingsChangeHandlers(
handler: (settings: SyncSettings, oldSettings: SyncSettings) => void
): void {
this.onSettingsChangeHandlers.push(handler);
}
public async setSetting<T extends keyof SyncSettings>(
key: T,
value: SyncSettings[T]
): Promise<void> {
const newSettings = { ...this._settings, [key]: value };
Logger.getInstance().debug(
`Setting ${key} to ${value}, new settings: ${JSON.stringify(
newSettings,
null,
2
)}`
);
await this.setSettings(newSettings);
}
public getLastSeenUpdateId(): VaultUpdateId | undefined {
return this._lastSeenUpdateId;
}
public async setLastSeenUpdateId(
value: VaultUpdateId | undefined
): Promise<void> {
this._lastSeenUpdateId = value;
await this.save();
}
public async resetSyncState(): Promise<void> {
this._documents = new Map();
this._lastSeenUpdateId = 0;
await this.save();
}
public getDocumentByDocumentId(
documentId: DocumentId
): [RelativePath, DocumentMetadata] | undefined {
return [...this._documents.entries()].find(
([_, metadata]) => metadata.documentId === documentId
);
}
public async setDocument({
documentId,
relativePath,
parentVersionId,
hash
}: {
documentId: DocumentId;
relativePath: RelativePath;
parentVersionId: VaultUpdateId;
hash: string;
}): Promise<void> {
this._documents.set(relativePath, {
documentId,
parentVersionId,
hash
});
await this.save();
}
public async moveDocument({
documentId,
oldRelativePath,
relativePath,
parentVersionId,
hash
}: {
documentId: DocumentId;
oldRelativePath: RelativePath;
relativePath: RelativePath;
parentVersionId: VaultUpdateId;
hash: string;
}): Promise<void> {
this._documents.delete(oldRelativePath);
this._documents.set(relativePath, {
documentId,
parentVersionId,
hash
});
await this.save();
}
public async removeDocument(relativePath: RelativePath): Promise<void> {
this._documents.delete(relativePath);
await this.save();
}
public getDocument(
relativePath: RelativePath
): DocumentMetadata | undefined {
return this._documents.get(relativePath);
}
private async save(): Promise<void> {
await this.saveData({
documents: Object.fromEntries(this._documents.entries()),
settings: this._settings,
lastSeenUpdateId: this._lastSeenUpdateId
});
}
}

View file

@ -1,9 +0,0 @@
export type VaultUpdateId = number;
export type DocumentId = string;
export type RelativePath = string;
export interface DocumentMetadata {
parentVersionId: VaultUpdateId;
documentId: DocumentId;
hash: string;
}

View file

@ -1,25 +0,0 @@
import { LogLevel } from "src/tracing/logger";
export interface SyncSettings {
remoteUri: string;
token: string;
vaultName: string;
fetchChangesUpdateIntervalMs: number;
syncConcurrency: number;
isSyncEnabled: boolean;
displayNoopSyncEvents: boolean;
minimumLogLevel: LogLevel;
maxFileSizeMB: number;
}
export const DEFAULT_SETTINGS: SyncSettings = {
remoteUri: "",
token: "",
vaultName: "default",
fetchChangesUpdateIntervalMs: 1000,
syncConcurrency: 1,
isSyncEnabled: false,
displayNoopSyncEvents: false,
minimumLogLevel: LogLevel.INFO,
maxFileSizeMB: 10
};

View file

@ -1,69 +0,0 @@
import type { TAbstractFile } from "obsidian";
import { TFile } from "obsidian";
import type { FileEventHandler } from "./file-event-handler";
import { Logger } from "src/tracing/logger";
import type { Syncer } from "src/sync-operations/syncer";
export class ObsidianFileEventHandler implements FileEventHandler {
public constructor(private readonly syncer: Syncer) {}
public async onCreate(file: TAbstractFile): Promise<void> {
if (file instanceof TFile) {
Logger.getInstance().info(`File created: ${file.path}`);
await this.syncer.syncLocallyCreatedFile(
file.path,
new Date(file.stat.ctime)
);
} else {
Logger.getInstance().debug(`Folder created: ${file.path}, ignored`);
}
}
public async onDelete(file: TAbstractFile): Promise<void> {
if (file instanceof TFile) {
Logger.getInstance().info(`File deleted: ${file.path}`);
await this.syncer.syncLocallyDeletedFile(file.path);
} else {
Logger.getInstance().debug(`Folder deleted: ${file.path}, ignored`);
}
}
public async onRename(file: TAbstractFile, oldPath: string): Promise<void> {
if (file instanceof TFile) {
Logger.getInstance().info(
`File renamed: ${oldPath} -> ${file.path}`
);
await this.syncer.syncLocallyUpdatedFile({
oldPath,
relativePath: file.path,
updateTime: new Date(file.stat.ctime)
});
} else {
Logger.getInstance().debug(
`Folder renamed: ${oldPath} -> ${file.path}, ignored`
);
}
}
public async onModify(file: TAbstractFile): Promise<void> {
if (file instanceof TFile) {
if (file.basename.startsWith("console-log.iPhone")) {
return;
}
Logger.getInstance().info(`File modified: ${file.path}`);
await this.syncer.syncLocallyUpdatedFile({
relativePath: file.path,
updateTime: new Date(file.stat.ctime)
});
} else {
Logger.getInstance().debug(
`Folder modified: ${file.path}, ignored`
);
}
}
}

View file

@ -1,32 +0,0 @@
import type { RelativePath } from "src/database/document-metadata";
export interface FileOperations {
listAllFiles: () => Promise<RelativePath[]>;
read: (path: RelativePath) => Promise<Uint8Array>;
getFileSize: (path: RelativePath) => Promise<number>;
exists: (path: RelativePath) => Promise<boolean>;
getModificationTime: (path: RelativePath) => Promise<Date>;
// Create and write the file if it doesn't exist. Otherwise, it has the same behavior as write.
// All parent directories are created if they don't exist.
create: (path: RelativePath, newContent: Uint8Array) => Promise<void>;
// Update the file at the given path.
// If the file's content is different from `expectedContent`, the a 3-way merge is performed before writing.
// If the file no longer exists, the file is not recreated and an empty array is returned.
write: (
path: RelativePath,
expectedContent: Uint8Array,
newContent: Uint8Array
) => Promise<Uint8Array>;
remove: (path: RelativePath) => Promise<void>;
move: (oldPath: RelativePath, newPath: RelativePath) => Promise<void>;
isFileEligibleForSync: (path: RelativePath) => boolean;
}

View file

@ -1,167 +0,0 @@
import type { Stat, Vault } from "obsidian";
import { normalizePath } from "obsidian";
import type { FileOperations } from "./file-operations";
import type { RelativePath } from "src/database/document-metadata";
import { isFileTypeMergable, mergeText } from "sync_lib";
import { Platform } from "obsidian";
import { Logger } from "src/tracing/logger";
export class ObsidianFileOperations implements FileOperations {
public constructor(private readonly vault: Vault) {}
public async listAllFiles(): Promise<RelativePath[]> {
const files = this.vault.getFiles();
Logger.getInstance().debug(`Listing all files, found ${files.length}`);
return files.map((file) => file.path);
}
public async read(path: RelativePath): Promise<Uint8Array> {
Logger.getInstance().debug(`Reading file: ${path}`);
if (isFileTypeMergable(path)) {
let text = await this.vault.adapter.read(normalizePath(path));
text = text.replace(/\r\n/g, "\n");
return new TextEncoder().encode(text);
}
return new Uint8Array(
await this.vault.adapter.readBinary(normalizePath(path))
);
}
public async getFileSize(path: RelativePath): Promise<number> {
Logger.getInstance().debug(`Getting file size: ${path}`);
return (await this.statFile(path)).size;
}
public async getModificationTime(path: RelativePath): Promise<Date> {
Logger.getInstance().debug(`Getting modification time: ${path}`);
return new Date((await this.statFile(path)).mtime);
}
public async exists(path: RelativePath): Promise<boolean> {
Logger.getInstance().debug(`Checking existance of ${path}`);
return this.vault.adapter.exists(normalizePath(path));
}
public async create(
path: RelativePath,
newContent: Uint8Array
): Promise<void> {
Logger.getInstance().debug(`Creating file: ${path}`);
if (await this.vault.adapter.exists(normalizePath(path))) {
Logger.getInstance().debug(
`Didn't expect ${path} to exist, when trying to create it, merging instead`
);
await this.write(path, new Uint8Array(0), newContent);
return;
}
await this.createParentDirectories(normalizePath(path));
await this.vault.adapter.writeBinary(normalizePath(path), newContent);
}
public async write(
path: RelativePath,
expectedContent: Uint8Array,
newContent: Uint8Array
): Promise<Uint8Array> {
Logger.getInstance().debug(`Writing file: ${path}`);
if (!(await this.vault.adapter.exists(normalizePath(path)))) {
Logger.getInstance().debug(
`The caller assumed ${path} exists, but it no longer, so we wont recreate it`
);
return new Uint8Array(0);
}
if (!isFileTypeMergable(path)) {
Logger.getInstance().debug(
`The expected content is not mergable, so we won't perform a 3-way merge, just overwrite it`
);
await this.vault.adapter.writeBinary(
normalizePath(path),
newContent
);
return newContent;
}
const expetedText = new TextDecoder().decode(expectedContent);
const newText = new TextDecoder().decode(newContent);
const resultText = await this.vault.adapter.process(
normalizePath(path),
(currentText) => {
currentText = currentText.replace(/\r\n/g, "\n");
if (currentText !== expetedText) {
Logger.getInstance().debug(
`Performing a 3-way merge for ${path} with the expected content`
);
return mergeText(expetedText, currentText, newText);
}
Logger.getInstance().debug(
`The current content of ${path} is the same as the expected content, so we will just write the new content`
);
return newText;
}
);
return new TextEncoder().encode(resultText);
}
public async remove(path: RelativePath): Promise<void> {
Logger.getInstance().debug(`Removing file: ${path}`);
if (await this.vault.adapter.exists(normalizePath(path))) {
await this.vault.adapter.trashSystem(normalizePath(path));
}
}
public async move(
oldPath: RelativePath,
newPath: RelativePath
): Promise<void> {
oldPath = normalizePath(oldPath);
newPath = normalizePath(newPath);
Logger.getInstance().debug(`Moving file: ${oldPath} -> ${newPath}`);
if (oldPath === newPath) {
return;
}
await this.createParentDirectories(newPath);
await this.vault.adapter.rename(oldPath, newPath);
}
public isFileEligibleForSync(path: RelativePath): boolean {
if (Platform.isDesktopApp) {
return true;
}
return isFileTypeMergable(path);
}
private async statFile(path: string): Promise<Stat> {
const file = await this.vault.adapter.stat(normalizePath(path));
if (!file) {
throw new Error(`File not found: ${path}`);
}
return file;
}
private async createParentDirectories(path: string): Promise<void> {
const components = path.split("/");
if (components.length === 1) {
return;
}
for (let i = 1; i < components.length; i++) {
const parentDir = components.slice(0, i).join("/");
if (!(await this.vault.adapter.exists(parentDir))) {
await this.vault.adapter.mkdir(parentDir);
}
}
}
}

View file

@ -1,293 +0,0 @@
import type { Client } from "openapi-fetch";
import createClient from "openapi-fetch";
import type { components, paths } from "./types"; // Generated by openapi-typescript
import type { Database } from "src/database/database";
import type { SyncSettings } from "src/database/sync-settings";
import type {
DocumentId,
RelativePath,
VaultUpdateId
} from "src/database/document-metadata";
import { Logger } from "src/tracing/logger";
import { retriedFetch } from "src/utils/retried-fetch";
export interface CheckConnectionResult {
isSuccessful: boolean;
message: string;
}
export class SyncService {
private client: Client<paths>;
private clientWithoutRetries: Client<paths>;
public constructor(private readonly database: Database) {
this.createClient(database.getSettings());
database.addOnSettingsChangeHandlers((s) => {
this.createClient(s);
});
}
private static formatError(
error: components["schemas"]["SerializedError"]
): string {
let result = error.message;
if (error.causes.length > 0) {
const causes = error.causes.join(", ");
result += ` caused by: ${causes}`;
}
return result;
}
public async ping(): Promise<components["schemas"]["PingResponse"]> {
const response = await this.clientWithoutRetries.GET("/ping", {
params: {
header: {
authorization: `Bearer ${this.database.getSettings().token}`
}
}
});
Logger.getInstance().debug(
`Ping response: ${JSON.stringify(response.data)}`
);
if (!response.data) {
throw new Error(
`Failed to ping server: ${SyncService.formatError(response.error)}`
);
}
return response.data;
}
public async create({
relativePath,
contentBytes,
createdDate
}: {
relativePath: RelativePath;
contentBytes: Uint8Array;
createdDate: Date;
}): Promise<components["schemas"]["DocumentUpdateResponse"]> {
const formData = new FormData();
formData.append("relative_path", relativePath);
formData.append("created_date", createdDate.toISOString());
formData.append("content", new Blob([contentBytes]));
const response = await this.client.POST(
"/vaults/{vault_id}/documents",
{
params: {
path: {
vault_id: this.database.getSettings().vaultName
},
header: {
authorization: `Bearer ${this.database.getSettings().token}`
}
},
// eslint-disable-next-line
body: formData as any // FormData is not supported by openapi-fetch
}
);
if (!response.data) {
throw new Error(
`Failed to create document: ${SyncService.formatError(response.error)}`
);
}
Logger.getInstance().debug(
`Created document ${JSON.stringify(
response.data
)} with id ${response.data.documentId}`
);
return response.data;
}
public async put({
parentVersionId,
documentId,
relativePath,
contentBytes,
createdDate
}: {
parentVersionId: VaultUpdateId;
documentId: DocumentId;
relativePath: RelativePath;
contentBytes: Uint8Array;
createdDate: Date;
}): Promise<components["schemas"]["DocumentUpdateResponse"]> {
const formData = new FormData();
formData.append("parent_version_id", parentVersionId.toString());
formData.append("created_date", createdDate.toISOString());
formData.append("relative_path", relativePath);
formData.append("content", new Blob([contentBytes]));
const response = await this.client.PUT(
"/vaults/{vault_id}/documents/{document_id}",
{
params: {
path: {
vault_id: this.database.getSettings().vaultName,
document_id: documentId
},
header: {
authorization: `Bearer ${this.database.getSettings().token}`
}
},
// eslint-disable-next-line
body: formData as any // FormData is not supported by openapi-fetch
}
);
if (!response.data) {
throw new Error(
`Failed to update document: ${SyncService.formatError(response.error)}`
);
}
Logger.getInstance().debug(
`Updated document ${JSON.stringify(response.data)} with id ${response.data.documentId}`
);
return response.data;
}
public async delete({
documentId,
relativePath,
createdDate
}: {
documentId: DocumentId;
relativePath: RelativePath;
createdDate: Date;
}): Promise<void> {
const response = await this.client.DELETE(
"/vaults/{vault_id}/documents/{document_id}",
{
params: {
path: {
vault_id: this.database.getSettings().vaultName,
document_id: documentId
},
header: {
authorization: `Bearer ${this.database.getSettings().token}`
}
},
body: {
createdDate: createdDate.toISOString(),
relativePath
}
}
);
if (response.error) {
throw new Error(`Failed to delete document`);
}
Logger.getInstance().debug(
`Deleted document ${relativePath} with id ${documentId}`
);
return response.data;
}
public async get({
documentId
}: {
documentId: DocumentId;
}): Promise<components["schemas"]["DocumentVersion"]> {
const response = await this.client.GET(
"/vaults/{vault_id}/documents/{document_id}",
{
params: {
path: {
vault_id: this.database.getSettings().vaultName,
document_id: documentId
},
header: {
authorization: `Bearer ${this.database.getSettings().token}`
}
}
}
);
if (!response.data) {
throw new Error(
`Failed to get document: ${SyncService.formatError(response.error)}`
);
}
Logger.getInstance().debug(
`Get document ${response.data.relativePath} with id ${response.data.documentId}`
);
return response.data;
}
public async getAll(
since?: VaultUpdateId
): Promise<components["schemas"]["FetchLatestDocumentsResponse"]> {
const response = await this.client.GET("/vaults/{vault_id}/documents", {
params: {
path: {
vault_id: this.database.getSettings().vaultName
},
header: {
authorization: `Bearer ${this.database.getSettings().token}`
},
query: {
since_update_id: since
}
}
});
const { error } = response;
if (error) {
throw new Error(
`Failed to get documents: ${SyncService.formatError(response.error)}`
);
}
Logger.getInstance().debug(
`Got ${response.data.latestDocuments.length} document metadata`
);
return response.data;
}
public async checkConnection(): Promise<CheckConnectionResult> {
try {
const result = await this.ping();
if (result.isAuthenticated) {
return {
isSuccessful: true,
message: `Successfully connected to server (version: ${result.serverVersion}) and authenticated.`
};
}
return {
isSuccessful: false,
message: `Successfully connected to server (version: ${result.serverVersion}) but failed to authenticate.`
};
} catch (e) {
return {
isSuccessful: false,
message: `Failed to connect to server: ${e}`
};
}
}
private createClient(settings: SyncSettings): void {
this.client = createClient<paths>({
baseUrl: settings.remoteUri,
fetch: retriedFetch
});
this.clientWithoutRetries = createClient<paths>({
baseUrl: settings.remoteUri
});
}
}

View file

@ -1,612 +0,0 @@
/**
* This file was auto-generated by openapi-typescript.
* Do not make direct changes to the file.
*/
export interface paths {
"/ping": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: {
parameters: {
query?: never;
header?: {
authorization?: string;
};
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PingResponse"];
};
};
default: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SerializedError"];
};
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/vaults/{vault_id}/documents": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: {
parameters: {
query?: {
since_update_id?: number | null;
};
header: {
authorization: string;
};
path: {
vault_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["FetchLatestDocumentsResponse"];
};
};
default: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SerializedError"];
};
};
};
};
put?: never;
post: {
parameters: {
query?: never;
header: {
authorization: string;
};
path: {
vault_id: string;
};
cookie?: never;
};
requestBody: {
content: {
"multipart/form-data": components["schemas"]["CreateDocumentVersionMultipart"];
};
};
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["DocumentUpdateResponse"];
};
};
default: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SerializedError"];
};
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/vaults/{vault_id}/documents/json": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: {
parameters: {
query?: never;
header: {
authorization: string;
};
path: {
vault_id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["CreateDocumentVersion"];
};
};
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["DocumentUpdateResponse"];
};
};
default: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SerializedError"];
};
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/vaults/{vault_id}/documents/{document_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: {
parameters: {
query?: never;
header: {
authorization: string;
};
path: {
document_id: string;
vault_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["DocumentVersion"];
};
};
default: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SerializedError"];
};
};
};
};
put: {
parameters: {
query?: never;
header: {
authorization: string;
};
path: {
document_id: string;
vault_id: string;
};
cookie?: never;
};
requestBody: {
content: {
"multipart/form-data": components["schemas"]["UpdateDocumentVersionMultipart"];
};
};
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["DocumentUpdateResponse"];
};
};
default: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SerializedError"];
};
};
};
};
post?: never;
delete: {
parameters: {
query?: never;
header: {
authorization: string;
};
path: {
document_id: string;
vault_id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["DeleteDocumentVersion"];
};
};
responses: {
/** @description no content */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
default: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SerializedError"];
};
};
};
};
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/vaults/{vault_id}/documents/{document_id}/json": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put: {
parameters: {
query?: never;
header: {
authorization: string;
};
path: {
document_id: string;
vault_id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["UpdateDocumentVersion"];
};
};
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["DocumentUpdateResponse"];
};
};
default: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SerializedError"];
};
};
};
};
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/vaults/{vault_id}/documents/{document_id}/versions/{version_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put: {
parameters: {
query?: never;
header: {
authorization: string;
};
path: {
document_id: string;
vault_id: string;
vault_update_id: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["DocumentVersion"];
};
};
default: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SerializedError"];
};
};
};
};
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/vaults/{vault_id}/documents/{document_id}/versions/{version_id}/content": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put: {
parameters: {
query?: never;
header: {
authorization: string;
};
path: {
document_id: string;
vault_id: string;
vault_update_id: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description byte stream */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/octet-stream": unknown;
};
};
default: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SerializedError"];
};
};
};
};
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
}
export type webhooks = Record<string, never>;
export interface components {
schemas: {
Array_of_uint8: number[];
CreateDocumentVersion: {
contentBase64: string;
/** Format: date-time */
createdDate: string;
relativePath: string;
};
CreateDocumentVersionMultipart: {
content: components["schemas"]["Array_of_uint8"];
/** Format: date-time */
created_date: string;
relative_path: string;
};
DeleteDocumentVersion: {
/** Format: date-time */
createdDate: string;
relativePath: string;
};
/** @description Response to a create/update document request. */
DocumentUpdateResponse:
| {
/** Format: date-time */
createdDate: string;
/** Format: uuid */
documentId: string;
isDeleted: boolean;
relativePath: string;
/** @enum {string} */
type: "FastForwardUpdate";
/** Format: date-time */
updatedDate: string;
vaultId: string;
/** Format: int64 */
vaultUpdateId: number;
}
| {
contentBase64: string;
/** Format: date-time */
createdDate: string;
/** Format: uuid */
documentId: string;
isDeleted: boolean;
relativePath: string;
/** @enum {string} */
type: "MergingUpdate";
/** Format: date-time */
updatedDate: string;
vaultId: string;
/** Format: int64 */
vaultUpdateId: number;
};
DocumentVersion: {
contentBase64: string;
/** Format: date-time */
createdDate: string;
/** Format: uuid */
documentId: string;
isDeleted: boolean;
relativePath: string;
/** Format: date-time */
updatedDate: string;
vaultId: string;
/** Format: int64 */
vaultUpdateId: number;
};
DocumentVersionWithoutContent: {
/** Format: date-time */
createdDate: string;
/** Format: uuid */
documentId: string;
isDeleted: boolean;
relativePath: string;
/** Format: date-time */
updatedDate: string;
vaultId: string;
/** Format: int64 */
vaultUpdateId: number;
};
/** @description Response to a fetch latest documents request. */
FetchLatestDocumentsResponse: {
/**
* Format: int64
* @description The update ID of the latest document in the response.
*/
lastUpdateId: number;
latestDocuments: components["schemas"]["DocumentVersionWithoutContent"][];
};
PathParams: {
vault_id: string;
};
PathParams2: {
vault_id: string;
};
PathParams3: {
/** Format: uuid */
document_id: string;
vault_id: string;
};
PathParams4: {
/** Format: uuid */
document_id: string;
vault_id: string;
};
PathParams5: {
/** Format: uuid */
document_id: string;
vault_id: string;
/** Format: int64 */
vault_update_id: number;
};
PathParams6: {
/** Format: uuid */
document_id: string;
vault_id: string;
/** Format: int64 */
vault_update_id: number;
};
PathParams7: {
/** Format: uuid */
document_id: string;
vault_id: string;
};
/** @description Response to a ping request. */
PingResponse: {
/** @description Whether the client is authenticated based on the sent Authorization header. */
isAuthenticated: boolean;
/** @description Semantic version of the server. */
serverVersion: string;
};
QueryParams: {
/** Format: int64 */
since_update_id?: number | null;
};
SerializedError: {
causes: string[];
message: string;
};
UpdateDocumentVersion: {
contentBase64: string;
/** Format: date-time */
createdDate: string;
/** Format: int64 */
parentVersionId: number;
relativePath: string;
};
UpdateDocumentVersionMultipart: {
content: components["schemas"]["Array_of_uint8"];
/** Format: date-time */
createdDate: string;
/** Format: int64 */
parentVersionId: number;
relativePath: string;
};
};
responses: never;
parameters: never;
requestBodies: never;
headers: never;
pathItems: never;
}
export type $defs = Record<string, never>;
export type operations = Record<string, never>;

View file

@ -1,179 +0,0 @@
@mixin number-card {
padding: var(--size-2-1) var(--size-4-1);
border-radius: var(--radius-s);
background-color: var(--color-base-30);
font-size: var(--font-ui-small);
&.good {
background-color: rgba(var(--color-green-rgb), 0.35);
}
&.bad {
background-color: rgba(var(--color-red-rgb), 0.35);
}
}
.status-description {
margin: var(--p-spacing) 0;
.number {
@include number-card;
font-family: var(--font-monospace);
font-weight: var(--bold-weight);
}
.error {
color: rgb(var(--color-red-rgb));
}
.warning {
color: rgb(var(--color-yellow-rgb));
}
}
.vault-link-settings {
h2 {
display: flex;
align-items: center;
font-size: var(--h2-size);
.version {
@include number-card;
margin: var(--size-2-2) 0 0 var(--size-4-2);
background-color: var(--color-base-30);
color: var(--color-base-70);
font-size: var(--font-ui-smaller);
}
}
.button-container {
display: flex;
gap: var(--size-4-2);
}
h3 {
font-size: var(--font-ui-large);
margin-top: var(--heading-spacing);
}
button,
input[type="range"],
.checkbox-container,
.slider::-webkit-slider-thumb {
cursor: pointer;
}
textarea {
resize: none;
width: 100%;
height: 60px;
}
}
.sync-status {
display: flex;
gap: var(--size-4-2);
* {
display: block;
}
.initialize-button {
padding: 0 var(--size-4-2);
background: rgba(var(--color-red-rgb), 0.4);
cursor: pointer;
}
}
.logs-view {
display: flex;
flex-direction: column;
.logs-container {
max-width: 100%;
overflow-y: auto;
.log-message {
font: var(--font-monospace);
margin-bottom: var(--size-2-1);
overflow-wrap: break-word;
white-space: pre-wrap;
.timestamp {
@include number-card;
font-family: var(--font-monospace);
font-weight: var(--bold-weight);
margin-right: var(--size-4-1);
}
&.DEBUG {
color: var(--color-base-50);
}
&.INFO {
color: var(--color-green-rgb);
}
&.WARNING {
color: var(--color-yellow-rgb);
}
&.ERROR {
color: var(--color-red-rgb);
}
}
}
}
.history-card {
padding: var(--size-4-4);
margin: var(--size-4-2);
background-color: var(--color-base-00);
border-radius: var(--radius-l);
container-type: inline-size;
&.clickable {
cursor: pointer;
}
&.success {
background-color: rgba(var(--color-green-rgb), 0.2);
}
&.error {
background-color: rgba(var(--color-red-rgb), 0.2);
}
.history-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--size-4-2);
gap: var(--size-4-2);
@container (max-width: 300px) {
flex-direction: column;
align-items: flex-start;
}
.history-card-title {
font: var(--font-monospace);
display: flex;
gap: var(--size-4-2);
word-break: break-all;
margin: 0;
}
.history-card-timestamp {
font-size: var(--font-ui-small);
font-style: italic;
color: var(--italic-color);
}
}
.history-card-message {
font-size: var(--font-ui-medium);
color: var(--color-base-70);
margin: 0;
}
}

View file

@ -1,61 +0,0 @@
import type { Database } from "src/database/database";
import type { SyncService } from "src/services/sync-service";
import { Logger } from "src/tracing/logger";
import type { Syncer } from "./syncer";
let isRunning = false;
export async function applyRemoteChangesLocally({
database,
syncService,
syncer
}: {
database: Database;
syncService: SyncService;
syncer: Syncer;
}): Promise<void> {
if (!database.getSettings().isSyncEnabled) {
Logger.getInstance().debug(
`Syncing is disabled, not fetching remote changes`
);
return;
} else if (isRunning) {
Logger.getInstance().debug(
"Applying remote changes locally is already in progress, skipping invocation"
);
return;
}
isRunning = true;
try {
const remote = await syncService.getAll(database.getLastSeenUpdateId());
if (remote.latestDocuments.length === 0) {
Logger.getInstance().debug("No remote changes to apply");
return;
}
Logger.getInstance().info("Applying remote changes locally");
await Promise.all(
remote.latestDocuments.map(async (remoteDocument) =>
syncer.syncRemotelyUpdatedFile(remoteDocument)
)
);
const lastSeenUpdateId = database.getLastSeenUpdateId();
if (
lastSeenUpdateId === undefined ||
remote.lastUpdateId > lastSeenUpdateId
) {
await database.setLastSeenUpdateId(remote.lastUpdateId);
}
} catch (e) {
Logger.getInstance().error(
`Failed to apply remote changes locally: ${e}`
);
} finally {
isRunning = false;
}
}

View file

@ -1,79 +0,0 @@
import {
tryLockDocument,
waitForDocumentLock,
unlockDocument
} from "./document-lock";
import type { RelativePath } from "src/database/document-metadata";
describe("Document Lock Operations", () => {
const testPath: RelativePath = "test/document/path";
beforeEach(() => {
// Reset the state before each test
(global as any).locked = new Set<RelativePath>();
(global as any).waiters = new Map<RelativePath, (() => void)[]>();
});
test("should lock a document successfully", () => {
const result = tryLockDocument(testPath);
expect(result).toBe(true);
});
test("should not lock a document that is already locked", () => {
tryLockDocument(testPath);
const result = tryLockDocument(testPath);
expect(result).toBe(false);
});
test("should unlock a locked document", () => {
tryLockDocument(testPath);
unlockDocument(testPath);
const result = tryLockDocument(testPath);
expect(result).toBe(true);
unlockDocument(testPath);
});
test("should throw an error when unlocking a document that is not locked", () => {
expect(() => {
unlockDocument(testPath);
}).toThrow(`Document ${testPath} is not locked, cannot unlock`);
});
test("should wait for a document lock and resolve when unlocked", async () => {
tryLockDocument(testPath);
let resolved = false;
const waitPromise = waitForDocumentLock(testPath).then(() => {
resolved = true;
});
unlockDocument(testPath);
await waitPromise;
expect(resolved).toBe(true);
});
test("should resolve multiple waiters in FIFO order", async () => {
tryLockDocument(testPath);
let firstResolved = false;
let secondResolved = false;
const firstWaitPromise = waitForDocumentLock(testPath).then(() => {
firstResolved = true;
});
const secondWaitPromise = waitForDocumentLock(testPath).then(() => {
secondResolved = true;
});
unlockDocument(testPath);
await firstWaitPromise;
expect(firstResolved).toBe(true);
expect(secondResolved).toBe(false);
unlockDocument(testPath);
await secondWaitPromise;
expect(secondResolved).toBe(true);
});
});

View file

@ -1,48 +0,0 @@
import type { RelativePath } from "src/database/document-metadata";
const locked = new Set<RelativePath>();
const waiters = new Map<RelativePath, (() => void)[]>();
export function tryLockDocument(relativePath: RelativePath): boolean {
if (locked.has(relativePath)) {
return false;
}
locked.add(relativePath);
return true;
}
export async function waitForDocumentLock(
relativePath: RelativePath
): Promise<void> {
if (tryLockDocument(relativePath)) {
return Promise.resolve();
}
return new Promise((resolve) => {
let waiting = waiters.get(relativePath);
if (!waiting) {
waiting = [];
waiters.set(relativePath, waiting);
}
waiting.push(resolve);
});
}
export function unlockDocument(relativePath: RelativePath): void {
if (!locked.has(relativePath)) {
throw new Error(
`Document ${relativePath} is not locked, cannot unlock`
);
}
// Remove the first element to ensure FIFO unblocking order
const nextWaiting = waiters.get(relativePath)?.shift();
if (nextWaiting) {
nextWaiting();
} else {
locked.delete(relativePath);
}
}

View file

@ -1,101 +0,0 @@
import { Notice } from "obsidian";
export enum LogLevel {
DEBUG = "DEBUG",
INFO = "INFO",
WARNING = "WARNING",
ERROR = "ERROR"
}
const LOG_LEVEL_ORDER = {
[LogLevel.DEBUG]: 0,
[LogLevel.INFO]: 1,
[LogLevel.WARNING]: 2,
[LogLevel.ERROR]: 3
};
class LogLine {
public timestamp = new Date();
public constructor(
public level: LogLevel,
public message: string
) {}
}
export class Logger {
private static readonly MAX_MESSAGES = 1000;
private static instance: Logger | null = null;
private readonly messages: LogLine[] = [];
private readonly onMessageListeners: ((
status: LogLine | undefined
) => void)[] = [];
private constructor() {} // eslint-disable-line @typescript-eslint/no-empty-function
public static getInstance(): Logger {
if (!Logger.instance) {
Logger.instance = new Logger();
}
return Logger.instance;
}
public debug(message: string): void {
console.debug(message);
this.pushMessage(message, LogLevel.DEBUG);
}
public info(message: string): void {
console.info(message);
this.pushMessage(message, LogLevel.INFO);
}
public warn(message: string): void {
console.warn(message);
this.pushMessage(message, LogLevel.WARNING);
}
public error(message: string): void {
console.error(message);
this.pushMessage(message, LogLevel.ERROR);
new Notice(message, 5000);
}
public getMessages(mininumSeverity: LogLevel): LogLine[] {
return this.messages.filter(
(message) =>
LOG_LEVEL_ORDER[message.level] >=
LOG_LEVEL_ORDER[mininumSeverity]
);
}
public addOnMessageListener(
listener: (message: LogLine | undefined) => void
): void {
this.onMessageListeners.push(listener);
}
public reset(): void {
this.messages.length = 0;
this.onMessageListeners.forEach((listener) => {
listener(undefined);
});
}
private pushMessage(message: string, level: LogLevel): void {
const logLine = new LogLine(level, message);
this.messages.push(logLine);
while (this.messages.length > Logger.MAX_MESSAGES) {
this.messages.shift();
}
this.onMessageListeners.forEach((listener) => {
listener(logLine);
});
}
}

View file

@ -1,103 +0,0 @@
import type { RelativePath } from "src/database/document-metadata";
import { Logger } from "./logger";
export interface CommonHistoryEntry {
status: SyncStatus;
relativePath: RelativePath;
message: string;
type?: SyncType;
source?: SyncSource;
}
export enum SyncType {
CREATE = "CREATE",
UPDATE = "UPDATE",
DELETE = "DELETE"
}
export enum SyncSource {
PUSH = "PUSH",
PULL = "PULL"
}
export enum SyncStatus {
NO_OP = "NO_OP",
SUCCESS = "SUCCESS",
ERROR = "ERROR"
}
export type HistoryEntry = CommonHistoryEntry & { timestamp: Date };
export interface HistoryStats {
success: number;
error: number;
}
export class SyncHistory {
private static readonly MAX_ENTRIES = 5000;
private readonly entries: HistoryEntry[] = [];
private readonly syncHistoryUpdateListeners: ((
status: HistoryStats
) => void)[] = [];
private status: HistoryStats = {
success: 0,
error: 0
};
public getEntries(): HistoryEntry[] {
return [...this.entries];
}
public reset(): void {
this.entries.length = 0;
this.status = {
success: 0,
error: 0
};
this.syncHistoryUpdateListeners.forEach((listener) => {
listener(this.status);
});
}
public addSyncHistoryUpdateListener(
listener: (stats: HistoryStats) => void
): void {
this.syncHistoryUpdateListeners.push(listener);
listener({ ...this.status });
}
public addHistoryEntry(entry: CommonHistoryEntry): void {
const historyEntry = {
...entry,
timestamp: new Date()
};
this.entries.push(historyEntry);
if (entry.status === SyncStatus.SUCCESS) {
this.status.success++;
Logger.getInstance().info(
`History entry: ${entry.relativePath} - ${entry.message}`
);
} else if (entry.status === SyncStatus.ERROR) {
this.status.error++;
Logger.getInstance().error(
`Error syncing file: ${entry.relativePath} - ${entry.message}`
);
} else {
Logger.getInstance().debug(
`No-op syncing file: ${entry.relativePath} - ${entry.message}`
);
}
this.syncHistoryUpdateListeners.forEach((listener) => {
listener(this.status);
});
if (this.entries.length > SyncHistory.MAX_ENTRIES) {
this.entries.shift();
}
}
}

Some files were not shown because too many files have changed in this diff Show more