Compare commits

..

45 commits
1.0.5 ... main

Author SHA1 Message Date
Silvano Cerza
2ebd201da9 Add brief contributing instructions in README.md 2025-05-25 16:52:44 +02:00
Silvano Cerza
ee9c2ee236 Bump version to 1.0.7 2025-05-22 19:43:02 +02:00
Silvano Cerza
b6eb9ed62d Fix conflict view not being scrollable 2025-05-22 19:34:41 +02:00
Silvano Cerza
59f951f2b8 Fix sync not working if files have been deleted, moved, or renamed 2025-05-22 16:50:02 +02:00
Silvano Cerza
c62afe938b Fix build workflow 2025-05-13 09:42:03 +02:00
Silvano Cerza
69841aa574 Fix log file being synced if settings sync is enabled 2025-04-20 21:25:24 +02:00
Silvano Cerza
345826443f Fix issue reading binary files on iOS causing sync to fail 2025-04-20 17:33:13 +02:00
Silvano Cerza
e33de48075 Create remote manifest on first sync with local manifest content 2025-04-20 15:42:14 +02:00
Silvano Cerza
b2f3db6050 Fix first sync marked as done on failure 2025-04-20 13:16:57 +02:00
Silvano Cerza
be547d8691 Fix trying to sync deleted files on first sync 2025-04-20 13:16:53 +02:00
Silvano Cerza
4fa2526322 Fix release workflow marking beta version as prerelease 2025-04-18 19:43:23 +02:00
Silvano Cerza
786cefaf4d Correctly update metadata when extracting zip 2025-04-18 19:41:16 +02:00
Silvano Cerza
654fa6b455 Add some more logs 2025-04-18 18:14:36 +02:00
Silvano Cerza
1e16d01adf Skip hidden files when unzipping repo on first sync from remote 2025-04-18 18:06:20 +02:00
Silvano Cerza
56bd6dee4c Fix crashes when unzipping repo on first sync from remote 2025-04-18 18:05:30 +02:00
Silvano Cerza
b6be53ca16 Add retry to most requests made by SyncManager 2025-04-17 12:57:57 +02:00
Silvano Cerza
53651f5b88 Adjust benchmark script with new imports and add some more progress logs 2025-04-17 12:57:57 +02:00
Silvano Cerza
263623dad7 Fix hasTextExtension util function 2025-04-17 12:57:57 +02:00
Silvano Cerza
13383dc65c Change all GithubClient method to support retry and slightly change arguments type 2025-04-17 12:57:57 +02:00
Silvano Cerza
15cc51014c Add utility method to retry a function with exponential backoff 2025-04-17 12:57:53 +02:00
Silvano Cerza
96da957f18 Fix benchmark script and Obsidian mock 2025-04-17 10:02:23 +02:00
Silvano Cerza
457a0b6d24 Fix repo unzip 2025-04-17 09:57:17 +02:00
Silvano Cerza
1a2eb64959 Simplify logic that determines if a file is text or binary 2025-04-16 16:58:23 +02:00
Silvano Cerza
5ab20cf482 Update README with logs instructions 2025-04-16 16:26:32 +02:00
Silvano Cerza
d0f04d5217 Add utility buttons in settings to copy or clean logs 2025-04-16 16:25:02 +02:00
Silvano Cerza
7e7eddaec3 Remove unnecessary import 2025-04-16 15:02:38 +02:00
Silvano Cerza
6c9b8625c3 Enhance first sync from remote to make less requests 2025-04-16 15:02:14 +02:00
Silvano Cerza
271d90b7f9 Update compatibility FAQ 2025-04-14 18:15:06 +02:00
Silvano Cerza
de1c814260 Update README with FAQ section and clarify sync compatibility 2025-04-14 18:14:26 +02:00
Silvano Cerza
32dee2db4c Rework release workflow to create beta releases 2025-04-02 15:09:30 +02:00
Silvano Cerza
415532ff07 Add manifest for beta testing 2025-04-02 14:57:44 +02:00
Silvano Cerza
5879d22a52 Fix text files being treated as binaries if their size is big 2025-04-01 13:09:46 +02:00
Silvano Cerza
bbe05f716a Add more benchmark cases and cleanup some stuff 2025-04-01 13:03:50 +02:00
Silvano Cerza
17f869f9b3 Change generateRandomFile to create all files with specified size 2025-03-31 15:56:16 +02:00
Silvano Cerza
b26853fb3a Fix cleanup 2025-03-31 14:56:28 +02:00
Silvano Cerza
91137ebf82 Fix metadata missing 2025-03-31 10:55:15 +02:00
Silvano Cerza
035f223aef Add benchmark script 2025-03-30 18:19:24 +02:00
Silvano Cerza
f2452b1c60 Fix mocked config dir and requestUrl 2025-03-30 17:54:13 +02:00
Silvano Cerza
4f018043f2 Mock Obsidian internals 2025-03-30 11:11:18 +02:00
Silvano Cerza
e0fca4dc7a Add benchmark_result.json to gitignore 2025-03-30 11:10:56 +02:00
Silvano Cerza
eec44d8190 Add dependencies to run benchmarks 2025-03-30 11:10:15 +02:00
Silvano Cerza
acf5157971 Remove unnecessary import 2025-03-30 11:10:02 +02:00
Silvano Cerza
03758866fb Change install instructions 2025-03-27 10:14:46 +01:00
Silvano Cerza
b7dbbba7b8 Bump version to 1.0.6 2025-03-21 17:03:38 +01:00
Silvano Cerza
5f0c8b1ed2 Fix binary files failing to upload to GitHub 2025-03-21 17:03:13 +01:00
26 changed files with 1706 additions and 212 deletions

View file

@ -3,7 +3,7 @@ name: Release plugin
on:
push:
tags:
- "[0-9].[0-9]+.[0-9]+"
- "[0-9].[0-9]+.[0-9]+*"
jobs:
build:
@ -20,13 +20,21 @@ jobs:
- name: Build plugin
run: |
npm install
npm run build
npm install -g pnpm@latest-10
pnpm install
pnpm build
- name: Create release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release create "${{ github.ref_name }}" \
--title="${{ github.ref_name }}" \
main.js manifest.json styles.css
if [[ "${{ github.ref_name }}" == *-beta* ]]; then
gh release create "${{ github.ref_name }}" \
--title="${{ github.ref_name }}" \
--prerelease \
main.js manifest.json styles.css
else
gh release create "${{ github.ref_name }}" \
--title="${{ github.ref_name }}" \
main.js manifest.json styles.css
fi

2
.gitignore vendored
View file

@ -156,3 +156,5 @@ data.json
# Exclude macOS Finder (System Explorer) View States
.DS_Store
benchmark_result.json

View file

@ -20,23 +20,17 @@ These are the main features of the plugin:
## Installation
The plugin is still not available in the community plugins.
The plugin is available as a community plugin, you can easily search for it in Obsidian and install it from there.
For the time being you can install it with BRAT. If you never used BRAT see [the official quick start guide](https://tfthacker.com/brat-quick-guide).
If you already have BRAT installed to install GitHub Sync copy the following link and paste it in the browser address bar.
```
obsidian://brat?plugin=https://github.com/silvanocerza/obsidian-github-sync
```
![Obsidian community plugin settings](./assets/install_instructions.png)
### Issues
If you find any problem please open an issue with as many details as possible.
Please also provide logs if possible, you can find the `github-sync.log` file in your config directory. Remember to enable logging first.
Please also provide logs if possible, you can copy them from the settings page. Remember to enable logging first.
![Enable logging](./assets/log_enable.png)
![Enable logging](./assets/logs_settings.png)
## Usage
@ -108,7 +102,9 @@ If you need to reset the plugin settings and metadata you can easily do that in
That will completely wipe all the sync metadata so you'll have to repeat the first sync as if you just enabled the plugin for the first time.
## What's different from other sync plugins?
## FAQs
### What's different from other sync plugins?
There are obviously other plugins that let you sync your vault with GitHub or other git hosts, like [`obsidian-git`](https://github.com/Vinzent03/obsidian-git) and [`Obsidian-GitHub-Sync`](https://github.com/kevinmkchin/Obsidian-GitHub-Sync) just to name a couple.
@ -122,6 +118,24 @@ This obviously comes with some limitations. Since `git` is not used you can't in
Also since this relies only on the GitHub APIs you can only sync with GitHub and no other host.
### Can I use this with other sync plugins?
No.
To work correctly this plugin uses a custom metadata file that is updated every time we sync, if you commit changes outside the plugin that file is not updated properly.
Other plugins don't know about that file, so if you sync with others too you risk losing data.
## Contributing
Contributions are obviously accepted.
Bug fixes are always welcome, new feature must be discussed first. Open a [discussion](https://github.com/silvanocerza/github-gitless-sync/discussions) and let's talk in that case.
Keep PRs as small as possible, I won't review PRs with hundreds of lines if it's mostly code.
Low quality or vibe coded PRs are not welcome. Put some effort in it please.
## License
The project is licensed under the [AGPLv3](https://www.gnu.org/licenses/agpl-3.0.en.html) license.

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

BIN
assets/logs_settings.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

280
benchmark.ts Normal file
View file

@ -0,0 +1,280 @@
import * as fs from "fs";
import * as proxyquire from "proxyquire";
import * as obsidianMocks from "./mock-obsidian";
import * as os from "os";
import * as path from "path";
import * as crypto from "crypto";
import { execSync } from "child_process";
const proxyquireNonStrict = proxyquire.noCallThru();
const LoggerModule = proxyquireNonStrict("./src/logger", {
obsidian: obsidianMocks,
});
const MetadataStoreModule = proxyquireNonStrict("./src/metadata-store", {
obsidian: obsidianMocks,
});
const EventsListenerModule = proxyquireNonStrict("./src/events-listener", {
obsidian: obsidianMocks,
"./metadata-store": MetadataStoreModule,
});
const UtilsModule = proxyquireNonStrict("./src/utils", {
obsidian: obsidianMocks,
});
const GithubClientModule = proxyquireNonStrict("./src/github/client", {
obsidian: obsidianMocks,
"src/utils": UtilsModule,
});
const SyncManagerModule = proxyquireNonStrict("./src/sync-manager", {
obsidian: obsidianMocks,
"./metadata-store": MetadataStoreModule,
"./events-listener": EventsListenerModule,
"./github/client": GithubClientModule,
"./utils": UtilsModule,
});
async function runBenchmark(vaultRootDir: string) {
const vault = new obsidianMocks.Vault(vaultRootDir);
// Create a real logger with our mock vault
const logger = new LoggerModule.default(vault, false);
// Settings for the sync manager
const settings = {
githubToken: process.env.GITHUB_TOKEN,
githubOwner: process.env.REPO_OWNER,
githubRepo: process.env.REPO_NAME,
githubBranch: process.env.REPO_BRANCH,
syncConfigDir: false,
};
// We're not going to get any conflicts, this is useless
const onConflicts = async () => {
return [];
};
// Create the sync manager
const SyncManager = SyncManagerModule.default;
const syncManager = new SyncManager(vault, settings, onConflicts, logger);
await syncManager.loadMetadata();
const startTime = performance.now();
await syncManager.firstSync();
return performance.now() - startTime;
}
const generateRandomFiles = (
rootPath: string,
numFiles: number,
maxDepth: number,
fileSize: number,
) => {
const metadata: { lastSync: number; files: { [key: string]: {} } } = {
lastSync: 0,
files: {},
};
// Create root directory if it doesn't exist
if (!fs.existsSync(rootPath)) {
fs.mkdirSync(rootPath, { recursive: true });
}
// Generate folder structure first
const allFolderPaths = [rootPath];
for (let depth = 1; depth <= maxDepth; depth++) {
const numFoldersAtThisDepth = Math.floor(Math.random() * 3) + 1; // 1-3 folders per level
for (let i = 0; i < numFoldersAtThisDepth; i++) {
const parentPath =
allFolderPaths[Math.floor(Math.random() * allFolderPaths.length)];
const currentDepthOfParent =
parentPath.split(path.sep).length - rootPath.split(path.sep).length;
// Only create subfolders if we haven't reached max depth for this path
if (currentDepthOfParent < maxDepth) {
const folderName = crypto.randomBytes(5).toString("hex");
const newFolderPath = path.join(parentPath, folderName);
fs.mkdirSync(newFolderPath, { recursive: true });
allFolderPaths.push(newFolderPath);
}
}
}
// Now generate files
const contentSize = fileSize / 2; // We divide by two as converting bytes to hex doubles the size
for (let i = 0; i < numFiles; i++) {
// Pick a random folder to place the file in
const targetFolder =
allFolderPaths[Math.floor(Math.random() * allFolderPaths.length)];
// Generate random file name
const fileName = crypto.randomBytes(8).toString("hex") + ".md";
const filePath = path.join(targetFolder, fileName);
// Generate random content
const content = crypto.randomBytes(contentSize).toString("hex");
// Write file
fs.writeFileSync(filePath, content);
const relativeFilePath = filePath.replace(`${rootPath}/`, "");
metadata.files[relativeFilePath] = {
path: relativeFilePath,
sha: null,
dirty: true,
justDownloaded: false,
lastModified: Date.now(),
};
}
const metadataFilePath = path.join(
rootPath,
".obsidian",
"github-sync-metadata.json",
);
fs.mkdirSync(path.join(rootPath, ".obsidian"));
fs.writeFileSync(metadataFilePath, JSON.stringify(metadata), { flag: "w" });
};
const cleanupRemote = () => {
const url = `git@github.com:${process.env.REPO_OWNER}/${process.env.REPO_NAME}.git`;
const clonedDir = path.join(os.tmpdir(), "temp-clone");
// Remove the folder in case it already exists
fs.rmSync(clonedDir, { recursive: true, force: true });
try {
// Clone the repository
execSync(`git clone ${url} ${clonedDir}`, { stdio: "ignore" });
const repoExists = fs.existsSync(clonedDir);
if (!repoExists) {
throw Error("Failed to clone repo");
}
// Remove all files except .git
execSync('find . -type f -not -path "./.git*" -delete', {
stdio: "ignore",
cwd: clonedDir,
});
// Commit empty state
execSync("git add -A", { stdio: "ignore", cwd: clonedDir });
execSync('git commit -m "Cleanup"', {
stdio: "ignore",
cwd: clonedDir,
});
// Push changes
execSync("git push", { stdio: "ignore", cwd: clonedDir });
} catch (error) {
console.error(`Error: ${error.message}`);
}
// Remove the folder when everything is done
fs.rmSync(clonedDir, { recursive: true, force: true });
};
const BENCHMARK_DATA = [
{
files: 1,
maxDepth: 0,
// 15 Kb
fileSize: 1024 * 15,
},
{
files: 10,
maxDepth: 0,
// 15 Kb
fileSize: 1024 * 15,
},
{
files: 100,
maxDepth: 0,
// 15 Kb
fileSize: 1024 * 15,
},
{
files: 1000,
maxDepth: 0,
// 15 Kb
fileSize: 1024 * 15,
},
{
files: 10000,
maxDepth: 0,
// 15 Kb
fileSize: 1024 * 15,
},
{
files: 100000,
maxDepth: 0,
// 15 Kb
fileSize: 1024 * 15,
},
];
(async () => {
const tmp = os.tmpdir();
const benchmarkRootDir = path.join(tmp, "github-gitless-sync-benchmark");
try {
const results = [];
for (const data of BENCHMARK_DATA) {
console.log(
`Running benchmark with ${data.files} files totaling ${data.fileSize} bytes`,
);
const vaultRootDir = path.join(
benchmarkRootDir,
`${data.files}-${data.maxDepth}-${data.fileSize}`,
);
// Generates random files
generateRandomFiles(
vaultRootDir,
data.files,
data.maxDepth,
data.fileSize,
);
// Run first sync by uploading all local files
console.log("First sync from local");
const uploadTime = await runBenchmark(vaultRootDir);
// Cleanup vault dir completely
fs.rmSync(vaultRootDir, { recursive: true, force: true });
// Run first sync again, this time we download the files we just uploaded
console.log("Second sync from remote");
const downloadTime = await runBenchmark(vaultRootDir);
// Cleanup the remote repo so it's ready for another benchmark
cleanupRemote();
results.push({
data,
uploadTime,
downloadTime,
});
// Cleanup vault dir again, it's not necessary to keep it around
fs.rmSync(vaultRootDir, { recursive: true, force: true });
// Wait 2 seconds between each run just to avoid annoying Github
await new Promise((resolve) => setTimeout(resolve, 2000));
console.log("");
}
fs.writeFileSync("benchmark_result.json", JSON.stringify(results), {
flag: "w",
});
} catch (error) {
console.error("Benchmark failed:", error);
}
fs.rmSync(benchmarkRootDir, { recursive: true, force: true });
})();

11
manifest-beta.json Normal file
View file

@ -0,0 +1,11 @@
{
"id": "github-gitless-sync",
"name": "GitHub Gitless Sync",
"version": "1.0.7-beta",
"minAppVersion": "1.7.7",
"description": "Sync a GitHub repository with vaults on different platforms without requiring git installation",
"author": "Silvano Cerza",
"authorUrl": "https://silvanocerza.com",
"fundingUrl": "https://ko-fi.com/silvanocerza",
"isDesktopOnly": false
}

View file

@ -1,7 +1,7 @@
{
"id": "github-gitless-sync",
"name": "GitHub Gitless Sync",
"version": "1.0.5",
"version": "1.0.7",
"minAppVersion": "1.7.7",
"description": "Sync a GitHub repository with vaults on different platforms without requiring git installation",
"author": "Silvano Cerza",

163
mock-obsidian.ts Normal file
View file

@ -0,0 +1,163 @@
import {
readFileSync,
writeFileSync,
existsSync,
mkdirSync,
promises as fs,
} from "fs";
import * as path from "path";
// Mock Obsidian's Vault class
export class Vault {
configDir: string;
private rootPath: string;
constructor(rootPath: string) {
this.rootPath = rootPath;
this.configDir = ".obsidian";
// Ensure vault directory exists
if (!existsSync(this.rootPath)) {
mkdirSync(this.rootPath, { recursive: true });
}
// Ensure config directory exists
if (!existsSync(path.join(this.rootPath, this.configDir))) {
mkdirSync(path.join(this.rootPath, this.configDir), { recursive: true });
}
}
getRoot() {
return { path: this.rootPath };
}
get adapter() {
return {
read: async (filePath: string) => {
const fullPath = path.join(this.rootPath, filePath);
return readFileSync(fullPath, "utf8");
},
write: async (filePath: string, data: string) => {
const fullPath = path.join(this.rootPath, filePath);
const dir = path.dirname(fullPath);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
writeFileSync(fullPath, data);
},
readBinary: async (filePath: string) => {
const fullPath = path.join(this.rootPath, filePath);
return readFileSync(fullPath);
},
writeBinary: async (filePath: string, data: ArrayBuffer) => {
const fullPath = path.join(this.rootPath, filePath);
const dir = path.dirname(fullPath);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
writeFileSync(fullPath, Buffer.from(data));
},
exists: async (filePath: string) => {
const fullPath = path.join(this.rootPath, filePath);
return existsSync(fullPath);
},
mkdir: async (dirPath: string) => {
const fullPath = path.join(this.rootPath, dirPath);
if (!existsSync(fullPath)) {
mkdirSync(fullPath, { recursive: true });
}
},
remove: async (filePath: string) => {
const fullPath = path.join(this.rootPath, filePath);
if (existsSync(fullPath)) {
await fs.unlink(fullPath);
}
},
list: async (dirPath: string) => {
const fullPath = path.join(this.rootPath, dirPath);
if (!existsSync(fullPath)) {
return { files: [], folders: [] };
}
const entries = await fs.readdir(fullPath, { withFileTypes: true });
const files = entries
.filter((entry) => entry.isFile())
.map((entry) => path.join(dirPath, entry.name));
const folders = entries
.filter((entry) => entry.isDirectory())
.map((entry) => path.join(dirPath, entry.name));
return { files, folders };
},
};
}
}
// Mock Notice
export class Notice {
constructor(message: string, timeout?: number) {
console.log(`NOTICE: ${message}`);
}
hide() {
// Do nothing in mock
}
}
interface RequestUrlParam {
url: string;
method?: string;
headers?: Record<string, string>;
contentType?: string;
body?: string | ArrayBuffer;
throw?: boolean;
}
export async function requestUrl(options: RequestUrlParam) {
const response = await fetch(options.url, {
method: options.method || "GET",
headers: options.headers,
body: options.body,
});
const isJsonResponse = response.headers
.get("content-type")
?.includes("application/json");
// Convert to expected Obsidian response format
if (isJsonResponse) {
return {
status: response.status,
json: await response.json(),
};
} else {
return {
status: response.status,
arrayBuffer: await response.arrayBuffer(),
};
}
}
// Mock utility functions
export function normalizePath(path: string): string {
return path.replace(/\\/g, "/");
}
export function arrayBufferToBase64(buffer: ArrayBuffer): string {
return Buffer.from(buffer).toString("base64");
}
export function base64ToArrayBuffer(base64: string): ArrayBuffer {
return Buffer.from(base64, "base64");
}
// Mock Event reference
export type EventRef = string;

View file

@ -1,26 +1,32 @@
{
"name": "github-gitless-sync",
"version": "1.0.5",
"version": "1.0.7",
"description": "Sync a GitHub repository with vaults on different platforms without requiring git installation",
"main": "main.js",
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"version": "node version-bump.mjs"
"version": "node version-bump.mjs",
"benchmark": "tsx benchmark.ts"
},
"keywords": [],
"author": "Silvano Cerza",
"license": "AGPL-3.0-only",
"devDependencies": {
"@types/node": "^22.10.5",
"@types/proxyquire": "^1.3.31",
"@types/react": "^19.0.4",
"@types/react-dom": "^19.0.2",
"@types/sinon": "^17.0.4",
"@typescript-eslint/eslint-plugin": "^8.19.0",
"@typescript-eslint/parser": "^8.19.0",
"builtin-modules": "^4.0.0",
"esbuild": "^0.24.2",
"obsidian": "^1.7.2",
"proxyquire": "^2.1.3",
"sinon": "^20.0.0",
"tslib": "^2.8.1",
"tsx": "^4.19.3",
"typescript": "^5.7.2"
},
"dependencies": {
@ -28,6 +34,7 @@
"@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.36.2",
"@uiw/react-codemirror": "^4.23.8",
"@zip.js/zip.js": "^2.7.60",
"codemirror": "^6.0.1",
"react": "^19.0.0",
"react-dom": "^19.0.0"

View file

@ -20,6 +20,9 @@ importers:
'@uiw/react-codemirror':
specifier: ^4.23.8
version: 4.23.8(@babel/runtime@7.26.9)(@codemirror/autocomplete@6.18.6)(@codemirror/language@6.10.8)(@codemirror/lint@6.8.4)(@codemirror/search@6.5.9)(@codemirror/state@6.5.2)(@codemirror/theme-one-dark@6.1.2)(@codemirror/view@6.36.2)(codemirror@6.0.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@zip.js/zip.js':
specifier: ^2.7.60
version: 2.7.60
codemirror:
specifier: ^6.0.1
version: 6.0.1
@ -33,12 +36,18 @@ importers:
'@types/node':
specifier: ^22.10.5
version: 22.10.5
'@types/proxyquire':
specifier: ^1.3.31
version: 1.3.31
'@types/react':
specifier: ^19.0.4
version: 19.0.4
'@types/react-dom':
specifier: ^19.0.2
version: 19.0.2(@types/react@19.0.4)
'@types/sinon':
specifier: ^17.0.4
version: 17.0.4
'@typescript-eslint/eslint-plugin':
specifier: ^8.19.0
version: 8.19.0(@typescript-eslint/parser@8.19.0(eslint@9.17.0)(typescript@5.7.2))(eslint@9.17.0)(typescript@5.7.2)
@ -54,9 +63,18 @@ importers:
obsidian:
specifier: ^1.7.2
version: 1.7.2(@codemirror/state@6.5.2)(@codemirror/view@6.36.2)
proxyquire:
specifier: ^2.1.3
version: 2.1.3
sinon:
specifier: ^20.0.0
version: 20.0.0
tslib:
specifier: ^2.8.1
version: 2.8.1
tsx:
specifier: ^4.19.3
version: 4.19.3
typescript:
specifier: ^5.7.2
version: 5.7.2
@ -117,150 +135,300 @@ packages:
cpu: [ppc64]
os: [aix]
'@esbuild/aix-ppc64@0.25.1':
resolution: {integrity: sha512-kfYGy8IdzTGy+z0vFGvExZtxkFlA4zAxgKEahG9KE1ScBjpQnFsNOX8KTU5ojNru5ed5CVoJYXFtoxaq5nFbjQ==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.24.2':
resolution: {integrity: sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm64@0.25.1':
resolution: {integrity: sha512-50tM0zCJW5kGqgG7fQ7IHvQOcAn9TKiVRuQ/lN0xR+T2lzEFvAi1ZcS8DiksFcEpf1t/GYOeOfCAgDHFpkiSmA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.24.2':
resolution: {integrity: sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-arm@0.25.1':
resolution: {integrity: sha512-dp+MshLYux6j/JjdqVLnMglQlFu+MuVeNrmT5nk6q07wNhCdSnB7QZj+7G8VMUGh1q+vj2Bq8kRsuyA00I/k+Q==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.24.2':
resolution: {integrity: sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/android-x64@0.25.1':
resolution: {integrity: sha512-GCj6WfUtNldqUzYkN/ITtlhwQqGWu9S45vUXs7EIYf+7rCiiqH9bCloatO9VhxsL0Pji+PF4Lz2XXCES+Q8hDw==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.24.2':
resolution: {integrity: sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-arm64@0.25.1':
resolution: {integrity: sha512-5hEZKPf+nQjYoSr/elb62U19/l1mZDdqidGfmFutVUjjUZrOazAtwK+Kr+3y0C/oeJfLlxo9fXb1w7L+P7E4FQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.24.2':
resolution: {integrity: sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/darwin-x64@0.25.1':
resolution: {integrity: sha512-hxVnwL2Dqs3fM1IWq8Iezh0cX7ZGdVhbTfnOy5uURtao5OIVCEyj9xIzemDi7sRvKsuSdtCAhMKarxqtlyVyfA==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.24.2':
resolution: {integrity: sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-arm64@0.25.1':
resolution: {integrity: sha512-1MrCZs0fZa2g8E+FUo2ipw6jw5qqQiH+tERoS5fAfKnRx6NXH31tXBKI3VpmLijLH6yriMZsxJtaXUyFt/8Y4A==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.24.2':
resolution: {integrity: sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/freebsd-x64@0.25.1':
resolution: {integrity: sha512-0IZWLiTyz7nm0xuIs0q1Y3QWJC52R8aSXxe40VUxm6BB1RNmkODtW6LHvWRrGiICulcX7ZvyH6h5fqdLu4gkww==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.24.2':
resolution: {integrity: sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm64@0.25.1':
resolution: {integrity: sha512-jaN3dHi0/DDPelk0nLcXRm1q7DNJpjXy7yWaWvbfkPvI+7XNSc/lDOnCLN7gzsyzgu6qSAmgSvP9oXAhP973uQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.24.2':
resolution: {integrity: sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-arm@0.25.1':
resolution: {integrity: sha512-NdKOhS4u7JhDKw9G3cY6sWqFcnLITn6SqivVArbzIaf3cemShqfLGHYMx8Xlm/lBit3/5d7kXvriTUGa5YViuQ==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.24.2':
resolution: {integrity: sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-ia32@0.25.1':
resolution: {integrity: sha512-OJykPaF4v8JidKNGz8c/q1lBO44sQNUQtq1KktJXdBLn1hPod5rE/Hko5ugKKZd+D2+o1a9MFGUEIUwO2YfgkQ==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.24.2':
resolution: {integrity: sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-loong64@0.25.1':
resolution: {integrity: sha512-nGfornQj4dzcq5Vp835oM/o21UMlXzn79KobKlcs3Wz9smwiifknLy4xDCLUU0BWp7b/houtdrgUz7nOGnfIYg==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.24.2':
resolution: {integrity: sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-mips64el@0.25.1':
resolution: {integrity: sha512-1osBbPEFYwIE5IVB/0g2X6i1qInZa1aIoj1TdL4AaAb55xIIgbg8Doq6a5BzYWgr+tEcDzYH67XVnTmUzL+nXg==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.24.2':
resolution: {integrity: sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-ppc64@0.25.1':
resolution: {integrity: sha512-/6VBJOwUf3TdTvJZ82qF3tbLuWsscd7/1w+D9LH0W/SqUgM5/JJD0lrJ1fVIfZsqB6RFmLCe0Xz3fmZc3WtyVg==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.24.2':
resolution: {integrity: sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-riscv64@0.25.1':
resolution: {integrity: sha512-nSut/Mx5gnilhcq2yIMLMe3Wl4FK5wx/o0QuuCLMtmJn+WeWYoEGDN1ipcN72g1WHsnIbxGXd4i/MF0gTcuAjQ==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.24.2':
resolution: {integrity: sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-s390x@0.25.1':
resolution: {integrity: sha512-cEECeLlJNfT8kZHqLarDBQso9a27o2Zd2AQ8USAEoGtejOrCYHNtKP8XQhMDJMtthdF4GBmjR2au3x1udADQQQ==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.24.2':
resolution: {integrity: sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/linux-x64@0.25.1':
resolution: {integrity: sha512-xbfUhu/gnvSEg+EGovRc+kjBAkrvtk38RlerAzQxvMzlB4fXpCFCeUAYzJvrnhFtdeyVCDANSjJvOvGYoeKzFA==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-arm64@0.24.2':
resolution: {integrity: sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-arm64@0.25.1':
resolution: {integrity: sha512-O96poM2XGhLtpTh+s4+nP7YCCAfb4tJNRVZHfIE7dgmax+yMP2WgMd2OecBuaATHKTHsLWHQeuaxMRnCsH8+5g==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-x64@0.24.2':
resolution: {integrity: sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/netbsd-x64@0.25.1':
resolution: {integrity: sha512-X53z6uXip6KFXBQ+Krbx25XHV/NCbzryM6ehOAeAil7X7oa4XIq+394PWGnwaSQ2WRA0KI6PUO6hTO5zeF5ijA==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-arm64@0.24.2':
resolution: {integrity: sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-arm64@0.25.1':
resolution: {integrity: sha512-Na9T3szbXezdzM/Kfs3GcRQNjHzM6GzFBeU1/6IV/npKP5ORtp9zbQjvkDJ47s6BCgaAZnnnu/cY1x342+MvZg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-x64@0.24.2':
resolution: {integrity: sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/openbsd-x64@0.25.1':
resolution: {integrity: sha512-T3H78X2h1tszfRSf+txbt5aOp/e7TAz3ptVKu9Oyir3IAOFPGV6O9c2naym5TOriy1l0nNf6a4X5UXRZSGX/dw==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/sunos-x64@0.24.2':
resolution: {integrity: sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/sunos-x64@0.25.1':
resolution: {integrity: sha512-2H3RUvcmULO7dIE5EWJH8eubZAI4xw54H1ilJnRNZdeo8dTADEZ21w6J22XBkXqGJbe0+wnNJtw3UXRoLJnFEg==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.24.2':
resolution: {integrity: sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-arm64@0.25.1':
resolution: {integrity: sha512-GE7XvrdOzrb+yVKB9KsRMq+7a2U/K5Cf/8grVFRAGJmfADr/e/ODQ134RK2/eeHqYV5eQRFxb1hY7Nr15fv1NQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.24.2':
resolution: {integrity: sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-ia32@0.25.1':
resolution: {integrity: sha512-uOxSJCIcavSiT6UnBhBzE8wy3n0hOkJsBOzy7HDAuTDE++1DJMRRVCPGisULScHL+a/ZwdXPpXD3IyFKjA7K8A==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.24.2':
resolution: {integrity: sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
'@esbuild/win32-x64@0.25.1':
resolution: {integrity: sha512-Y1EQdcfwMSeQN/ujR5VayLOJ1BHaK+ssyk0AEzPjC+t1lITgsnccPqFjb6V+LsTp/9Iov4ysfjxLaGJ9RPtkVg==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
'@eslint-community/eslint-utils@4.4.1':
resolution: {integrity: sha512-s3O3waFUrMV8P/XaF/+ZTp1X9XBZW1a4B97ZnjQF2KYWaFD2A8KyFBsrsfSjEmjn3RGWAIuvlneuZm3CUK3jbA==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
@ -357,6 +525,15 @@ packages:
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
engines: {node: '>= 8'}
'@sinonjs/commons@3.0.1':
resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==}
'@sinonjs/fake-timers@13.0.5':
resolution: {integrity: sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==}
'@sinonjs/samsam@8.0.2':
resolution: {integrity: sha512-v46t/fwnhejRSFTGqbpn9u+LQ9xJDse10gNnPgAcxgdoCDMXj/G2asWAC/8Qs+BAZDicX+MNZouXT1A7c83kVw==}
'@types/codemirror@5.60.8':
resolution: {integrity: sha512-VjFgDF/eB+Aklcy15TtOTLQeMjTo07k7KAjql8OK5Dirr7a6sJY4T1uVBDuTVG9VEmn1uUsohOpYnVfgC6/jyw==}
@ -372,6 +549,9 @@ packages:
'@types/node@22.10.5':
resolution: {integrity: sha512-F8Q+SeGimwOo86fiovQh8qiXfFEh2/ocYv7tU5pJ3EXMSSxk1Joj5wefpFK2fHTf/N6HKGSxIDBT9f3gCxXPkQ==}
'@types/proxyquire@1.3.31':
resolution: {integrity: sha512-uALowNG2TSM1HNPMMOR0AJwv4aPYPhqB0xlEhkeRTMuto5hjoSPZkvgu1nbPUkz3gEPAHv4sy4DmKsurZiEfRQ==}
'@types/react-dom@19.0.2':
resolution: {integrity: sha512-c1s+7TKFaDRRxr1TxccIX2u7sfCnc3RxkVyBIUA2lCpyqCF+QoAwQ/CBg7bsMdVwP120HEH143VQezKtef5nCg==}
peerDependencies:
@ -380,6 +560,12 @@ packages:
'@types/react@19.0.4':
resolution: {integrity: sha512-3O4QisJDYr1uTUMZHA2YswiQZRq+Pd8D+GdVFYikTutYsTz+QZgWkAPnP7rx9txoI6EXKcPiluMqWPFV3tT9Wg==}
'@types/sinon@17.0.4':
resolution: {integrity: sha512-RHnIrhfPO3+tJT0s7cFaXGZvsL4bbR3/k7z3P312qMS4JaS2Tk+KiwiLx1S0rQ56ERj00u1/BtdyVd0FY+Pdew==}
'@types/sinonjs__fake-timers@8.1.5':
resolution: {integrity: sha512-mQkU2jY8jJEF7YHjHvsQO8+3ughTL1mcnn96igfhONmR+fUPSKIkefQYpSe8bsly2Ep7oQbn/6VG5/9/0qcArQ==}
'@types/tern@0.23.9':
resolution: {integrity: sha512-ypzHFE/wBzh+BlH6rrBgS5I/Z7RD21pGhZ2rltb/+ZrVM1awdZwjx7hE5XfuYgHWk9uvV5HLZN3SloevCAp3Bw==}
@ -452,6 +638,10 @@ packages:
react: '>=16.8.0'
react-dom: '>=16.8.0'
'@zip.js/zip.js@2.7.60':
resolution: {integrity: sha512-vA3rLyqdxBrVo1FWSsbyoecaqWTV+vgPRf0QKeM7kVDG0r+lHUqd7zQDv1TO9k4BcAoNzNDSNrrel24Mk6addA==}
engines: {bun: '>=0.7.0', deno: '>=1.0.0', node: '>=16.5.0'}
acorn-jsx@5.3.2:
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
peerDependencies:
@ -536,11 +726,20 @@ packages:
deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
diff@7.0.0:
resolution: {integrity: sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==}
engines: {node: '>=0.3.1'}
esbuild@0.24.2:
resolution: {integrity: sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==}
engines: {node: '>=18'}
hasBin: true
esbuild@0.25.1:
resolution: {integrity: sha512-BGO5LtrGC7vxnqucAe/rmvKdJllfGaYWdyABvyMoXQlfYMb2bbRuReWR5tEGE//4LcNJj9XrkovTqNYRFZHAMQ==}
engines: {node: '>=18'}
hasBin: true
escape-string-regexp@4.0.0:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'}
@ -607,6 +806,10 @@ packages:
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
engines: {node: '>=16.0.0'}
fill-keys@1.0.2:
resolution: {integrity: sha512-tcgI872xXjwFF4xgQmLxi76GnwJG3g/3isB1l4/G5Z4zrbddGpBjqZCO9oEAcB5wX0Hj/5iQB3toxfO7in1hHA==}
engines: {node: '>=0.10.0'}
fill-range@7.1.1:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'}
@ -627,6 +830,12 @@ packages:
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
get-tsconfig@4.10.0:
resolution: {integrity: sha512-kGzZ3LWWQcGIAmg6iWvXn0ei6WDtV26wzHRMwDSzmAbcXrTEXxHy6IehI6/4eT6VRKyMP1eF1VqwrVUmE/LR7A==}
glob-parent@5.1.2:
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
engines: {node: '>= 6'}
@ -646,6 +855,10 @@ packages:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'}
hasown@2.0.2:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
ignore@5.3.2:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'}
@ -658,6 +871,10 @@ packages:
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
engines: {node: '>=0.8.19'}
is-core-module@2.16.1:
resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==}
engines: {node: '>= 0.4'}
is-extglob@2.1.1:
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
engines: {node: '>=0.10.0'}
@ -670,6 +887,9 @@ packages:
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
engines: {node: '>=0.12.0'}
is-object@1.0.2:
resolution: {integrity: sha512-2rRIahhZr2UWb45fIOuvZGpFtz0TyOZLf32KxBbSoUCeZR495zCKlWUKKUByk3geS2eAs7ZAABt0Y/Rx0GiQGA==}
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
@ -700,12 +920,19 @@ packages:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'}
lodash.get@4.4.2:
resolution: {integrity: sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==}
deprecated: This package is deprecated. Use the optional chaining (?.) operator instead.
lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
magic-string@0.30.17:
resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==}
merge-descriptors@1.0.3:
resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==}
merge2@1.4.1:
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
engines: {node: '>= 8'}
@ -721,6 +948,9 @@ packages:
resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==}
engines: {node: '>=16 || 14 >=14.17'}
module-not-found-error@1.0.1:
resolution: {integrity: sha512-pEk4ECWQXV6z2zjhRZUongnLJNUeGQJ3w6OQ5ctGwD+i5o93qjRQUk2Rt6VdNeu3sEP0AB4LcfvdebpxBRVr4g==}
moment@2.29.4:
resolution: {integrity: sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==}
@ -760,6 +990,9 @@ packages:
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
engines: {node: '>=8'}
path-parse@1.0.7:
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@ -771,6 +1004,9 @@ packages:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
proxyquire@2.1.3:
resolution: {integrity: sha512-BQWfCqYM+QINd+yawJz23tbBM40VIGXOdDw3X344KcclI/gtBbdWF6SlQ4nK/bYhF9d27KYug9WzljHC6B9Ysg==}
punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
@ -794,6 +1030,14 @@ packages:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'}
resolve-pkg-maps@1.0.0:
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
resolve@1.22.10:
resolution: {integrity: sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==}
engines: {node: '>= 0.4'}
hasBin: true
reusify@1.0.4:
resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==}
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
@ -829,6 +1073,9 @@ packages:
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
engines: {node: '>=8'}
sinon@20.0.0:
resolution: {integrity: sha512-+FXOAbdnj94AQIxH0w1v8gzNxkawVvNqE3jUzRLptR71Oykeu2RrQXXl/VQjKay+Qnh73fDt/oDfMo6xMeDQbQ==}
strip-json-comments@3.1.1:
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
engines: {node: '>=8'}
@ -840,6 +1087,10 @@ packages:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'}
supports-preserve-symlinks-flag@1.0.0:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'}
to-regex-range@5.0.1:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'}
@ -853,10 +1104,23 @@ packages:
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
tsx@4.19.3:
resolution: {integrity: sha512-4H8vUNGNjQ4V2EOoGw005+c+dGuPSnhpPBPHBtsZdGZBk/iJb4kguGlPWaZTZ3q5nMtFOEsY0nRDlh9PJyd6SQ==}
engines: {node: '>=18.0.0'}
hasBin: true
type-check@0.4.0:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'}
type-detect@4.0.8:
resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==}
engines: {node: '>=4'}
type-detect@4.1.0:
resolution: {integrity: sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==}
engines: {node: '>=4'}
typescript@5.7.2:
resolution: {integrity: sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==}
engines: {node: '>=14.17'}
@ -995,78 +1259,153 @@ snapshots:
'@esbuild/aix-ppc64@0.24.2':
optional: true
'@esbuild/aix-ppc64@0.25.1':
optional: true
'@esbuild/android-arm64@0.24.2':
optional: true
'@esbuild/android-arm64@0.25.1':
optional: true
'@esbuild/android-arm@0.24.2':
optional: true
'@esbuild/android-arm@0.25.1':
optional: true
'@esbuild/android-x64@0.24.2':
optional: true
'@esbuild/android-x64@0.25.1':
optional: true
'@esbuild/darwin-arm64@0.24.2':
optional: true
'@esbuild/darwin-arm64@0.25.1':
optional: true
'@esbuild/darwin-x64@0.24.2':
optional: true
'@esbuild/darwin-x64@0.25.1':
optional: true
'@esbuild/freebsd-arm64@0.24.2':
optional: true
'@esbuild/freebsd-arm64@0.25.1':
optional: true
'@esbuild/freebsd-x64@0.24.2':
optional: true
'@esbuild/freebsd-x64@0.25.1':
optional: true
'@esbuild/linux-arm64@0.24.2':
optional: true
'@esbuild/linux-arm64@0.25.1':
optional: true
'@esbuild/linux-arm@0.24.2':
optional: true
'@esbuild/linux-arm@0.25.1':
optional: true
'@esbuild/linux-ia32@0.24.2':
optional: true
'@esbuild/linux-ia32@0.25.1':
optional: true
'@esbuild/linux-loong64@0.24.2':
optional: true
'@esbuild/linux-loong64@0.25.1':
optional: true
'@esbuild/linux-mips64el@0.24.2':
optional: true
'@esbuild/linux-mips64el@0.25.1':
optional: true
'@esbuild/linux-ppc64@0.24.2':
optional: true
'@esbuild/linux-ppc64@0.25.1':
optional: true
'@esbuild/linux-riscv64@0.24.2':
optional: true
'@esbuild/linux-riscv64@0.25.1':
optional: true
'@esbuild/linux-s390x@0.24.2':
optional: true
'@esbuild/linux-s390x@0.25.1':
optional: true
'@esbuild/linux-x64@0.24.2':
optional: true
'@esbuild/linux-x64@0.25.1':
optional: true
'@esbuild/netbsd-arm64@0.24.2':
optional: true
'@esbuild/netbsd-arm64@0.25.1':
optional: true
'@esbuild/netbsd-x64@0.24.2':
optional: true
'@esbuild/netbsd-x64@0.25.1':
optional: true
'@esbuild/openbsd-arm64@0.24.2':
optional: true
'@esbuild/openbsd-arm64@0.25.1':
optional: true
'@esbuild/openbsd-x64@0.24.2':
optional: true
'@esbuild/openbsd-x64@0.25.1':
optional: true
'@esbuild/sunos-x64@0.24.2':
optional: true
'@esbuild/sunos-x64@0.25.1':
optional: true
'@esbuild/win32-arm64@0.24.2':
optional: true
'@esbuild/win32-arm64@0.25.1':
optional: true
'@esbuild/win32-ia32@0.24.2':
optional: true
'@esbuild/win32-ia32@0.25.1':
optional: true
'@esbuild/win32-x64@0.24.2':
optional: true
'@esbuild/win32-x64@0.25.1':
optional: true
'@eslint-community/eslint-utils@4.4.1(eslint@9.17.0)':
dependencies:
eslint: 9.17.0
@ -1180,6 +1519,20 @@ snapshots:
'@nodelib/fs.scandir': 2.1.5
fastq: 1.18.0
'@sinonjs/commons@3.0.1':
dependencies:
type-detect: 4.0.8
'@sinonjs/fake-timers@13.0.5':
dependencies:
'@sinonjs/commons': 3.0.1
'@sinonjs/samsam@8.0.2':
dependencies:
'@sinonjs/commons': 3.0.1
lodash.get: 4.4.2
type-detect: 4.1.0
'@types/codemirror@5.60.8':
dependencies:
'@types/tern': 0.23.9
@ -1194,6 +1547,8 @@ snapshots:
dependencies:
undici-types: 6.20.0
'@types/proxyquire@1.3.31': {}
'@types/react-dom@19.0.2(@types/react@19.0.4)':
dependencies:
'@types/react': 19.0.4
@ -1202,6 +1557,12 @@ snapshots:
dependencies:
csstype: 3.1.3
'@types/sinon@17.0.4':
dependencies:
'@types/sinonjs__fake-timers': 8.1.5
'@types/sinonjs__fake-timers@8.1.5': {}
'@types/tern@0.23.9':
dependencies:
'@types/estree': 1.0.6
@ -1310,6 +1671,8 @@ snapshots:
- '@codemirror/lint'
- '@codemirror/search'
'@zip.js/zip.js@2.7.60': {}
acorn-jsx@5.3.2(acorn@8.14.0):
dependencies:
acorn: 8.14.0
@ -1391,6 +1754,8 @@ snapshots:
deep-is@0.1.4: {}
diff@7.0.0: {}
esbuild@0.24.2:
optionalDependencies:
'@esbuild/aix-ppc64': 0.24.2
@ -1419,6 +1784,34 @@ snapshots:
'@esbuild/win32-ia32': 0.24.2
'@esbuild/win32-x64': 0.24.2
esbuild@0.25.1:
optionalDependencies:
'@esbuild/aix-ppc64': 0.25.1
'@esbuild/android-arm': 0.25.1
'@esbuild/android-arm64': 0.25.1
'@esbuild/android-x64': 0.25.1
'@esbuild/darwin-arm64': 0.25.1
'@esbuild/darwin-x64': 0.25.1
'@esbuild/freebsd-arm64': 0.25.1
'@esbuild/freebsd-x64': 0.25.1
'@esbuild/linux-arm': 0.25.1
'@esbuild/linux-arm64': 0.25.1
'@esbuild/linux-ia32': 0.25.1
'@esbuild/linux-loong64': 0.25.1
'@esbuild/linux-mips64el': 0.25.1
'@esbuild/linux-ppc64': 0.25.1
'@esbuild/linux-riscv64': 0.25.1
'@esbuild/linux-s390x': 0.25.1
'@esbuild/linux-x64': 0.25.1
'@esbuild/netbsd-arm64': 0.25.1
'@esbuild/netbsd-x64': 0.25.1
'@esbuild/openbsd-arm64': 0.25.1
'@esbuild/openbsd-x64': 0.25.1
'@esbuild/sunos-x64': 0.25.1
'@esbuild/win32-arm64': 0.25.1
'@esbuild/win32-ia32': 0.25.1
'@esbuild/win32-x64': 0.25.1
escape-string-regexp@4.0.0: {}
eslint-scope@8.2.0:
@ -1509,6 +1902,11 @@ snapshots:
dependencies:
flat-cache: 4.0.1
fill-keys@1.0.2:
dependencies:
is-object: 1.0.2
merge-descriptors: 1.0.3
fill-range@7.1.1:
dependencies:
to-regex-range: 5.0.1
@ -1528,6 +1926,12 @@ snapshots:
fsevents@2.3.3:
optional: true
function-bind@1.1.2: {}
get-tsconfig@4.10.0:
dependencies:
resolve-pkg-maps: 1.0.0
glob-parent@5.1.2:
dependencies:
is-glob: 4.0.3
@ -1542,6 +1946,10 @@ snapshots:
has-flag@4.0.0: {}
hasown@2.0.2:
dependencies:
function-bind: 1.1.2
ignore@5.3.2: {}
import-fresh@3.3.0:
@ -1551,6 +1959,10 @@ snapshots:
imurmurhash@0.1.4: {}
is-core-module@2.16.1:
dependencies:
hasown: 2.0.2
is-extglob@2.1.1: {}
is-glob@4.0.3:
@ -1559,6 +1971,8 @@ snapshots:
is-number@7.0.0: {}
is-object@1.0.2: {}
isexe@2.0.0: {}
js-tokens@4.0.0:
@ -1587,12 +2001,16 @@ snapshots:
dependencies:
p-locate: 5.0.0
lodash.get@4.4.2: {}
lodash.merge@4.6.2: {}
magic-string@0.30.17:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.0
merge-descriptors@1.0.3: {}
merge2@1.4.1: {}
micromatch@4.0.8:
@ -1608,6 +2026,8 @@ snapshots:
dependencies:
brace-expansion: 2.0.1
module-not-found-error@1.0.1: {}
moment@2.29.4: {}
ms@2.1.3: {}
@ -1646,6 +2066,8 @@ snapshots:
path-key@3.1.1: {}
path-parse@1.0.7: {}
picocolors@1.1.1:
optional: true
@ -1653,6 +2075,12 @@ snapshots:
prelude-ls@1.2.1: {}
proxyquire@2.1.3:
dependencies:
fill-keys: 1.0.2
module-not-found-error: 1.0.1
resolve: 1.22.10
punycode@2.3.1: {}
queue-microtask@1.2.3: {}
@ -1668,6 +2096,14 @@ snapshots:
resolve-from@4.0.0: {}
resolve-pkg-maps@1.0.0: {}
resolve@1.22.10:
dependencies:
is-core-module: 2.16.1
path-parse: 1.0.7
supports-preserve-symlinks-flag: 1.0.0
reusify@1.0.4: {}
rollup-plugin-dts@5.3.1(rollup@3.29.5)(typescript@5.7.2):
@ -1696,6 +2132,14 @@ snapshots:
shebang-regex@3.0.0: {}
sinon@20.0.0:
dependencies:
'@sinonjs/commons': 3.0.1
'@sinonjs/fake-timers': 13.0.5
'@sinonjs/samsam': 8.0.2
diff: 7.0.0
supports-color: 7.2.0
strip-json-comments@3.1.1: {}
style-mod@4.1.2: {}
@ -1704,6 +2148,8 @@ snapshots:
dependencies:
has-flag: 4.0.0
supports-preserve-symlinks-flag@1.0.0: {}
to-regex-range@5.0.1:
dependencies:
is-number: 7.0.0
@ -1714,10 +2160,21 @@ snapshots:
tslib@2.8.1: {}
tsx@4.19.3:
dependencies:
esbuild: 0.25.1
get-tsconfig: 4.10.0
optionalDependencies:
fsevents: 2.3.3
type-check@0.4.0:
dependencies:
prelude-ls: 1.2.1
type-detect@4.0.8: {}
type-detect@4.1.0: {}
typescript@5.7.2: {}
undici-types@6.20.0: {}

View file

@ -1,7 +1,7 @@
import { Vault, TAbstractFile, TFolder, EventRef } from "obsidian";
import { Vault, TAbstractFile, TFolder } from "obsidian";
import MetadataStore, { MANIFEST_FILE_NAME } from "./metadata-store";
import { GitHubSyncSettings } from "./settings/settings";
import Logger from "./logger";
import Logger, { LOG_FILE_NAME } from "./logger";
import GitHubSyncPlugin from "./main";
/**
@ -145,6 +145,9 @@ export default class EventsListener {
) {
// Obsidian recommends not syncing the workspace files
return false;
} else if (filePath === `${this.vault.configDir}/${LOG_FILE_NAME}`) {
// Don't sync the log file, doesn't make sense
return false;
} else if (
this.settings.syncConfigDir &&
filePath.startsWith(this.vault.configDir)

View file

@ -1,6 +1,7 @@
import { requestUrl } from "obsidian";
import Logger from "src/logger";
import { GitHubSyncSettings } from "src/settings/settings";
import { retryUntil } from "src/utils";
export type RepoContent = {
files: { [key: string]: GetTreeResponseItem };
@ -27,6 +28,13 @@ export type NewTreeRequestItem = {
content?: string;
};
/**
* Response received when we create a new binary blob on GitHub
*/
export type CreatedBlob = {
sha: string;
};
/**
* Represents a git blob response from the GitHub API.
*/
@ -68,22 +76,35 @@ export default class GithubClient {
/**
* Gets the content of the repo.
*
* @param retry Whether to retry the request on failure (default: false)
* @param maxRetries Maximum number of retry attempts (default: 5)
* @returns Array of files in the directory in the remote repo
*/
async getRepoContent(): Promise<RepoContent> {
const res = await requestUrl({
url: `https://api.github.com/repos/${this.settings.githubOwner}/${this.settings.githubRepo}/git/trees/${this.settings.githubBranch}?recursive=1`,
headers: this.headers(),
throw: false,
});
if (res.status < 200 || res.status >= 400) {
await this.logger.error("Failed to get repo content", res);
async getRepoContent({
retry = false,
maxRetries = 5,
} = {}): Promise<RepoContent> {
const response = await retryUntil(
async () => {
return requestUrl({
url: `https://api.github.com/repos/${this.settings.githubOwner}/${this.settings.githubRepo}/git/trees/${this.settings.githubBranch}?recursive=1`,
headers: this.headers(),
throw: false,
});
},
(res) => res.status !== 422, // Retry condition: only retry on 422 status
retry ? maxRetries : 0, // Use 0 retries if retry is false
);
if (response.status < 200 || response.status >= 400) {
await this.logger.error("Failed to get repo content", response);
throw new GithubAPIError(
res.status,
`Failed to get repo content, status ${res.status}`,
response.status,
`Failed to get repo content, status ${response.status}`,
);
}
const files = res.json.tree
const files = response.json.tree
.filter((file: GetTreeResponseItem) => file.type === "blob")
.reduce(
(
@ -92,129 +113,337 @@ export default class GithubClient {
) => ({ ...acc, [file.path]: file }),
{},
);
return { files, sha: res.json.sha };
return { files, sha: response.json.sha };
}
async createTree(tree: { tree: NewTreeRequestItem[]; base_tree: string }) {
const res = await requestUrl({
url: `https://api.github.com/repos/${this.settings.githubOwner}/${this.settings.githubRepo}/git/trees`,
headers: this.headers(),
method: "POST",
body: JSON.stringify(tree),
throw: false,
});
if (res.status < 200 || res.status >= 400) {
await this.logger.error("Failed to create tree", res);
/**
* Creates a new tree in the GitHub repository.
*
* @param tree The tree object to create
* @param retry Whether to retry the request on failure (default: false)
* @param maxRetries Maximum number of retry attempts (default: 5)
* @returns The SHA of the created tree
*/
async createTree({
tree,
retry = false,
maxRetries = 5,
}: {
tree: { tree: NewTreeRequestItem[]; base_tree: string };
retry?: boolean;
maxRetries?: number;
}) {
const response = await retryUntil(
async () => {
return requestUrl({
url: `https://api.github.com/repos/${this.settings.githubOwner}/${this.settings.githubRepo}/git/trees`,
headers: this.headers(),
method: "POST",
body: JSON.stringify(tree),
throw: false,
});
},
(res) => res.status !== 422,
retry ? maxRetries : 0,
);
if (response.status < 200 || response.status >= 400) {
await this.logger.error("Failed to create tree", response);
throw new GithubAPIError(
res.status,
`Failed to create tree, status ${res.status}`,
response.status,
`Failed to create tree, status ${response.status}`,
);
}
return res.json.sha;
return response.json.sha;
}
async createCommit(message: string, treeSha: string, parent: string) {
const res = await requestUrl({
url: `https://api.github.com/repos/${this.settings.githubOwner}/${this.settings.githubRepo}/git/commits`,
headers: this.headers(),
method: "POST",
body: JSON.stringify({
message: message,
tree: treeSha,
parents: [parent],
}),
throw: false,
});
if (res.status < 200 || res.status >= 400) {
await this.logger.error("Failed to create commit", res);
/**
* Creates a new commit in the repository.
*
* @param message The commit message
* @param treeSha The SHA of the tree
* @param parent The SHA of the parent commit
* @param retry Whether to retry the request on failure (default: false)
* @param maxRetries Maximum number of retry attempts (default: 5)
* @returns The SHA of the created commit
*/
async createCommit({
message,
treeSha,
parent,
retry = false,
maxRetries = 5,
}: {
message: string;
treeSha: string;
parent: string;
retry?: boolean;
maxRetries?: number;
}): Promise<string> {
const response = await retryUntil(
async () => {
return requestUrl({
url: `https://api.github.com/repos/${this.settings.githubOwner}/${this.settings.githubRepo}/git/commits`,
headers: this.headers(),
method: "POST",
body: JSON.stringify({
message: message,
tree: treeSha,
parents: [parent],
}),
throw: false,
});
},
(res) => res.status !== 422,
retry ? maxRetries : 0,
);
if (response.status < 200 || response.status >= 400) {
await this.logger.error("Failed to create commit", response);
throw new GithubAPIError(
res.status,
`Failed to create commit, status ${res.status}`,
response.status,
`Failed to create commit, status ${response.status}`,
);
}
return res.json.sha;
return response.json.sha;
}
async getBranchHeadSha() {
const res = await requestUrl({
url: `https://api.github.com/repos/${this.settings.githubOwner}/${this.settings.githubRepo}/git/refs/heads/${this.settings.githubBranch}`,
headers: this.headers(),
throw: false,
});
if (res.status < 200 || res.status >= 400) {
await this.logger.error("Failed to get branch head sha", res);
/**
* Gets the SHA of the branch head.
*
* @param retry Whether to retry the request on failure (default: false)
* @param maxRetries Maximum number of retry attempts (default: 5)
* @returns The SHA of the branch head
*/
async getBranchHeadSha({ retry = false, maxRetries = 5 } = {}) {
const response = await retryUntil(
async () => {
return requestUrl({
url: `https://api.github.com/repos/${this.settings.githubOwner}/${this.settings.githubRepo}/git/refs/heads/${this.settings.githubBranch}`,
headers: this.headers(),
throw: false,
});
},
(res) => res.status !== 422,
retry ? maxRetries : 0,
);
if (response.status < 200 || response.status >= 400) {
await this.logger.error("Failed to get branch head sha", response);
throw new GithubAPIError(
res.status,
`Failed to get branch head sha, status ${res.status}`,
response.status,
`Failed to get branch head sha, status ${response.status}`,
);
}
return res.json.object.sha;
return response.json.object.sha;
}
async updateBranchHead(sha: string) {
const res = await requestUrl({
url: `https://api.github.com/repos/${this.settings.githubOwner}/${this.settings.githubRepo}/git/refs/heads/${this.settings.githubBranch}`,
headers: this.headers(),
method: "PATCH",
body: JSON.stringify({
sha: sha,
}),
throw: false,
});
if (res.status < 200 || res.status >= 400) {
await this.logger.error("Failed to update branch head sha", res);
/**
* Updates the branch head to point to a new commit.
*
* @param sha The SHA of the commit to point to
* @param retry Whether to retry the request on failure (default: false)
* @param maxRetries Maximum number of retry attempts (default: 5)
*/
async updateBranchHead({
sha,
retry = false,
maxRetries = 5,
}: {
sha: string;
retry?: boolean;
maxRetries?: number;
}) {
const response = await retryUntil(
async () => {
return requestUrl({
url: `https://api.github.com/repos/${this.settings.githubOwner}/${this.settings.githubRepo}/git/refs/heads/${this.settings.githubBranch}`,
headers: this.headers(),
method: "PATCH",
body: JSON.stringify({
sha: sha,
}),
throw: false,
});
},
(res) => res.status !== 422,
retry ? maxRetries : 0,
);
if (response.status < 200 || response.status >= 400) {
await this.logger.error("Failed to update branch head sha", response);
throw new GithubAPIError(
res.status,
`Failed to update branch head sha, status ${res.status}`,
response.status,
`Failed to update branch head sha, status ${response.status}`,
);
}
}
/**
* Gets a blob from its sha
* @param url blob sha
* Creates a new blob in the GitHub remote, this is mainly used to upload binary files.
*
* @param content The content of the blob to upload
* @param encoding Content encoding, can be "utf-8" or "base64". Defaults to "base64"
* @param retry Whether to retry the request on failure (default: false)
* @param maxRetries Maximum number of retry attempts (default: 5)
* @returns The SHA of the newly uploaded blob
*/
async getBlob(sha: string): Promise<BlobFile> {
const res = await requestUrl({
url: `https://api.github.com/repos/${this.settings.githubOwner}/${this.settings.githubRepo}/git/blobs/${sha}`,
headers: this.headers(),
throw: false,
});
if (res.status < 200 || res.status >= 400) {
await this.logger.error("Failed to get blob", res);
async createBlob({
content,
encoding = "base64",
retry = false,
maxRetries = 5,
}: {
content: string;
encoding?: "utf-8" | "base64";
retry?: boolean;
maxRetries?: number;
}): Promise<CreatedBlob> {
const response = await retryUntil(
async () => {
return requestUrl({
url: `https://api.github.com/repos/${this.settings.githubOwner}/${this.settings.githubRepo}/git/blobs`,
headers: this.headers(),
method: "POST",
body: JSON.stringify({ content, encoding }),
throw: false,
});
},
(res) => res.status !== 422,
retry ? maxRetries : 0,
);
if (response.status < 200 || response.status >= 400) {
await this.logger.error("Failed to create blob", response);
throw new GithubAPIError(
res.status,
`Failed to get blob, status ${res.status}`,
response.status,
`Failed to create blob, status ${response.status}`,
);
}
return res.json;
return {
sha: response.json["sha"],
};
}
/**
* Gets a blob from its sha
*
* @param sha The SHA of the blob
* @param retry Whether to retry the request on failure (default: false)
* @param maxRetries Maximum number of retry attempts (default: 5)
* @returns The blob file
*/
async getBlob({
sha,
retry = false,
maxRetries = 5,
}: {
sha: string;
retry?: boolean;
maxRetries?: number;
}): Promise<BlobFile> {
const response = await retryUntil(
async () => {
return requestUrl({
url: `https://api.github.com/repos/${this.settings.githubOwner}/${this.settings.githubRepo}/git/blobs/${sha}`,
headers: this.headers(),
throw: false,
});
},
(res) => res.status !== 422,
retry ? maxRetries : 0,
);
if (response.status < 200 || response.status >= 400) {
await this.logger.error("Failed to get blob", response);
throw new GithubAPIError(
response.status,
`Failed to get blob, status ${response.status}`,
);
}
return response.json;
}
/**
* Create a new file in the repo, the content must be base64 encoded or the request will fail.
*
* @param message commit message
* @param path path to create in the repo
* @param content base64 encoded content of the file
* @param path Path to create in the repo
* @param content Base64 encoded content of the file
* @param message Commit message
* @param retry Whether to retry the request on failure (default: false)
* @param maxRetries Maximum number of retry attempts (default: 5)
*/
async createFile(path: string, content: string, message: string) {
const res = await requestUrl({
url: `https://api.github.com/repos/${this.settings.githubOwner}/${this.settings.githubRepo}/contents/${path}`,
headers: this.headers(),
method: "PUT",
body: JSON.stringify({
message: message,
content: content,
branch: this.settings.githubBranch,
}),
throw: false,
});
if (res.status < 200 || res.status >= 400) {
await this.logger.error("Failed to create file", res);
async createFile({
path,
content,
message,
retry = false,
maxRetries = 5,
}: {
path: string;
content: string;
message: string;
retry?: boolean;
maxRetries?: number;
}) {
const response = await retryUntil(
async () => {
return requestUrl({
url: `https://api.github.com/repos/${this.settings.githubOwner}/${this.settings.githubRepo}/contents/${path}`,
headers: this.headers(),
method: "PUT",
body: JSON.stringify({
message: message,
content: content,
branch: this.settings.githubBranch,
}),
throw: false,
});
},
(res) => res.status !== 422,
retry ? maxRetries : 0,
);
if (response.status < 200 || response.status >= 400) {
await this.logger.error("Failed to create file", response);
throw new GithubAPIError(
res.status,
`Failed to create file, status ${res.status}`,
response.status,
`Failed to create file, status ${response.status}`,
);
}
}
/**
* Downloads the repository as a ZIP archive from GitHub.
*
* @param retry Whether to retry the request on failure (default: false)
* @param maxRetries Maximum number of retry attempts (default: 5)
* @returns The archive contents as an ArrayBuffer
*/
async downloadRepositoryArchive({
retry = false,
maxRetries = 5,
} = {}): Promise<ArrayBuffer> {
const response = await retryUntil(
async () => {
return requestUrl({
url: `https://api.github.com/repos/${this.settings.githubOwner}/${this.settings.githubRepo}/zipball/${this.settings.githubBranch}`,
headers: this.headers(),
method: "GET",
throw: false,
});
},
(res) => res.status !== 422,
retry ? maxRetries : 0,
);
if (response.status < 200 || response.status >= 400) {
await this.logger.error("Failed to download zip archive", response);
throw new GithubAPIError(
response.status,
`Failed to download zip archive, status ${response.status}`,
);
}
return response.arrayBuffer;
}
}

View file

@ -1,6 +1,6 @@
import { Vault, normalizePath } from "obsidian";
const LOG_FILE_NAME = "github-sync.log" as const;
export const LOG_FILE_NAME = "github-sync.log" as const;
export default class Logger {
private logFile: string;
@ -40,6 +40,14 @@ export default class Logger {
);
}
async read(): Promise<string> {
return await this.vault.adapter.read(this.logFile);
}
async clean(): Promise<void> {
return await this.vault.adapter.write(this.logFile, "");
}
enable(): void {
this.enabled = true;
}

View file

@ -152,9 +152,19 @@ export default class GitHubSyncPlugin extends Plugin {
return;
}
if (this.settings.firstSync) {
await this.syncManager.firstSync();
this.settings.firstSync = false;
this.saveSettings();
const notice = new Notice("Syncing...");
try {
await this.syncManager.firstSync();
this.settings.firstSync = false;
this.saveSettings();
// Shown only if sync doesn't fail
new Notice("Sync successful", 5000);
} catch (err) {
// Show the error to the user, it's not automatically dismissed to make sure
// the user sees it.
new Notice(`Error syncing. ${err}`);
}
notice.hide();
} else {
await this.syncManager.sync();
}

View file

@ -1,5 +1,13 @@
import { PluginSettingTab, App, Setting, TextComponent, Modal } from "obsidian";
import {
PluginSettingTab,
App,
Setting,
TextComponent,
Modal,
Notice,
} from "obsidian";
import GitHubSyncPlugin from "src/main";
import { copyToClipboard } from "src/utils";
export default class GitHubSyncSettingsTab extends PluginSettingTab {
plugin: GitHubSyncPlugin;
@ -268,6 +276,30 @@ export default class GitHubSyncSettingsTab extends PluginSettingTab {
});
});
new Setting(containerEl)
.setName("Copy logs")
.setDesc("Copy the log file content, this is useful to report bugs.")
.addButton((button) => {
button.setButtonText("Copy").onClick(async () => {
const logs: string = await this.plugin.logger.read();
try {
await copyToClipboard(logs);
new Notice("Logs copied", 5000);
} catch (err) {
new Notice(`Failed copying logs: ${err}`, 10000);
}
});
});
new Setting(containerEl)
.setName("Clean logs")
.setDesc("Delete all existing logs.")
.addButton((button) => {
button.setButtonText("Clean").onClick(async () => {
await this.plugin.logger.clean();
});
});
new Setting(containerEl)
.setName("Reset")
.setDesc("Reset the plugin settings and metadata")

View file

@ -3,7 +3,7 @@ import {
Notice,
normalizePath,
base64ToArrayBuffer,
EventRef,
arrayBufferToBase64,
} from "obsidian";
import GithubClient, {
GetTreeResponseItem,
@ -17,9 +17,10 @@ import MetadataStore, {
} from "./metadata-store";
import EventsListener from "./events-listener";
import { GitHubSyncSettings } from "./settings/settings";
import Logger from "./logger";
import { decodeBase64String } from "./utils";
import Logger, { LOG_FILE_NAME } from "./logger";
import { decodeBase64String, hasTextExtension } from "./utils";
import GitHubSyncPlugin from "./main";
import { BlobReader, Entry, Uint8ArrayWriter, ZipReader } from "@zip.js/zip.js";
interface SyncAction {
type: "upload" | "download" | "delete_local" | "delete_remote";
@ -94,19 +95,14 @@ export default class SyncManager {
return;
}
const notice = new Notice("Syncing...");
this.syncing = true;
try {
await this.firstSyncImpl();
// Shown only if sync doesn't fail
new Notice("Sync successful", 5000);
} catch (err) {
// Show the error to the user, it's not automatically dismissed to make sure
// the user sees it.
new Notice(`Error syncing. ${err}`);
this.syncing = false;
throw err;
}
this.syncing = false;
notice.hide();
}
private async firstSyncImpl() {
@ -141,14 +137,18 @@ export default class SyncManager {
// API doesn't let us create a new tree when the repo is empty.
// So we create a the manifest file as the first commit, since we're going
// to create that in any case right after this.
await this.client.createFile(
`${this.vault.configDir}/${MANIFEST_FILE_NAME}`,
"",
"First sync",
const buffer = await this.vault.adapter.readBinary(
normalizePath(`${this.vault.configDir}/${MANIFEST_FILE_NAME}`),
);
await this.client.createFile({
path: `${this.vault.configDir}/${MANIFEST_FILE_NAME}`,
content: arrayBufferToBase64(buffer),
message: "First sync",
retry: true,
});
// Now get the repo content again cause we know for sure it will return a
// valid sha that we can use to create the first sync commit.
res = await this.client.getRepoContent();
res = await this.client.getRepoContent({ retry: true });
files = res.files;
treeSha = res.sha;
}
@ -186,24 +186,98 @@ export default class SyncManager {
treeSha: string,
) {
await this.logger.info("Starting first sync from remote files");
// We want to avoid getting throttled by GitHub, so instead of making a request for each
// file we download the whole repository as a ZIP file and extract it in the vault.
// We exclude config dir files if the user doesn't want to sync those.
const zipBuffer = await this.client.downloadRepositoryArchive();
const zipBlob = new Blob([zipBuffer]);
const reader = new ZipReader(new BlobReader(zipBlob));
const entries = await reader.getEntries();
await this.logger.info("Extracting files from ZIP", {
length: entries.length,
});
await Promise.all(
Object.keys(files)
.filter((filePath: string) => {
if (
this.settings.syncConfigDir &&
filePath.startsWith(this.vault.configDir) &&
filePath !== `${this.vault.configDir}/${MANIFEST_FILE_NAME}`
) {
// Include files in the config dir only if the user has enabled it.
// The metadata file must always be synced.
return false;
}
return true;
})
.map(async (filePath: string) => {
await this.downloadFile(files[filePath], Date.now());
}),
entries.map(async (entry: Entry) => {
// All repo ZIPs contain a root directory that contains all the content
// of that repo, we need to ignore that directory so we strip the first
// folder segment from the path
const pathParts = entry.filename.split("/");
const targetPath =
pathParts.length > 1 ? pathParts.slice(1).join("/") : entry.filename;
if (targetPath === "") {
// Must be the root folder, skip it.
// This is really important as that would lead us to try and
// create the folder "/" and crash Obsidian
return;
}
if (
this.settings.syncConfigDir &&
targetPath.startsWith(this.vault.configDir) &&
targetPath !== `${this.vault.configDir}/${MANIFEST_FILE_NAME}`
) {
await this.logger.info("Skipped config", { targetPath });
return;
}
if (entry.directory) {
const normalizedPath = normalizePath(targetPath);
await this.vault.adapter.mkdir(normalizedPath);
await this.logger.info("Created directory", {
normalizedPath,
});
return;
}
if (targetPath === `${this.vault.configDir}/${LOG_FILE_NAME}`) {
// We don't want to download the log file if the user synced it in the past.
// This is necessary because in the past we forgot to ignore the log file
// from syncing if the user enabled configs sync.
// To avoid downloading it we ignore it if still present in the remote repo.
return;
}
if (targetPath.split("/").last()?.startsWith(".")) {
// We must skip hidden files as that creates issues with syncing.
// This is fine as users can't edit hidden files in Obsidian anyway.
await this.logger.info("Skipping hidden file", targetPath);
return;
}
const writer = new Uint8ArrayWriter();
await entry.getData!(writer);
const data = await writer.getData();
const dir = targetPath.split("/").splice(0, -1).join("/");
if (dir !== "") {
const normalizedDir = normalizePath(dir);
await this.vault.adapter.mkdir(normalizedDir);
await this.logger.info("Created directory", {
normalizedDir,
});
}
const normalizedPath = normalizePath(targetPath);
await this.vault.adapter.writeBinary(normalizedPath, data);
await this.logger.info("Written file", {
normalizedPath,
});
this.metadataStore.data.files[normalizedPath] = {
path: normalizedPath,
sha: files[normalizedPath].sha,
dirty: false,
justDownloaded: true,
lastModified: Date.now(),
};
await this.metadataStore.save();
}),
);
await this.logger.info("Extracted zip");
const newTreeFiles = Object.keys(files)
.map((filePath: string) => ({
path: files[filePath].path,
@ -220,18 +294,33 @@ export default class SyncManager {
);
// Add files that are in the manifest but not in the tree.
await Promise.all(
Object.keys(this.metadataStore.data.files).map(
async (filePath: string) => {
Object.keys(this.metadataStore.data.files)
.filter((filePath: string) => {
return !Object.keys(files).contains(filePath);
})
.map(async (filePath: string) => {
const normalizedPath = normalizePath(filePath);
const content = await this.vault.adapter.read(normalizedPath);
// We need to check whether the file is a text file or not before
// reading it here because trying to read a binary file as text fails
// on iOS, and probably on other mobile devices too, so we read the file
// content only if we're sure it contains text only.
//
// It's fine not reading the binary file in here and just setting some bogus
// content because when committing the sync we're going to read the binary
// file and upload its blob if it needs to be synced. The important thing is
// that some content is set so we know the file changed locally and needs to be
// uploaded.
let content = "binaryfile";
if (hasTextExtension(normalizedPath)) {
content = await this.vault.adapter.read(normalizedPath);
}
newTreeFiles[filePath] = {
path: filePath,
mode: "100644",
type: "blob",
content: content,
content,
};
},
),
}),
);
await this.commitSync(newTreeFiles, treeSha);
}
@ -264,18 +353,36 @@ export default class SyncManager {
{},
);
await Promise.all(
Object.keys(this.metadataStore.data.files).map(
async (filePath: string) => {
Object.keys(this.metadataStore.data.files)
.filter((filePath: string) => {
// We should not try to sync deleted files, this can happen when
// the user renames or deletes files after enabling the plugin but
// before syncing for the first time
return !this.metadataStore.data.files[filePath].deleted;
})
.map(async (filePath: string) => {
const normalizedPath = normalizePath(filePath);
const content = await this.vault.adapter.read(normalizedPath);
// We need to check whether the file is a text file or not before
// reading it here because trying to read a binary file as text fails
// on iOS, and probably on other mobile devices too, so we read the file
// content only if we're sure it contains text only.
//
// It's fine not reading the binary file in here and just setting some bogus
// content because when committing the sync we're going to read the binary
// file and upload its blob if it needs to be synced. The important thing is
// that some content is set so we know the file changed locally and needs to be
// uploaded.
let content = "binaryfile";
if (hasTextExtension(normalizedPath)) {
content = await this.vault.adapter.read(normalizedPath);
}
newTreeFiles[filePath] = {
path: filePath,
mode: "100644",
type: "blob",
content: content,
content,
};
},
),
}),
);
await this.commitSync(newTreeFiles, treeSha);
}
@ -308,7 +415,9 @@ export default class SyncManager {
private async syncImpl() {
await this.logger.info("Starting sync");
const { files, sha: treeSha } = await this.client.getRepoContent();
const { files, sha: treeSha } = await this.client.getRepoContent({
retry: true,
});
const manifest = files[`${this.vault.configDir}/${MANIFEST_FILE_NAME}`];
if (manifest === undefined) {
@ -316,7 +425,17 @@ export default class SyncManager {
throw new Error("Remote manifest is missing");
}
const blob = await this.client.getBlob(manifest.sha);
if (
Object.keys(files).contains(`${this.vault.configDir}/${LOG_FILE_NAME}`)
) {
// We don't want to download the log file if the user synced it in the past.
// This is necessary because in the past we forgot to ignore the log file
// from syncing if the user enabled configs sync.
// To avoid downloading it we delete it if still around.
delete files[`${this.vault.configDir}/${LOG_FILE_NAME}`];
}
const blob = await this.client.getBlob({ sha: manifest.sha });
const remoteMetadata: Metadata = JSON.parse(
decodeBase64String(blob.content),
);
@ -512,9 +631,11 @@ export default class SyncManager {
// Load contents in parallel
const [remoteContent, localContent] = await Promise.all([
await (async () => {
const res = await this.client.getBlob(
filesMetadata[filePath].sha!,
);
const res = await this.client.getBlob({
sha: filesMetadata[filePath].sha!,
retry: true,
maxRetries: 1,
});
return decodeBase64String(res.content);
})(),
await this.vault.adapter.read(normalizePath(filePath)),
@ -579,16 +700,19 @@ export default class SyncManager {
type: "delete_local",
filePath: filePath,
});
return;
} else if (
localFile.lastModified > (remoteFile.deletedAt as number)
) {
actions.push({ type: "upload", filePath: filePath });
return;
}
}
if (!remoteFile.deleted && localFile.deleted) {
if (remoteFile.lastModified > (localFile.deletedAt as number)) {
actions.push({ type: "download", filePath: filePath });
return;
} else if (
(localFile.deletedAt as number) > remoteFile.lastModified
) {
@ -596,6 +720,7 @@ export default class SyncManager {
type: "delete_remote",
filePath: filePath,
});
return;
}
}
@ -603,8 +728,10 @@ export default class SyncManager {
// Conflicts are already filtered out so we can make this decision easily
if (localSHA !== localFile.sha) {
actions.push({ type: "upload", filePath: filePath });
return;
} else {
actions.push({ type: "download", filePath: filePath });
return;
}
}),
);
@ -663,11 +790,15 @@ export default class SyncManager {
* This is the same identical algoritm used by git to calculate
* a blob's SHA.
* @param filePath normalized path to file
* @returns String containing the file SHA1
* @returns String containing the file SHA1 or null in case the file doesn't exist
*/
async calculateSHA(filePath: string): Promise<string> {
const content = await this.vault.adapter.read(filePath);
const contentBytes = new TextEncoder().encode(content);
async calculateSHA(filePath: string): Promise<string | null> {
if (!(await this.vault.adapter.exists(filePath))) {
// The file doesn't exist, can't calculate any SHA
return null;
}
const contentBuffer = await this.vault.adapter.readBinary(filePath);
const contentBytes = new Uint8Array(contentBuffer);
const header = new TextEncoder().encode(`blob ${contentBytes.length}\0`);
const store = new Uint8Array([...header, ...contentBytes]);
return await crypto.subtle.digest("SHA-1", store).then((hash) =>
@ -713,12 +844,38 @@ export default class SyncManager {
// We don't save the metadata file after setting the SHAs cause we do that when
// the sync is fully commited at the end.
// TODO: Understand whether it's a problem we don't revert the SHA setting in case of sync failure
//
// In here we also upload blob is file is a binary. We do it here because when uploading a blob we
// also get back its SHA, so we can set it together with other files.
// We also do that right before creating the new tree because we need the SHAs of those blob to
// correctly create it.
await Promise.all(
Object.keys(treeFiles)
.filter((filePath: string) => treeFiles[filePath].content)
.map(async (filePath: string) => {
const newSha = await this.calculateSHA(filePath);
this.metadataStore.data.files[filePath].sha = newSha;
// I don't fully trust file extensions as they're not completely reliable
// to determine the file type, though I feel it's ok to compromise and rely
// on them if it makes the plugin handle upload better on certain devices.
if (hasTextExtension(filePath)) {
const sha = await this.calculateSHA(filePath);
this.metadataStore.data.files[filePath].sha = sha;
return;
}
// We can't upload binary files by setting the content of a tree item,
// we first need to create a Git blob by uploading the file, then
// we must update the tree item to point the SHA to the blob we just created.
const buffer = await this.vault.adapter.readBinary(filePath);
const { sha } = await this.client.createBlob({
content: arrayBufferToBase64(buffer),
retry: true,
maxRetries: 3,
});
await this.logger.info("Created blob", filePath);
treeFiles[filePath].sha = sha;
// Can't have both sha and content set, so we delete it
delete treeFiles[filePath].content;
this.metadataStore.data.files[filePath].sha = sha;
}),
);
@ -734,18 +891,21 @@ export default class SyncManager {
),
base_tree: baseTreeSha,
};
const newTreeSha = await this.client.createTree(newTree);
const newTreeSha = await this.client.createTree({
tree: newTree,
retry: true,
});
const branchHeadSha = await this.client.getBranchHeadSha();
const branchHeadSha = await this.client.getBranchHeadSha({ retry: true });
const commitSha = await this.client.createCommit(
const commitSha = await this.client.createCommit({
// TODO: Make this configurable or find a nicer commit message
"Sync",
newTreeSha,
branchHeadSha,
);
message: "Sync",
treeSha: newTreeSha,
parent: branchHeadSha,
});
await this.client.updateBranchHead(commitSha);
await this.client.updateBranchHead({ sha: commitSha, retry: true });
// Update the local content of all files that had conflicts we resolved
await Promise.all(
@ -772,7 +932,7 @@ export default class SyncManager {
// File already exists and has the same SHA, no need to download it again.
return;
}
const blob = await this.client.getBlob(file.sha);
const blob = await this.client.getBlob({ sha: file.sha, retry: true });
const normalizedPath = normalizePath(file.path);
const fileFolder = normalizePath(
normalizedPath.split("/").slice(0, -1).join("/"),

View file

@ -1,5 +1,15 @@
import { base64ToArrayBuffer } from "obsidian";
const TEXT_EXTENSIONS = [
".css",
".md",
".json",
".txt",
".csv",
".js",
".log",
] as const;
/**
* Decodes a base64 encoded string, this properly
* handles emojis and other non ASCII chars.
@ -12,3 +22,78 @@ export function decodeBase64String(s: string): string {
const decoder = new TextDecoder();
return decoder.decode(buffer);
}
/**
* Copies the provided text to the system clipboard.
* Uses the modern Clipboard API with a fallback to older APIs.
*
* @param text The string to be copied to clipboard
* @returns A promise that resolves when the text has been copied
*/
export async function copyToClipboard(text: string) {
try {
await navigator.clipboard.writeText(text);
} catch (err) {
// Fallback for devices like iOS that don't support Clipboard API
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.setAttribute("readonly", "");
textarea.style.position = "absolute";
textarea.style.left = "-9999px";
document.body.appendChild(textarea);
textarea.select();
document.execCommand("copy");
document.body.removeChild(textarea);
}
}
/**
* Checks if a file path has one of the predefined text extensions.
* This is a best guess at best.
*
* @param filePath The path of the file to check
* @returns True if the file has a text extension, false otherwise
*/
export function hasTextExtension(filePath: string) {
for (const extension of TEXT_EXTENSIONS) {
if (filePath.endsWith(extension)) {
return true;
}
}
return false;
}
/**
* Retries an async function until its return value satisfies a condition or max retries is reached.
* Uses exponential backoff between retry attempts.
*
* @param fn - The async function to execute and potentially retry
* @param condition - Function that evaluates if the result is acceptable
* @param maxRetries - Maximum number of retry attempts (default: 5)
* @param initialDelay - Initial delay in ms before first retry (default: 1000)
* @param backoffFactor - Multiplicative factor for delay between retries (default: 2)
* @returns The result of the function execution
*/
export async function retryUntil<T>(
fn: () => Promise<T>,
condition: (result: T) => boolean,
maxRetries: number = 5,
initialDelay: number = 1000,
backoffFactor: number = 2,
): Promise<T> {
let retries = 0;
let delay = initialDelay;
while (true) {
const result = await fn();
if (condition(result) || retries >= maxRetries) {
return result;
}
retries++;
await new Promise((resolve) => setTimeout(resolve, delay));
delay *= backoffFactor;
}
}

View file

@ -175,7 +175,6 @@ const ActionsGutter: React.FC<ActionsGutterProps> = ({
style={{
width: "100%",
height: "100%",
overflow: "hidden",
position: "relative",
backgroundColor: "var(--background-secondary)",
}}

View file

@ -36,13 +36,11 @@ const DiffView: React.FC<DiffViewProps> = ({
return (
<div
style={{
width: "100%",
height: "100%",
display: "flex",
overflow: "hidden",
}}
>
<div style={{ flex: 1, overflow: "hidden" }}>
<div style={{ flex: 1 }}>
<EditorPane
content={remoteText}
highlightPluginSpec={{
@ -142,7 +140,7 @@ const DiffView: React.FC<DiffViewProps> = ({
}}
/>
</div>
<div style={{ flex: 1, overflow: "hidden" }}>
<div style={{ flex: 1 }}>
<EditorPane
content={localText}
highlightPluginSpec={{

View file

@ -46,9 +46,9 @@ const SplitView = ({
<React.StrictMode>
<div
style={{
height: "100%",
display: "flex",
flexDirection: "column",
height: "100%",
justifyContent: "center",
}}
>
@ -88,21 +88,28 @@ const SplitView = ({
currentFile={currentFile?.filePath || ""}
setCurrentFileIndex={setCurrentFileIndex}
/>
<DiffView
remoteText={currentFile?.remoteContent || ""}
localText={currentFile?.localContent || ""}
onRemoteTextChange={(content: string) => {
const tempFiles = [...files];
tempFiles[currentFileIndex].remoteContent = content;
setFiles(tempFiles);
<div
style={{
overflow: "auto",
flex: 1,
}}
onLocalTextChange={(content: string) => {
const tempFiles = [...files];
tempFiles[currentFileIndex].localContent = content;
setFiles(tempFiles);
}}
onConflictResolved={onConflictResolved}
/>
>
<DiffView
remoteText={currentFile?.remoteContent || ""}
localText={currentFile?.localContent || ""}
onRemoteTextChange={(content: string) => {
const tempFiles = [...files];
tempFiles[currentFileIndex].remoteContent = content;
setFiles(tempFiles);
}}
onLocalTextChange={(content: string) => {
const tempFiles = [...files];
tempFiles[currentFileIndex].localContent = content;
setFiles(tempFiles);
}}
onConflictResolved={onConflictResolved}
/>
</div>
</>
)}
</div>

View file

@ -183,7 +183,15 @@ const DiffView: React.FC<DiffViewProps> = ({
}, [initialRemoteText, initialLocalText]);
return (
<div style={{ width: "100%", height: "100%", overflow: "hidden" }}>
<div
style={{
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
overflow: "hidden",
}}
>
<CodeMirror
value={doc}
height="100%"

View file

@ -42,6 +42,8 @@ const UnifiedView = ({
paddingTop: "var(--size-4-4)",
paddingBottom: "var(--size-4-4)",
borderBottom: "1px solid var(--background-modifier-border)",
display: "flex",
flexDirection: "column",
}}
>
<div
@ -67,7 +69,14 @@ const UnifiedView = ({
return (
<React.StrictMode>
<div>
<div
style={{
height: "100%",
display: "flex",
flexDirection: "column",
overflow: "auto",
}}
>
{files.length === 0 ? (
<div
style={{

View file

@ -4,4 +4,6 @@
.padless-conflicts-view-container {
padding: 0;
height: 100%;
width: 100%;
}

View file

@ -11,5 +11,7 @@
"1.0.2": "1.7.7",
"1.0.3": "1.7.7",
"1.0.4": "1.7.7",
"1.0.5": "1.7.7"
"1.0.5": "1.7.7",
"1.0.6": "1.7.7",
"1.0.7": "1.7.7"
}