Extract library from plugin
This commit is contained in:
parent
8374c971ee
commit
ae3acb9e1e
37 changed files with 61 additions and 77 deletions
|
|
@ -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
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
module.exports = {
|
||||
preset: "ts-jest/presets/js-with-babel-esm"
|
||||
};
|
||||
|
|
@ -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
8430
plugin/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
|
||||
};
|
||||
|
|
@ -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`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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>;
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
import init, { base64ToBytes } from "sync_lib";
|
||||
import fs from "fs";
|
||||
|
||||
describe("deserialize", () => {
|
||||
it("should serialize a Uint8Array to a base64 string", async () => {
|
||||
const wasmBin = fs.readFileSync(
|
||||
"../backend/sync_lib/pkg/sync_lib_bg.wasm"
|
||||
);
|
||||
await init({ module_or_path: wasmBin });
|
||||
|
||||
const base64 = "SGVsbG8=";
|
||||
const jsResult = base64ToBytes(base64);
|
||||
const expected = new Uint8Array([72, 101, 108, 108, 111]);
|
||||
expect(jsResult).toEqual(expected);
|
||||
const rustResult = base64ToBytes(base64);
|
||||
expect(jsResult).toEqual(rustResult);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
import { base64ToBytes } from "byte-base64";
|
||||
|
||||
export function deserialize(data: string): Uint8Array {
|
||||
return base64ToBytes(data);
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
// https://stackoverflow.com/questions/7616461/generate-a-hash-from-string-in-javascript
|
||||
export function hash(content: Uint8Array): string {
|
||||
let result = 0;
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-for-of
|
||||
for (let i = 0; i < content.length; i++) {
|
||||
result = (result << 5) - result + content[i];
|
||||
result |= 0; // Convert to 32bit integer
|
||||
}
|
||||
return Math.abs(result).toString(16);
|
||||
}
|
||||
|
||||
export const EMPTY_HASH = hash(new Uint8Array(0));
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
import { isEqualBytes } from "./is-equal-bytes";
|
||||
|
||||
describe("isEqualBytes", () => {
|
||||
it("should return true for equal byte arrays", () => {
|
||||
const bytes1 = new Uint8Array([1, 2, 3, 4]);
|
||||
const bytes2 = new Uint8Array([1, 2, 3, 4]);
|
||||
expect(isEqualBytes(bytes1, bytes2)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for byte arrays of different lengths", () => {
|
||||
const bytes1 = new Uint8Array([1, 2, 3, 4]);
|
||||
const bytes2 = new Uint8Array([1, 2, 3]);
|
||||
expect(isEqualBytes(bytes1, bytes2)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true for empty byte arrays", () => {
|
||||
const bytes1 = new Uint8Array([]);
|
||||
const bytes2 = new Uint8Array([]);
|
||||
expect(isEqualBytes(bytes1, bytes2)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for byte arrays with same length but different content", () => {
|
||||
const bytes1 = new Uint8Array([1, 2, 3, 4]);
|
||||
const bytes2 = new Uint8Array([4, 3, 2, 1]);
|
||||
expect(isEqualBytes(bytes1, bytes2)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
export function isEqualBytes(bytes1: Uint8Array, bytes2: Uint8Array): boolean {
|
||||
if (bytes1.length !== bytes2.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let i = 0; i < bytes1.length; i++) {
|
||||
if (bytes1[i] !== bytes2[i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
import * as fetchRetryFactory from "fetch-retry";
|
||||
import type { RequestInitRetryParams } from "fetch-retry";
|
||||
import { Logger } from "src/tracing/logger";
|
||||
|
||||
const fetchWithRetry = fetchRetryFactory.default(fetch);
|
||||
|
||||
function getUrlFromInput(input: RequestInfo | URL): string {
|
||||
if (input instanceof URL) {
|
||||
return input.href;
|
||||
}
|
||||
if (typeof input === "string") {
|
||||
return input;
|
||||
}
|
||||
return input.url;
|
||||
}
|
||||
|
||||
export async function retriedFetch(
|
||||
input: RequestInfo | URL,
|
||||
init: RequestInitRetryParams<typeof fetch> = {}
|
||||
): Promise<Response> {
|
||||
return fetchWithRetry(input, {
|
||||
retryOn: function (attempt, error, response) {
|
||||
if (error !== null || !response || response.status >= 500) {
|
||||
Logger.getInstance().warn(
|
||||
`Retrying fetch for ${getUrlFromInput(input)}, attempt ${attempt}`
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
retries: 6,
|
||||
retryDelay: (attempt) => Math.pow(1.5, attempt) * 500,
|
||||
...init
|
||||
});
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
import { serialize } from "./serialize";
|
||||
import init, { bytesToBase64 } from "sync_lib";
|
||||
import fs from "fs";
|
||||
|
||||
describe("serialize", () => {
|
||||
it("should serialize a Uint8Array to a base64 string", async () => {
|
||||
const wasmBin = fs.readFileSync(
|
||||
"../backend/sync_lib/pkg/sync_lib_bg.wasm"
|
||||
);
|
||||
await init({ module_or_path: wasmBin });
|
||||
|
||||
const data = new Uint8Array([72, 101, 108, 108, 111]);
|
||||
const jsResult = serialize(data);
|
||||
const rustResult = bytesToBase64(data);
|
||||
expect(rustResult).toBe("SGVsbG8=");
|
||||
expect(jsResult).toBe(rustResult);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
import { bytesToBase64 } from "byte-base64";
|
||||
|
||||
export function serialize(data: Uint8Array): string {
|
||||
return bytesToBase64(data);
|
||||
}
|
||||
|
|
@ -1,200 +0,0 @@
|
|||
import type { WorkspaceLeaf } from "obsidian";
|
||||
import { Plugin } from "obsidian";
|
||||
import "./styles.scss";
|
||||
import "../manifest.json";
|
||||
import init from "sync_lib";
|
||||
import wasmBin from "sync_lib/sync_lib_bg.wasm";
|
||||
import { SyncSettingsTab } from "./views/settings-tab";
|
||||
import { HistoryView } from "./views/history-view";
|
||||
import { ObsidianFileEventHandler } from "./events/obisidan-event-handler";
|
||||
import { SyncService } from "./services/sync-service";
|
||||
import { Database } from "./database/database";
|
||||
import { applyRemoteChangesLocally } from "./sync-operations/apply-remote-changes-locally";
|
||||
import { ObsidianFileOperations } from "./file-operations/obsidian-file-operations";
|
||||
import { StatusBar } from "./views/status-bar";
|
||||
import { Logger } from "./tracing/logger";
|
||||
import { SyncHistory } from "./tracing/sync-history";
|
||||
import { LogsView } from "./views/logs-view";
|
||||
import { Syncer } from "./sync-operations/syncer";
|
||||
import { StatusDescription } from "./views/status-description";
|
||||
|
||||
export default class VaultLinkPlugin extends Plugin {
|
||||
private readonly operations = new ObsidianFileOperations(this.app.vault);
|
||||
private readonly history = new SyncHistory();
|
||||
private settingsTab: SyncSettingsTab;
|
||||
private remoteListenerIntervalId: number | null = null;
|
||||
|
||||
public async onload(): Promise<void> {
|
||||
Logger.getInstance().info("Starting plugin");
|
||||
|
||||
await init(
|
||||
// eslint-disable-next-line
|
||||
(wasmBin as any).default // it is loaded as a base64 string by webpack
|
||||
);
|
||||
|
||||
const database = new Database(
|
||||
await this.loadData(),
|
||||
this.saveData.bind(this)
|
||||
);
|
||||
|
||||
const syncService = new SyncService(database);
|
||||
|
||||
const syncer = new Syncer(
|
||||
database,
|
||||
syncService,
|
||||
this.operations,
|
||||
this.history
|
||||
);
|
||||
|
||||
const statusDescription = new StatusDescription(
|
||||
database,
|
||||
syncService,
|
||||
this.history,
|
||||
syncer
|
||||
);
|
||||
|
||||
this.settingsTab = new SyncSettingsTab({
|
||||
app: this.app,
|
||||
plugin: this,
|
||||
database,
|
||||
syncService,
|
||||
statusDescription,
|
||||
syncer
|
||||
});
|
||||
this.addSettingTab(this.settingsTab);
|
||||
|
||||
new StatusBar(database, this, this.history, syncer);
|
||||
|
||||
this.registerView(
|
||||
HistoryView.TYPE,
|
||||
(leaf) => new HistoryView(leaf, database, this.history)
|
||||
);
|
||||
this.registerView(
|
||||
LogsView.TYPE,
|
||||
(leaf) => new LogsView(this, database, leaf)
|
||||
);
|
||||
|
||||
this.addRibbonIcon(
|
||||
HistoryView.ICON,
|
||||
"Open VaultLink events",
|
||||
async (_: MouseEvent) => this.activateView(HistoryView.TYPE)
|
||||
);
|
||||
this.addRibbonIcon(
|
||||
LogsView.ICON,
|
||||
"Open VaultLink logs",
|
||||
async (_: MouseEvent) => this.activateView(LogsView.TYPE)
|
||||
);
|
||||
|
||||
const eventHandler = new ObsidianFileEventHandler(syncer);
|
||||
|
||||
this.app.workspace.onLayoutReady(async () => {
|
||||
Logger.getInstance().info("Initialising sync handlers");
|
||||
|
||||
[
|
||||
this.app.vault.on(
|
||||
"create",
|
||||
eventHandler.onCreate.bind(eventHandler)
|
||||
),
|
||||
this.app.vault.on(
|
||||
"modify",
|
||||
eventHandler.onModify.bind(eventHandler)
|
||||
),
|
||||
this.app.vault.on(
|
||||
"delete",
|
||||
eventHandler.onDelete.bind(eventHandler)
|
||||
),
|
||||
this.app.vault.on(
|
||||
"rename",
|
||||
eventHandler.onRename.bind(eventHandler)
|
||||
)
|
||||
].forEach((event) => {
|
||||
this.registerEvent(event);
|
||||
});
|
||||
|
||||
Logger.getInstance().info("Sync handlers initialised");
|
||||
|
||||
void syncer.scheduleSyncForOfflineChanges();
|
||||
});
|
||||
|
||||
this.registerRemoteEventListener(
|
||||
database,
|
||||
syncService,
|
||||
syncer,
|
||||
database.getSettings().fetchChangesUpdateIntervalMs
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
database.addOnSettingsChangeHandlers(async (settings, oldSettings) => {
|
||||
this.registerRemoteEventListener(
|
||||
database,
|
||||
syncService,
|
||||
syncer,
|
||||
settings.fetchChangesUpdateIntervalMs
|
||||
);
|
||||
|
||||
if (!oldSettings.isSyncEnabled && settings.isSyncEnabled) {
|
||||
await syncer.scheduleSyncForOfflineChanges();
|
||||
}
|
||||
});
|
||||
|
||||
Logger.getInstance().info("Plugin loaded");
|
||||
}
|
||||
|
||||
public onunload(): void {
|
||||
if (this.remoteListenerIntervalId !== null) {
|
||||
window.clearInterval(this.remoteListenerIntervalId);
|
||||
}
|
||||
}
|
||||
|
||||
public openSettings(): void {
|
||||
// eslint-disable-next-line
|
||||
(this.app as any).setting.open(); // this is undocumented
|
||||
// eslint-disable-next-line
|
||||
(this.app as any).setting.openTab(this.settingsTab); // this is undocumented
|
||||
}
|
||||
|
||||
public closeSettings(): void {
|
||||
// eslint-disable-next-line
|
||||
(this.app as any).setting.close(); // this is undocumented
|
||||
}
|
||||
|
||||
public async activateView(type: string): Promise<void> {
|
||||
const { workspace } = this.app;
|
||||
|
||||
let leaf: WorkspaceLeaf | null = null;
|
||||
const leaves = workspace.getLeavesOfType(type);
|
||||
|
||||
if (leaves.length > 0) {
|
||||
[leaf] = leaves;
|
||||
} else {
|
||||
leaf = workspace.getRightLeaf(false);
|
||||
await leaf?.setViewState({ type: type, active: true });
|
||||
}
|
||||
|
||||
if (leaf) {
|
||||
await workspace.revealLeaf(leaf);
|
||||
}
|
||||
}
|
||||
|
||||
private registerRemoteEventListener(
|
||||
database: Database,
|
||||
syncService: SyncService,
|
||||
syncer: Syncer,
|
||||
intervalMs: number
|
||||
): void {
|
||||
if (this.remoteListenerIntervalId !== null) {
|
||||
window.clearInterval(this.remoteListenerIntervalId);
|
||||
}
|
||||
|
||||
this.remoteListenerIntervalId = window.setInterval(
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
async () =>
|
||||
applyRemoteChangesLocally({
|
||||
database,
|
||||
syncService,
|
||||
syncer
|
||||
}),
|
||||
intervalMs
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,164 +0,0 @@
|
|||
import type { IconName, WorkspaceLeaf } from "obsidian";
|
||||
import { ItemView, setIcon } from "obsidian";
|
||||
import type { HistoryEntry, SyncHistory } from "src/tracing/sync-history";
|
||||
import { SyncType } from "src/tracing/sync-history";
|
||||
import { SyncSource, SyncStatus } from "src/tracing/sync-history";
|
||||
import { intlFormatDistance } from "date-fns";
|
||||
import type { Database } from "src/database/database";
|
||||
|
||||
export class HistoryView extends ItemView {
|
||||
public static readonly TYPE = "history-view";
|
||||
public static readonly ICON = "square-stack";
|
||||
private timer: NodeJS.Timer | null = null;
|
||||
|
||||
public constructor(
|
||||
leaf: WorkspaceLeaf,
|
||||
private readonly database: Database,
|
||||
private readonly history: SyncHistory
|
||||
) {
|
||||
super(leaf);
|
||||
this.icon = HistoryView.ICON;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
history.addSyncHistoryUpdateListener(async () => {
|
||||
await this.updateView();
|
||||
});
|
||||
}
|
||||
|
||||
private static getSyncTypeIcon(type: SyncType | undefined): IconName {
|
||||
switch (type) {
|
||||
case SyncType.CREATE:
|
||||
return "file-plus";
|
||||
case SyncType.DELETE:
|
||||
return "trash-2";
|
||||
case SyncType.UPDATE:
|
||||
return "file-pen-line";
|
||||
case undefined:
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private static getSyncSourceIcon(source: SyncSource | undefined): IconName {
|
||||
switch (source) {
|
||||
case SyncSource.PUSH:
|
||||
return "upload";
|
||||
case SyncSource.PULL:
|
||||
return "download";
|
||||
case undefined:
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private static renderSyncItemTitle(
|
||||
element: HTMLElement,
|
||||
entry: HistoryEntry
|
||||
): void {
|
||||
const syncTypeIcon = HistoryView.getSyncTypeIcon(entry.type);
|
||||
if (syncTypeIcon) {
|
||||
setIcon(element.createDiv(), syncTypeIcon);
|
||||
}
|
||||
|
||||
element.createEl("span", {
|
||||
text: entry.relativePath
|
||||
});
|
||||
|
||||
const syncSourceIcon = HistoryView.getSyncSourceIcon(entry.source);
|
||||
if (syncSourceIcon) {
|
||||
setIcon(element.createDiv(), syncSourceIcon);
|
||||
}
|
||||
}
|
||||
|
||||
public getViewType(): string {
|
||||
return HistoryView.TYPE;
|
||||
}
|
||||
|
||||
public getDisplayText(): string {
|
||||
return "VaultLink history";
|
||||
}
|
||||
|
||||
public async onOpen(): Promise<void> {
|
||||
await this.updateView();
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
this.timer = setInterval(async () => this.updateView(), 1000);
|
||||
}
|
||||
|
||||
public async onClose(): Promise<void> {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
}
|
||||
}
|
||||
|
||||
private async updateView(): Promise<void> {
|
||||
const container = this.containerEl.children[1];
|
||||
container.empty();
|
||||
container.createEl("h4", { text: "VaultLink History" });
|
||||
|
||||
const entries = this.history
|
||||
.getEntries()
|
||||
.reverse()
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.status !== SyncStatus.NO_OP ||
|
||||
this.database.getSettings().displayNoopSyncEvents
|
||||
);
|
||||
|
||||
entries.forEach((entry) => {
|
||||
container.createDiv(
|
||||
{
|
||||
cls: ["history-card", entry.status.toLocaleLowerCase()]
|
||||
},
|
||||
(card) => {
|
||||
if (
|
||||
this.app.vault.getFileByPath(entry.relativePath) !==
|
||||
null
|
||||
) {
|
||||
card.addEventListener("click", () => {
|
||||
void this.app.workspace.openLinkText(
|
||||
entry.relativePath,
|
||||
entry.relativePath,
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
card.addClass("clickable");
|
||||
}
|
||||
|
||||
card.createDiv(
|
||||
{
|
||||
cls: "history-card-header"
|
||||
},
|
||||
(header) => {
|
||||
header.createEl(
|
||||
"h5",
|
||||
{
|
||||
cls: "history-card-title"
|
||||
},
|
||||
(title) => {
|
||||
HistoryView.renderSyncItemTitle(
|
||||
title,
|
||||
entry
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
header.createSpan({
|
||||
text: intlFormatDistance(
|
||||
entry.timestamp,
|
||||
new Date()
|
||||
),
|
||||
cls: "history-card-timestamp"
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
card.createEl("p", {
|
||||
text: `${entry.message}.`,
|
||||
cls: "history-card-message"
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,120 +0,0 @@
|
|||
import type { WorkspaceLeaf } from "obsidian";
|
||||
import { ItemView } from "obsidian";
|
||||
import type VaultLinkPlugin from "src/vault-link-plugin";
|
||||
import { Logger } from "src/tracing/logger";
|
||||
import type { Database } from "src/database/database";
|
||||
|
||||
export class LogsView extends ItemView {
|
||||
public static readonly TYPE = "logs-view";
|
||||
public static readonly ICON = "logs";
|
||||
|
||||
public constructor(
|
||||
private readonly plugin: VaultLinkPlugin,
|
||||
private readonly database: Database,
|
||||
leaf: WorkspaceLeaf
|
||||
) {
|
||||
super(leaf);
|
||||
this.icon = LogsView.ICON;
|
||||
Logger.getInstance().addOnMessageListener(() => {
|
||||
this.updateView();
|
||||
});
|
||||
|
||||
database.addOnSettingsChangeHandlers((newSettings, oldSettings) => {
|
||||
if (newSettings.minimumLogLevel !== oldSettings.minimumLogLevel) {
|
||||
this.updateView();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static formatTimestamp(timestamp: Date): string {
|
||||
return timestamp.toTimeString().split(" ")[0];
|
||||
}
|
||||
|
||||
public getViewType(): string {
|
||||
return LogsView.TYPE;
|
||||
}
|
||||
|
||||
public getDisplayText(): string {
|
||||
return "VaultLink logs";
|
||||
}
|
||||
|
||||
public async onOpen(): Promise<void> {
|
||||
this.updateView();
|
||||
|
||||
const container = this.containerEl.children[1];
|
||||
container.addClass("logs-view");
|
||||
}
|
||||
|
||||
private updateView(): void {
|
||||
const container = this.containerEl.children[1];
|
||||
|
||||
let logsContainer = container
|
||||
.getElementsByClassName("logs-container")
|
||||
.item(0);
|
||||
const scrollPosition = logsContainer?.scrollTop;
|
||||
|
||||
container.empty();
|
||||
|
||||
container.createEl("h4", { text: "VaultLink logs" });
|
||||
container.createEl(
|
||||
"p",
|
||||
{
|
||||
text: "This view displays logs generated by VaultLink. You can set the log level in the "
|
||||
},
|
||||
(p) => {
|
||||
p.createEl(
|
||||
"a",
|
||||
{
|
||||
text: "settings"
|
||||
},
|
||||
(button) => {
|
||||
button.addEventListener("click", () => {
|
||||
this.plugin.openSettings();
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
p.createSpan({ text: "." });
|
||||
}
|
||||
);
|
||||
|
||||
const logs = Logger.getInstance().getMessages(
|
||||
this.database.getSettings().minimumLogLevel
|
||||
);
|
||||
|
||||
if (logs.length === 0) {
|
||||
container.createEl("p", { text: "No logs available yet." });
|
||||
return;
|
||||
}
|
||||
|
||||
logsContainer = container.createDiv(
|
||||
{ cls: "logs-container" },
|
||||
(element) => {
|
||||
logs.forEach((message) =>
|
||||
element.createDiv(
|
||||
{
|
||||
cls: ["log-message", message.level]
|
||||
},
|
||||
(messageContainer) => {
|
||||
messageContainer.createEl("span", {
|
||||
text: LogsView.formatTimestamp(
|
||||
message.timestamp
|
||||
),
|
||||
cls: "timestamp"
|
||||
});
|
||||
messageContainer.createEl("span", {
|
||||
text: message.message
|
||||
});
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
if (scrollPosition !== undefined) {
|
||||
logsContainer.scrollTop = scrollPosition;
|
||||
} else {
|
||||
logsContainer.scrollTop = logsContainer.scrollHeight;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,344 +0,0 @@
|
|||
import type { App } from "obsidian";
|
||||
import { Notice, PluginSettingTab, Setting } from "obsidian";
|
||||
|
||||
import type VaultLinkPlugin from "src/vault-link-plugin";
|
||||
import type { Database } from "src/database/database";
|
||||
import type { SyncService } from "src/services/sync-service";
|
||||
import type { Syncer } from "src/sync-operations/syncer";
|
||||
import type { StatusDescription } from "./status-description";
|
||||
import { LogsView } from "./logs-view";
|
||||
import { HistoryView } from "./history-view";
|
||||
import { Logger, LogLevel } from "src/tracing/logger";
|
||||
|
||||
export class SyncSettingsTab extends PluginSettingTab {
|
||||
private editedVaultName: string;
|
||||
|
||||
private readonly plugin: VaultLinkPlugin;
|
||||
private readonly database: Database;
|
||||
private readonly syncService: SyncService;
|
||||
private readonly statusDescription: StatusDescription;
|
||||
private readonly syncer: Syncer;
|
||||
private statusDescriptionSubscription: (() => void) | undefined;
|
||||
|
||||
public constructor({
|
||||
app,
|
||||
plugin,
|
||||
database,
|
||||
syncService,
|
||||
statusDescription,
|
||||
syncer
|
||||
}: {
|
||||
app: App;
|
||||
plugin: VaultLinkPlugin;
|
||||
database: Database;
|
||||
syncService: SyncService;
|
||||
statusDescription: StatusDescription;
|
||||
syncer: Syncer;
|
||||
}) {
|
||||
super(app, plugin);
|
||||
this.plugin = plugin;
|
||||
this.database = database;
|
||||
this.syncService = syncService;
|
||||
this.statusDescription = statusDescription;
|
||||
this.syncer = syncer;
|
||||
|
||||
this.editedVaultName = this.database.getSettings().vaultName;
|
||||
this.database.addOnSettingsChangeHandlers(
|
||||
(newSettings, oldSettings) => {
|
||||
if (newSettings.vaultName !== oldSettings.vaultName) {
|
||||
this.editedVaultName = newSettings.vaultName;
|
||||
this.display();
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public display(): void {
|
||||
const { containerEl } = this;
|
||||
containerEl.empty();
|
||||
containerEl.addClass("vault-link-settings");
|
||||
|
||||
this.renderSettingsHeader(containerEl);
|
||||
this.renderConnectionSettings(containerEl);
|
||||
this.renderSyncSettings(containerEl);
|
||||
this.renderViewSettings(containerEl);
|
||||
}
|
||||
|
||||
public hide(): void {
|
||||
super.hide();
|
||||
this.setStatusDescriptionSubscription();
|
||||
}
|
||||
|
||||
private renderSettingsHeader(containerEl: HTMLElement): void {
|
||||
containerEl.createEl("h2", { text: "VaultLink" }).createSpan({
|
||||
text: this.plugin.manifest.version,
|
||||
cls: "version"
|
||||
});
|
||||
|
||||
containerEl.createDiv(
|
||||
{
|
||||
cls: "description"
|
||||
},
|
||||
(descriptionContainer) => {
|
||||
this.setStatusDescriptionSubscription((): void => {
|
||||
this.statusDescription.renderStatusDescription(
|
||||
descriptionContainer
|
||||
);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
containerEl.createDiv(
|
||||
{
|
||||
cls: "button-container"
|
||||
},
|
||||
(buttonContainer) => {
|
||||
buttonContainer.createEl(
|
||||
"button",
|
||||
{
|
||||
text: "Show history"
|
||||
},
|
||||
(button) =>
|
||||
(button.onclick = async (): Promise<void> => {
|
||||
this.plugin.closeSettings();
|
||||
await this.plugin.activateView(HistoryView.TYPE);
|
||||
})
|
||||
);
|
||||
|
||||
buttonContainer.createEl(
|
||||
"button",
|
||||
{
|
||||
text: "Show logs"
|
||||
},
|
||||
(button) =>
|
||||
(button.onclick = async (): Promise<void> => {
|
||||
this.plugin.closeSettings();
|
||||
await this.plugin.activateView(LogsView.TYPE);
|
||||
})
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private renderConnectionSettings(containerEl: HTMLElement): void {
|
||||
containerEl.createEl("h3", { text: "Connection" });
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Server address")
|
||||
.setDesc(
|
||||
"Your VaultLink server's URL including the protocol and full path."
|
||||
)
|
||||
.setTooltip("This is the URL of the server you want to sync with.")
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder("https://example.com:3030")
|
||||
.setValue(this.database.getSettings().remoteUri)
|
||||
.onChange(async (value) =>
|
||||
this.database.setSetting("remoteUri", value)
|
||||
)
|
||||
)
|
||||
.addButton((button) =>
|
||||
button.setButtonText("Test connection").onClick(async () => {
|
||||
new Notice(
|
||||
(await this.syncService.checkConnection()).message
|
||||
);
|
||||
await this.statusDescription.updateConnectionState();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Access token")
|
||||
.setClass("sync-settings-access-token")
|
||||
.setDesc(
|
||||
"Set the access token for the server that you can get from the server"
|
||||
)
|
||||
.setTooltip("todo, links to dcocs")
|
||||
.addTextArea((text) =>
|
||||
text
|
||||
.setPlaceholder("ey...")
|
||||
.setValue(this.database.getSettings().token)
|
||||
.onChange(async (value) =>
|
||||
this.database.setSetting("token", value)
|
||||
)
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Vault name")
|
||||
.setDesc(
|
||||
"Set the name of the remote vault that you want to sync with"
|
||||
)
|
||||
.setTooltip("todo, links to dcocs")
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder("My Obsidian Vault")
|
||||
.setValue(this.database.getSettings().vaultName)
|
||||
.onChange((value) => (this.editedVaultName = value))
|
||||
)
|
||||
.addButton((button) =>
|
||||
button.setButtonText("Apply").onClick(async () => {
|
||||
if (
|
||||
this.editedVaultName ===
|
||||
this.database.getSettings().vaultName
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await this.database.setSetting(
|
||||
"vaultName",
|
||||
this.editedVaultName
|
||||
);
|
||||
await this.syncer.reset();
|
||||
Logger.getInstance().reset();
|
||||
new Notice(
|
||||
"Sync state has been reset, you will need to resync"
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private renderSyncSettings(containerEl: HTMLElement): void {
|
||||
containerEl.createEl("h3", { text: "Sync" });
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Danger zone")
|
||||
.setDesc(
|
||||
"How many concurrent sync operations to run. Setting this value higher may increase the overall performance, however, it will require more memory as well. If you notice frequent crashes, especially on mobile, set this to 1."
|
||||
)
|
||||
.addButton((button) =>
|
||||
button.setButtonText("Reset sync state").onClick(async () => {
|
||||
await this.syncer.reset();
|
||||
Logger.getInstance().reset();
|
||||
new Notice(
|
||||
"Sync state has been reset, you will need to resync"
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Remote fetching frequency (seconds)")
|
||||
.setDesc(
|
||||
"Set how often should the plugin check for changes on the server. Lower values will increase the frequency of the checks making it easier to collaborate with others."
|
||||
)
|
||||
.setTooltip("todo, links to docs")
|
||||
.addSlider((text) =>
|
||||
text
|
||||
.setLimits(0.5, 60, 0.5)
|
||||
.setDynamicTooltip()
|
||||
.setInstant(false)
|
||||
.setValue(
|
||||
this.database.getSettings()
|
||||
.fetchChangesUpdateIntervalMs / 1000
|
||||
)
|
||||
.onChange(async (value) =>
|
||||
this.database.setSetting(
|
||||
"fetchChangesUpdateIntervalMs",
|
||||
value * 1000
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Sync concurrency")
|
||||
.setDesc(
|
||||
"How many concurrent sync operations to run. Setting this value higher may increase the overall performance, however, it will require more memory as well. If you notice frequent crashes, especially on mobile, set this to 1."
|
||||
)
|
||||
.addSlider((text) =>
|
||||
text
|
||||
.setLimits(1, 16, 1)
|
||||
.setDynamicTooltip()
|
||||
.setInstant(false)
|
||||
.setValue(this.database.getSettings().syncConcurrency)
|
||||
.onChange(async (value) =>
|
||||
this.database.setSetting("syncConcurrency", value)
|
||||
)
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Maximum file size to be uploaded (MB)")
|
||||
.setDesc(
|
||||
"Set the maximum file size that can be uploaded to the server. Files larger than this size will be ignored."
|
||||
)
|
||||
.addSlider((slider) =>
|
||||
slider
|
||||
.setLimits(0, 32, 1)
|
||||
.setDynamicTooltip()
|
||||
.setInstant(false)
|
||||
.setValue(this.database.getSettings().maxFileSizeMB)
|
||||
.onChange(async (value) =>
|
||||
this.database.setSetting("maxFileSizeMB", value)
|
||||
)
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Enable sync")
|
||||
.setDesc(
|
||||
"Enable pulling and pushing changes to the remote server. The first time it's enabled, or after the sync state has been reset, all local files will be pushed to the server."
|
||||
)
|
||||
.setTooltip(
|
||||
"Enable pulling and pushing changes to the remote server."
|
||||
)
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
.setValue(this.database.getSettings().isSyncEnabled)
|
||||
.onChange(async (value) =>
|
||||
this.database.setSetting("isSyncEnabled", value)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private renderViewSettings(containerEl: HTMLElement): void {
|
||||
containerEl.createEl("h3", { text: "View" });
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Show no-op sync operations in history")
|
||||
.setDesc(
|
||||
"Enabling this will make the history view more verbose while also providing more explanation for the scyning choices made."
|
||||
)
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
.setValue(this.database.getSettings().displayNoopSyncEvents)
|
||||
.onChange(async (value) =>
|
||||
this.database.setSetting("displayNoopSyncEvents", value)
|
||||
)
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Minimum log level")
|
||||
.setDesc(
|
||||
"Set the log level for the plugin. Lower levels will show more logs."
|
||||
)
|
||||
.addDropdown((dropdown) =>
|
||||
dropdown
|
||||
.addOptions({
|
||||
[LogLevel.DEBUG]: LogLevel.DEBUG,
|
||||
[LogLevel.INFO]: LogLevel.INFO,
|
||||
[LogLevel.WARNING]: LogLevel.WARNING,
|
||||
[LogLevel.ERROR]: LogLevel.ERROR
|
||||
})
|
||||
.setValue(this.database.getSettings().minimumLogLevel)
|
||||
.onChange(async (value) =>
|
||||
this.database.setSetting(
|
||||
"minimumLogLevel",
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
value as LogLevel
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private setStatusDescriptionSubscription(
|
||||
newSubscription?: () => void
|
||||
): void {
|
||||
if (this.statusDescriptionSubscription) {
|
||||
this.statusDescription.removeStatusChangeListener(
|
||||
this.statusDescriptionSubscription
|
||||
);
|
||||
}
|
||||
this.statusDescriptionSubscription = newSubscription;
|
||||
if (this.statusDescriptionSubscription) {
|
||||
this.statusDescriptionSubscription();
|
||||
this.statusDescription.addStatusChangeListener(
|
||||
this.statusDescriptionSubscription
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
import type { Database } from "src/database/database";
|
||||
import type VaultLinkPlugin from "src/vault-link-plugin";
|
||||
import type { Syncer } from "src/sync-operations/syncer";
|
||||
import type { HistoryStats, SyncHistory } from "src/tracing/sync-history";
|
||||
|
||||
export class StatusBar {
|
||||
private readonly statusBarItem: HTMLElement;
|
||||
|
||||
private lastHistoryStats: HistoryStats | undefined;
|
||||
private lastRemaining: number | undefined;
|
||||
|
||||
public constructor(
|
||||
private readonly database: Database,
|
||||
private readonly plugin: VaultLinkPlugin,
|
||||
history: SyncHistory,
|
||||
syncer: Syncer
|
||||
) {
|
||||
this.statusBarItem = plugin.addStatusBarItem();
|
||||
history.addSyncHistoryUpdateListener((status) => {
|
||||
this.lastHistoryStats = status;
|
||||
this.updateStatus();
|
||||
});
|
||||
|
||||
syncer.addRemainingOperationsListener((remainingOperations) => {
|
||||
this.lastRemaining = remainingOperations;
|
||||
this.updateStatus();
|
||||
});
|
||||
|
||||
database.addOnSettingsChangeHandlers(() => {
|
||||
this.updateStatus();
|
||||
});
|
||||
}
|
||||
|
||||
private updateStatus(): void {
|
||||
this.statusBarItem.empty();
|
||||
const container = this.statusBarItem.createDiv({
|
||||
cls: ["sync-status"]
|
||||
});
|
||||
|
||||
let hasShownMessage = false;
|
||||
|
||||
if ((this.lastRemaining ?? 0) > 0) {
|
||||
hasShownMessage = true;
|
||||
container.createSpan({ text: `${this.lastRemaining} ⏳` });
|
||||
}
|
||||
|
||||
if ((this.lastHistoryStats?.success ?? 0) > 0) {
|
||||
hasShownMessage = true;
|
||||
container.createSpan({
|
||||
text: `${this.lastHistoryStats?.success ?? 0} ✅`
|
||||
});
|
||||
}
|
||||
|
||||
if ((this.lastHistoryStats?.error ?? 0) > 0) {
|
||||
hasShownMessage = true;
|
||||
container.createSpan({
|
||||
text: `${this.lastHistoryStats?.error ?? 0} ❌`
|
||||
});
|
||||
}
|
||||
|
||||
if (!hasShownMessage) {
|
||||
if (this.database.getSettings().isSyncEnabled) {
|
||||
container.createSpan({ text: "VaultLink is idle" });
|
||||
} else {
|
||||
const button = container.createEl("button", {
|
||||
text: "VaultLink is disabled, click to configure",
|
||||
cls: "initialize-button"
|
||||
});
|
||||
button.onclick = (): void => {
|
||||
this.plugin.openSettings();
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,139 +0,0 @@
|
|||
import type { Database } from "src/database/database";
|
||||
import type {
|
||||
CheckConnectionResult,
|
||||
SyncService
|
||||
} from "src/services/sync-service";
|
||||
import type { Syncer } from "src/sync-operations/syncer";
|
||||
import type { HistoryStats, SyncHistory } from "src/tracing/sync-history";
|
||||
|
||||
export class StatusDescription {
|
||||
private lastHistoryStats: HistoryStats | undefined;
|
||||
private lastRemaining: number | undefined;
|
||||
private lastConnectionState: CheckConnectionResult | undefined;
|
||||
|
||||
private statusChangeListeners: (() => void)[] = [];
|
||||
|
||||
public constructor(
|
||||
private readonly database: Database,
|
||||
private readonly syncService: SyncService,
|
||||
history: SyncHistory,
|
||||
syncer: Syncer
|
||||
) {
|
||||
void this.updateConnectionState();
|
||||
|
||||
history.addSyncHistoryUpdateListener((status) => {
|
||||
this.lastHistoryStats = status;
|
||||
this.updateDescription();
|
||||
});
|
||||
|
||||
syncer.addRemainingOperationsListener((remainingOperations) => {
|
||||
this.lastRemaining = remainingOperations;
|
||||
this.updateDescription();
|
||||
});
|
||||
|
||||
database.addOnSettingsChangeHandlers(() => {
|
||||
void this.updateConnectionState();
|
||||
});
|
||||
}
|
||||
|
||||
public async updateConnectionState(): Promise<void> {
|
||||
this.lastConnectionState = await this.syncService.checkConnection();
|
||||
this.updateDescription();
|
||||
}
|
||||
|
||||
public addStatusChangeListener(listener: () => void): void {
|
||||
this.statusChangeListeners.push(listener);
|
||||
}
|
||||
public removeStatusChangeListener(listener: () => void): void {
|
||||
this.statusChangeListeners = this.statusChangeListeners.filter(
|
||||
(l) => l !== listener
|
||||
);
|
||||
}
|
||||
|
||||
public renderStatusDescription(container: HTMLElement): void {
|
||||
container.empty();
|
||||
container.addClass("status-description");
|
||||
|
||||
if (this.lastConnectionState == undefined) {
|
||||
container.createSpan({
|
||||
text: "VaultLink is starting up…",
|
||||
cls: "warning"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.lastConnectionState.isSuccessful) {
|
||||
container.createSpan({
|
||||
text: `VaultLink failed to connect to the remote server with the error "${this.lastConnectionState.message}"`,
|
||||
cls: "error"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
container.createSpan({ text: "VaultLink is connected to the server " });
|
||||
container.createEl("a", {
|
||||
text: this.database.getSettings().remoteUri,
|
||||
href: this.database.getSettings().remoteUri
|
||||
});
|
||||
|
||||
container.createSpan({
|
||||
text: ` and has indexed approximately `
|
||||
});
|
||||
container.createSpan({
|
||||
text: `${this.database.getDocuments().size}`,
|
||||
cls: "number"
|
||||
});
|
||||
container.createSpan({
|
||||
text: ` documents. `
|
||||
});
|
||||
|
||||
if (
|
||||
(this.lastRemaining ?? 0) === 0 &&
|
||||
(this.lastHistoryStats?.success ?? 0) === 0 &&
|
||||
(this.lastHistoryStats?.error ?? 0) === 0
|
||||
) {
|
||||
if (this.database.getSettings().isSyncEnabled) {
|
||||
container.createSpan({
|
||||
text: "Syncing is enabled but VaultLink hasn't found anything to sync yet."
|
||||
});
|
||||
} else {
|
||||
container.createSpan({
|
||||
text: "However, syncing is disabled right now.",
|
||||
cls: "warning"
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
container.createSpan({
|
||||
text: "The plugin has "
|
||||
});
|
||||
container.createSpan({
|
||||
text: `${this.lastRemaining ?? 0}`,
|
||||
cls: "number"
|
||||
});
|
||||
container.createSpan({
|
||||
text: " outstanding operations while having succeeded "
|
||||
});
|
||||
container.createSpan({
|
||||
text: `${this.lastHistoryStats?.success ?? 0}`,
|
||||
cls: ["number", "good"]
|
||||
});
|
||||
container.createSpan({
|
||||
text: " times and failed "
|
||||
});
|
||||
container.createSpan({
|
||||
text: `${this.lastHistoryStats?.error ?? 0}`,
|
||||
cls: ["number", "bad"]
|
||||
});
|
||||
container.createSpan({
|
||||
text: " times."
|
||||
});
|
||||
}
|
||||
|
||||
private updateDescription(): void {
|
||||
this.statusChangeListeners.forEach((listener) => {
|
||||
listener();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"inlineSourceMap": true,
|
||||
"inlineSources": true,
|
||||
"module": "ESNext",
|
||||
"target": "ES6",
|
||||
"allowJs": true,
|
||||
"noImplicitAny": true,
|
||||
"moduleResolution": "bundler",
|
||||
"importHelpers": true,
|
||||
"isolatedModules": true,
|
||||
"strictNullChecks": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"lib": [
|
||||
"DOM",
|
||||
"ES5",
|
||||
"ES6",
|
||||
"ES7"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts"
|
||||
]
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
import { readFileSync, writeFileSync } from "fs";
|
||||
|
||||
const targetVersion = process.env.npm_package_version;
|
||||
|
||||
let manifest = JSON.parse(readFileSync("manifest.json", "utf8"));
|
||||
manifest.version = targetVersion;
|
||||
writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t"));
|
||||
|
|
@ -1,116 +0,0 @@
|
|||
const path = require("path");
|
||||
const TerserPlugin = require("terser-webpack-plugin");
|
||||
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
|
||||
const fs = require("fs-extra");
|
||||
|
||||
module.exports = (env, argv) => ({
|
||||
devtool: argv.mode === "development" ? "inline-source-map" : false,
|
||||
entry: {
|
||||
index: "./src/vault-link-plugin.ts"
|
||||
},
|
||||
watchOptions: {
|
||||
ignored: "**/node_modules"
|
||||
},
|
||||
externals: {
|
||||
obsidian: "commonjs obsidian"
|
||||
},
|
||||
optimization: {
|
||||
minimizer: [
|
||||
new TerserPlugin({
|
||||
terserOptions: {
|
||||
module: true
|
||||
}
|
||||
})
|
||||
]
|
||||
},
|
||||
plugins: [
|
||||
new MiniCssExtractPlugin({
|
||||
filename: "styles.css"
|
||||
}),
|
||||
|
||||
new (require("webpack").DefinePlugin)({
|
||||
__CURRENT_DATE__: Date.now()
|
||||
}),
|
||||
{
|
||||
apply: (compiler) => {
|
||||
if (argv.mode !== "development") {
|
||||
return;
|
||||
}
|
||||
|
||||
compiler.hooks.done.tap("Copy Files Plugin", (stats) => {
|
||||
const source = path.resolve(__dirname, "dist");
|
||||
const destinations = [
|
||||
"/mnt/c/Users/Andras/Desktop/test/test/.obsidian/plugins/my-plugin",
|
||||
"/mnt/c/Users/Andras/Desktop/test/test2/.obsidian/plugins/my-plugin",
|
||||
"/home/andras/obsidian-test/.obsidian/plugins/my-plugin"
|
||||
];
|
||||
destinations.forEach((destination) => {
|
||||
fs.copy(source, destination)
|
||||
.then(() =>
|
||||
console.log(
|
||||
"Files copied successfully after build!"
|
||||
)
|
||||
)
|
||||
.catch((err) =>
|
||||
console.error("Error copying files:", err)
|
||||
);
|
||||
|
||||
fs.createFile(path.join(destination, ".hotreload"));
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
],
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.json$/i,
|
||||
type: "asset/resource",
|
||||
generator: {
|
||||
filename: "[name][ext]"
|
||||
}
|
||||
},
|
||||
{
|
||||
test: /\.scss$/i,
|
||||
use: [
|
||||
MiniCssExtractPlugin.loader,
|
||||
"css-loader",
|
||||
"resolve-url-loader",
|
||||
{
|
||||
loader: "sass-loader",
|
||||
options: {
|
||||
sourceMap: true // required by resolve-url-loader
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
test: /\.ts$/,
|
||||
use: ["ts-loader"]
|
||||
},
|
||||
{
|
||||
test: /\.wasm$/,
|
||||
type: "asset/inline"
|
||||
}
|
||||
]
|
||||
},
|
||||
resolve: {
|
||||
extensions: [
|
||||
".ts",
|
||||
".js" // required for development
|
||||
],
|
||||
alias: {
|
||||
root: __dirname,
|
||||
src: path.resolve(__dirname, "src")
|
||||
}
|
||||
},
|
||||
output: {
|
||||
clean: true,
|
||||
filename: "main.js",
|
||||
library: {
|
||||
type: "commonjs" // required for Obsidian
|
||||
},
|
||||
path: path.resolve(__dirname, "dist"),
|
||||
publicPath: ""
|
||||
}
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue