Compare commits

...

12 commits

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
15 changed files with 171 additions and 56 deletions

View file

@ -20,14 +20,15 @@ jobs:
- name: Build plugin - name: Build plugin
run: | run: |
npm install npm install -g pnpm@latest-10
npm run build pnpm install
pnpm build
- name: Create release - name: Create release
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
if [[ "${{ github.ref_name }}" == *-beta ]]; then if [[ "${{ github.ref_name }}" == *-beta* ]]; then
gh release create "${{ github.ref_name }}" \ gh release create "${{ github.ref_name }}" \
--title="${{ github.ref_name }}" \ --title="${{ github.ref_name }}" \
--prerelease \ --prerelease \

View file

@ -126,6 +126,16 @@ To work correctly this plugin uses a custom metadata file that is updated every
Other plugins don't know about that file, so if you sync with others too you risk losing data. 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 ## License
The project is licensed under the [AGPLv3](https://www.gnu.org/licenses/agpl-3.0.en.html) license. The project is licensed under the [AGPLv3](https://www.gnu.org/licenses/agpl-3.0.en.html) license.

View file

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

View file

@ -1,6 +1,6 @@
{ {
"name": "github-gitless-sync", "name": "github-gitless-sync",
"version": "1.0.6", "version": "1.0.7",
"description": "Sync a GitHub repository with vaults on different platforms without requiring git installation", "description": "Sync a GitHub repository with vaults on different platforms without requiring git installation",
"main": "main.js", "main": "main.js",
"scripts": { "scripts": {

View file

@ -1,7 +1,7 @@
import { Vault, TAbstractFile, TFolder } from "obsidian"; import { Vault, TAbstractFile, TFolder } from "obsidian";
import MetadataStore, { MANIFEST_FILE_NAME } from "./metadata-store"; import MetadataStore, { MANIFEST_FILE_NAME } from "./metadata-store";
import { GitHubSyncSettings } from "./settings/settings"; import { GitHubSyncSettings } from "./settings/settings";
import Logger from "./logger"; import Logger, { LOG_FILE_NAME } from "./logger";
import GitHubSyncPlugin from "./main"; import GitHubSyncPlugin from "./main";
/** /**
@ -145,6 +145,9 @@ export default class EventsListener {
) { ) {
// Obsidian recommends not syncing the workspace files // Obsidian recommends not syncing the workspace files
return false; 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 ( } else if (
this.settings.syncConfigDir && this.settings.syncConfigDir &&
filePath.startsWith(this.vault.configDir) filePath.startsWith(this.vault.configDir)

View file

@ -1,6 +1,6 @@
import { Vault, normalizePath } from "obsidian"; 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 { export default class Logger {
private logFile: string; private logFile: string;

View file

@ -152,9 +152,19 @@ export default class GitHubSyncPlugin extends Plugin {
return; return;
} }
if (this.settings.firstSync) { if (this.settings.firstSync) {
await this.syncManager.firstSync(); const notice = new Notice("Syncing...");
this.settings.firstSync = false; try {
this.saveSettings(); 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 { } else {
await this.syncManager.sync(); await this.syncManager.sync();
} }

View file

@ -17,7 +17,7 @@ import MetadataStore, {
} from "./metadata-store"; } from "./metadata-store";
import EventsListener from "./events-listener"; import EventsListener from "./events-listener";
import { GitHubSyncSettings } from "./settings/settings"; import { GitHubSyncSettings } from "./settings/settings";
import Logger from "./logger"; import Logger, { LOG_FILE_NAME } from "./logger";
import { decodeBase64String, hasTextExtension } from "./utils"; import { decodeBase64String, hasTextExtension } from "./utils";
import GitHubSyncPlugin from "./main"; import GitHubSyncPlugin from "./main";
import { BlobReader, Entry, Uint8ArrayWriter, ZipReader } from "@zip.js/zip.js"; import { BlobReader, Entry, Uint8ArrayWriter, ZipReader } from "@zip.js/zip.js";
@ -95,19 +95,14 @@ export default class SyncManager {
return; return;
} }
const notice = new Notice("Syncing...");
this.syncing = true; this.syncing = true;
try { try {
await this.firstSyncImpl(); await this.firstSyncImpl();
// Shown only if sync doesn't fail
new Notice("Sync successful", 5000);
} catch (err) { } catch (err) {
// Show the error to the user, it's not automatically dismissed to make sure this.syncing = false;
// the user sees it. throw err;
new Notice(`Error syncing. ${err}`);
} }
this.syncing = false; this.syncing = false;
notice.hide();
} }
private async firstSyncImpl() { private async firstSyncImpl() {
@ -142,9 +137,12 @@ export default class SyncManager {
// API doesn't let us create a new tree when the repo is empty. // 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 // So we create a the manifest file as the first commit, since we're going
// to create that in any case right after this. // to create that in any case right after this.
const buffer = await this.vault.adapter.readBinary(
normalizePath(`${this.vault.configDir}/${MANIFEST_FILE_NAME}`),
);
await this.client.createFile({ await this.client.createFile({
path: `${this.vault.configDir}/${MANIFEST_FILE_NAME}`, path: `${this.vault.configDir}/${MANIFEST_FILE_NAME}`,
content: "", content: arrayBufferToBase64(buffer),
message: "First sync", message: "First sync",
retry: true, retry: true,
}); });
@ -235,6 +233,14 @@ export default class SyncManager {
return; 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(".")) { if (targetPath.split("/").last()?.startsWith(".")) {
// We must skip hidden files as that creates issues with syncing. // We must skip hidden files as that creates issues with syncing.
// This is fine as users can't edit hidden files in Obsidian anyway. // This is fine as users can't edit hidden files in Obsidian anyway.
@ -259,6 +265,14 @@ export default class SyncManager {
await this.logger.info("Written file", { await this.logger.info("Written file", {
normalizedPath, normalizedPath,
}); });
this.metadataStore.data.files[normalizedPath] = {
path: normalizedPath,
sha: files[normalizedPath].sha,
dirty: false,
justDownloaded: true,
lastModified: Date.now(),
};
await this.metadataStore.save();
}), }),
); );
@ -280,18 +294,33 @@ export default class SyncManager {
); );
// Add files that are in the manifest but not in the tree. // Add files that are in the manifest but not in the tree.
await Promise.all( await Promise.all(
Object.keys(this.metadataStore.data.files).map( Object.keys(this.metadataStore.data.files)
async (filePath: string) => { .filter((filePath: string) => {
return !Object.keys(files).contains(filePath);
})
.map(async (filePath: string) => {
const normalizedPath = normalizePath(filePath); 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] = { newTreeFiles[filePath] = {
path: filePath, path: filePath,
mode: "100644", mode: "100644",
type: "blob", type: "blob",
content: content, content,
}; };
}, }),
),
); );
await this.commitSync(newTreeFiles, treeSha); await this.commitSync(newTreeFiles, treeSha);
} }
@ -324,18 +353,36 @@ export default class SyncManager {
{}, {},
); );
await Promise.all( await Promise.all(
Object.keys(this.metadataStore.data.files).map( Object.keys(this.metadataStore.data.files)
async (filePath: string) => { .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 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] = { newTreeFiles[filePath] = {
path: filePath, path: filePath,
mode: "100644", mode: "100644",
type: "blob", type: "blob",
content: content, content,
}; };
}, }),
),
); );
await this.commitSync(newTreeFiles, treeSha); await this.commitSync(newTreeFiles, treeSha);
} }
@ -378,6 +425,16 @@ export default class SyncManager {
throw new Error("Remote manifest is missing"); throw new Error("Remote manifest is missing");
} }
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 blob = await this.client.getBlob({ sha: manifest.sha });
const remoteMetadata: Metadata = JSON.parse( const remoteMetadata: Metadata = JSON.parse(
decodeBase64String(blob.content), decodeBase64String(blob.content),
@ -643,16 +700,19 @@ export default class SyncManager {
type: "delete_local", type: "delete_local",
filePath: filePath, filePath: filePath,
}); });
return;
} else if ( } else if (
localFile.lastModified > (remoteFile.deletedAt as number) localFile.lastModified > (remoteFile.deletedAt as number)
) { ) {
actions.push({ type: "upload", filePath: filePath }); actions.push({ type: "upload", filePath: filePath });
return;
} }
} }
if (!remoteFile.deleted && localFile.deleted) { if (!remoteFile.deleted && localFile.deleted) {
if (remoteFile.lastModified > (localFile.deletedAt as number)) { if (remoteFile.lastModified > (localFile.deletedAt as number)) {
actions.push({ type: "download", filePath: filePath }); actions.push({ type: "download", filePath: filePath });
return;
} else if ( } else if (
(localFile.deletedAt as number) > remoteFile.lastModified (localFile.deletedAt as number) > remoteFile.lastModified
) { ) {
@ -660,6 +720,7 @@ export default class SyncManager {
type: "delete_remote", type: "delete_remote",
filePath: filePath, filePath: filePath,
}); });
return;
} }
} }
@ -667,8 +728,10 @@ export default class SyncManager {
// Conflicts are already filtered out so we can make this decision easily // Conflicts are already filtered out so we can make this decision easily
if (localSHA !== localFile.sha) { if (localSHA !== localFile.sha) {
actions.push({ type: "upload", filePath: filePath }); actions.push({ type: "upload", filePath: filePath });
return;
} else { } else {
actions.push({ type: "download", filePath: filePath }); actions.push({ type: "download", filePath: filePath });
return;
} }
}), }),
); );
@ -727,9 +790,13 @@ export default class SyncManager {
* This is the same identical algoritm used by git to calculate * This is the same identical algoritm used by git to calculate
* a blob's SHA. * a blob's SHA.
* @param filePath normalized path to file * @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> { 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 contentBuffer = await this.vault.adapter.readBinary(filePath);
const contentBytes = new Uint8Array(contentBuffer); const contentBytes = new Uint8Array(contentBuffer);
const header = new TextEncoder().encode(`blob ${contentBytes.length}\0`); const header = new TextEncoder().encode(`blob ${contentBytes.length}\0`);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -12,5 +12,6 @@
"1.0.3": "1.7.7", "1.0.3": "1.7.7",
"1.0.4": "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.6": "1.7.7",
"1.0.7": "1.7.7"
} }