mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-04-29 12:28:35 +00:00
11th March, 2026
Now, Self-hosted LiveSync has finally begun to be split into the Self-hosted LiveSync plugin for Obsidian, and a properly abstracted version of it. This may not offer much benefit to Obsidian plugin users, or might even cause a slight inconvenience, but I believe it will certainly help improve testability and make the ecosystem better. However, I do not see the point in putting something with little benefit into beta, so I am handling this on the alpha branch. I would actually preferred to create an R&D branch, but I was not keen on the ampersand, and I feel it will eventually become a proper beta anyway. ### Refactored - Separated `ObsidianLiveSyncPlugin` into `ObsidianLiveSyncPlugin` and `LiveSyncBaseCore`. - Now `LiveSyncCore` indicates the type specified version of `LiveSyncBaseCore`. - Referencing `plugin.xxx` has been rewritten to referencing the corresponding service or `core.xxx`. ### Internal API changes - Storage Access APIs are now yielding Promises. This is to allow more limited storage platforms to be supported. ### R&D - Browser-version of Self-hosted LiveSync is now in development. This is not intended for public use now, but I will eventually make it available for testing. - We can see the code in `src/apps/webapp` for the browser version.
This commit is contained in:
4
src/apps/webapp/.gitignore
vendored
Normal file
4
src/apps/webapp/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
.DS_Store
|
||||
*.log
|
||||
34
src/apps/webapp/adapters/FSAPIConversionAdapter.ts
Normal file
34
src/apps/webapp/adapters/FSAPIConversionAdapter.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { UXFileInfoStub, UXFolderInfo } from "../../../lib/src/common/types";
|
||||
import type { IConversionAdapter } from "../../../lib/src/serviceModules/adapters";
|
||||
import type { FSAPIFile, FSAPIFolder } from "./FSAPITypes";
|
||||
|
||||
/**
|
||||
* Conversion adapter implementation for FileSystem API
|
||||
*/
|
||||
export class FSAPIConversionAdapter implements IConversionAdapter<FSAPIFile, FSAPIFolder> {
|
||||
nativeFileToUXFileInfoStub(file: FSAPIFile): UXFileInfoStub {
|
||||
const pathParts = file.path.split("/");
|
||||
const name = pathParts[pathParts.length - 1] || file.handle.name;
|
||||
|
||||
return {
|
||||
name: name,
|
||||
path: file.path,
|
||||
stat: file.stat,
|
||||
isFolder: false,
|
||||
};
|
||||
}
|
||||
|
||||
nativeFolderToUXFolder(folder: FSAPIFolder): UXFolderInfo {
|
||||
const pathParts = folder.path.split("/");
|
||||
const name = pathParts[pathParts.length - 1] || folder.handle.name;
|
||||
const parentPath = pathParts.slice(0, -1).join("/");
|
||||
|
||||
return {
|
||||
name: name,
|
||||
path: folder.path,
|
||||
isFolder: true,
|
||||
children: [],
|
||||
parent: parentPath as any,
|
||||
};
|
||||
}
|
||||
}
|
||||
214
src/apps/webapp/adapters/FSAPIFileSystemAdapter.ts
Normal file
214
src/apps/webapp/adapters/FSAPIFileSystemAdapter.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import type { FilePath, UXStat } from "../../../lib/src/common/types";
|
||||
import type { IFileSystemAdapter } from "../../../lib/src/serviceModules/adapters";
|
||||
import { FSAPIPathAdapter } from "./FSAPIPathAdapter";
|
||||
import { FSAPITypeGuardAdapter } from "./FSAPITypeGuardAdapter";
|
||||
import { FSAPIConversionAdapter } from "./FSAPIConversionAdapter";
|
||||
import { FSAPIStorageAdapter } from "./FSAPIStorageAdapter";
|
||||
import { FSAPIVaultAdapter } from "./FSAPIVaultAdapter";
|
||||
import type { FSAPIFile, FSAPIFolder, FSAPIStat } from "./FSAPITypes";
|
||||
import { shareRunningResult } from "octagonal-wheels/concurrency/lock_v2";
|
||||
|
||||
/**
|
||||
* Complete file system adapter implementation for FileSystem API
|
||||
*/
|
||||
export class FSAPIFileSystemAdapter implements IFileSystemAdapter<FSAPIFile, FSAPIFile, FSAPIFolder, FSAPIStat> {
|
||||
readonly path: FSAPIPathAdapter;
|
||||
readonly typeGuard: FSAPITypeGuardAdapter;
|
||||
readonly conversion: FSAPIConversionAdapter;
|
||||
readonly storage: FSAPIStorageAdapter;
|
||||
readonly vault: FSAPIVaultAdapter;
|
||||
|
||||
private fileCache = new Map<string, FSAPIFile>();
|
||||
private handleCache = new Map<string, FileSystemFileHandle>();
|
||||
|
||||
constructor(private rootHandle: FileSystemDirectoryHandle) {
|
||||
this.path = new FSAPIPathAdapter();
|
||||
this.typeGuard = new FSAPITypeGuardAdapter();
|
||||
this.conversion = new FSAPIConversionAdapter();
|
||||
this.storage = new FSAPIStorageAdapter(rootHandle);
|
||||
this.vault = new FSAPIVaultAdapter(rootHandle);
|
||||
}
|
||||
|
||||
private normalisePath(path: FilePath | string): string {
|
||||
return this.path.normalisePath(path as string);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file handle for a given path
|
||||
*/
|
||||
private async getFileHandleByPath(p: FilePath | string): Promise<FileSystemFileHandle | null> {
|
||||
const pathStr = p as string;
|
||||
|
||||
// Check cache first
|
||||
const cached = this.handleCache.get(pathStr);
|
||||
if (cached) return cached;
|
||||
|
||||
try {
|
||||
const parts = pathStr.split("/").filter((part) => part !== "");
|
||||
if (parts.length === 0) return null;
|
||||
|
||||
let currentHandle: FileSystemDirectoryHandle = this.rootHandle;
|
||||
const fileName = parts[parts.length - 1];
|
||||
|
||||
// Navigate to the parent directory
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
currentHandle = await currentHandle.getDirectoryHandle(parts[i]);
|
||||
}
|
||||
|
||||
const fileHandle = await currentHandle.getFileHandle(fileName);
|
||||
this.handleCache.set(pathStr, fileHandle);
|
||||
return fileHandle;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getAbstractFileByPath(p: FilePath | string): Promise<FSAPIFile | null> {
|
||||
const pathStr = this.normalisePath(p);
|
||||
|
||||
const cached = this.fileCache.get(pathStr);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
return await this.refreshFile(pathStr);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
async getAbstractFileByPathInsensitive(p: FilePath | string): Promise<FSAPIFile | null> {
|
||||
const pathStr = this.normalisePath(p);
|
||||
const exact = await this.getAbstractFileByPath(pathStr);
|
||||
if (exact) {
|
||||
return exact;
|
||||
}
|
||||
// TODO: Refactor: Very, Very heavy.
|
||||
|
||||
const lowerPath = pathStr.toLowerCase();
|
||||
for (const [cachedPath, cachedFile] of this.fileCache.entries()) {
|
||||
if (cachedPath.toLowerCase() === lowerPath) {
|
||||
return cachedFile;
|
||||
}
|
||||
}
|
||||
|
||||
await this.scanDirectory();
|
||||
|
||||
for (const [cachedPath, cachedFile] of this.fileCache.entries()) {
|
||||
if (cachedPath.toLowerCase() === lowerPath) {
|
||||
return cachedFile;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async getFiles(): Promise<FSAPIFile[]> {
|
||||
if (this.fileCache.size === 0) {
|
||||
await this.scanDirectory();
|
||||
}
|
||||
return Array.from(this.fileCache.values());
|
||||
}
|
||||
|
||||
async statFromNative(file: FSAPIFile): Promise<UXStat> {
|
||||
// Refresh stat from the file handle
|
||||
try {
|
||||
const fileObject = await file.handle.getFile();
|
||||
return {
|
||||
size: fileObject.size,
|
||||
mtime: fileObject.lastModified,
|
||||
ctime: fileObject.lastModified,
|
||||
type: "file",
|
||||
};
|
||||
} catch {
|
||||
return file.stat;
|
||||
}
|
||||
}
|
||||
|
||||
async reconcileInternalFile(p: string): Promise<void> {
|
||||
// No-op in webapp version
|
||||
// This is used by Obsidian to sync internal file metadata
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh file cache for a specific path
|
||||
*/
|
||||
async refreshFile(p: string): Promise<FSAPIFile | null> {
|
||||
const pathStr = this.normalisePath(p);
|
||||
const handle = await this.getFileHandleByPath(pathStr);
|
||||
if (!handle) {
|
||||
this.fileCache.delete(pathStr);
|
||||
this.handleCache.delete(pathStr);
|
||||
return null;
|
||||
}
|
||||
|
||||
const fileObject = await handle.getFile();
|
||||
const file: FSAPIFile = {
|
||||
path: pathStr as FilePath,
|
||||
stat: {
|
||||
size: fileObject.size,
|
||||
mtime: fileObject.lastModified,
|
||||
ctime: fileObject.lastModified,
|
||||
type: "file",
|
||||
},
|
||||
handle: handle,
|
||||
};
|
||||
|
||||
this.fileCache.set(pathStr, file);
|
||||
this.handleCache.set(pathStr, handle);
|
||||
return file;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to recursively scan directory and populate file cache
|
||||
*/
|
||||
async scanDirectory(relativePath: string = ""): Promise<void> {
|
||||
return shareRunningResult("scanDirectory:" + relativePath, async () => {
|
||||
try {
|
||||
const parts = relativePath.split("/").filter((part) => part !== "");
|
||||
let currentHandle = this.rootHandle;
|
||||
|
||||
for (const part of parts) {
|
||||
currentHandle = await currentHandle.getDirectoryHandle(part);
|
||||
}
|
||||
|
||||
// Use AsyncIterator instead of .values() for better compatibility
|
||||
for await (const [name, entry] of (currentHandle as any).entries()) {
|
||||
const entryPath = relativePath ? `${relativePath}/${name}` : name;
|
||||
|
||||
if (entry.kind === "directory") {
|
||||
// Recursively scan subdirectories
|
||||
await this.scanDirectory(entryPath);
|
||||
} else if (entry.kind === "file") {
|
||||
const fileHandle = entry as FileSystemFileHandle;
|
||||
const fileObject = await fileHandle.getFile();
|
||||
|
||||
const file: FSAPIFile = {
|
||||
path: entryPath as FilePath,
|
||||
stat: {
|
||||
size: fileObject.size,
|
||||
mtime: fileObject.lastModified,
|
||||
ctime: fileObject.lastModified,
|
||||
type: "file",
|
||||
},
|
||||
handle: fileHandle,
|
||||
};
|
||||
|
||||
this.fileCache.set(entryPath, file);
|
||||
this.handleCache.set(entryPath, fileHandle);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error scanning directory ${relativePath}:`, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all caches
|
||||
*/
|
||||
clearCache(): void {
|
||||
this.fileCache.clear();
|
||||
this.handleCache.clear();
|
||||
}
|
||||
}
|
||||
18
src/apps/webapp/adapters/FSAPIPathAdapter.ts
Normal file
18
src/apps/webapp/adapters/FSAPIPathAdapter.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { FilePath } from "../../../lib/src/common/types";
|
||||
import type { IPathAdapter } from "../../../lib/src/serviceModules/adapters";
|
||||
import type { FSAPIFile } from "./FSAPITypes";
|
||||
|
||||
/**
|
||||
* Path adapter implementation for FileSystem API
|
||||
*/
|
||||
export class FSAPIPathAdapter implements IPathAdapter<FSAPIFile> {
|
||||
getPath(file: string | FSAPIFile): FilePath {
|
||||
return (typeof file === "string" ? file : file.path) as FilePath;
|
||||
}
|
||||
|
||||
normalisePath(p: string): string {
|
||||
// Normalize path separators to forward slashes (like Obsidian)
|
||||
// Remove leading/trailing slashes
|
||||
return p.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
|
||||
}
|
||||
}
|
||||
210
src/apps/webapp/adapters/FSAPIStorageAdapter.ts
Normal file
210
src/apps/webapp/adapters/FSAPIStorageAdapter.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import type { UXDataWriteOptions } from "../../../lib/src/common/types";
|
||||
import type { IStorageAdapter } from "../../../lib/src/serviceModules/adapters";
|
||||
import type { FSAPIStat } from "./FSAPITypes";
|
||||
|
||||
/**
|
||||
* Storage adapter implementation for FileSystem API
|
||||
*/
|
||||
export class FSAPIStorageAdapter implements IStorageAdapter<FSAPIStat> {
|
||||
constructor(private rootHandle: FileSystemDirectoryHandle) {}
|
||||
|
||||
/**
|
||||
* Resolve a path to directory and file handles
|
||||
*/
|
||||
private async resolvePath(p: string): Promise<{
|
||||
dirHandle: FileSystemDirectoryHandle;
|
||||
fileName: string;
|
||||
} | null> {
|
||||
try {
|
||||
const parts = p.split("/").filter((part) => part !== "");
|
||||
if (parts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let currentHandle = this.rootHandle;
|
||||
const fileName = parts[parts.length - 1];
|
||||
|
||||
// Navigate to the parent directory
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
currentHandle = await currentHandle.getDirectoryHandle(parts[i]);
|
||||
}
|
||||
|
||||
return { dirHandle: currentHandle, fileName };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file handle for a given path
|
||||
*/
|
||||
private async getFileHandle(p: string): Promise<FileSystemFileHandle | null> {
|
||||
const resolved = await this.resolvePath(p);
|
||||
if (!resolved) return null;
|
||||
|
||||
try {
|
||||
return await resolved.dirHandle.getFileHandle(resolved.fileName);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get directory handle for a given path
|
||||
*/
|
||||
private async getDirectoryHandle(p: string): Promise<FileSystemDirectoryHandle | null> {
|
||||
try {
|
||||
const parts = p.split("/").filter((part) => part !== "");
|
||||
if (parts.length === 0) {
|
||||
return this.rootHandle;
|
||||
}
|
||||
|
||||
let currentHandle = this.rootHandle;
|
||||
for (const part of parts) {
|
||||
currentHandle = await currentHandle.getDirectoryHandle(part);
|
||||
}
|
||||
|
||||
return currentHandle;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async exists(p: string): Promise<boolean> {
|
||||
const fileHandle = await this.getFileHandle(p);
|
||||
if (fileHandle) return true;
|
||||
|
||||
const dirHandle = await this.getDirectoryHandle(p);
|
||||
return dirHandle !== null;
|
||||
}
|
||||
|
||||
async trystat(p: string): Promise<FSAPIStat | null> {
|
||||
// Try as file first
|
||||
const fileHandle = await this.getFileHandle(p);
|
||||
if (fileHandle) {
|
||||
const file = await fileHandle.getFile();
|
||||
return {
|
||||
size: file.size,
|
||||
mtime: file.lastModified,
|
||||
ctime: file.lastModified,
|
||||
type: "file",
|
||||
};
|
||||
}
|
||||
|
||||
// Try as directory
|
||||
const dirHandle = await this.getDirectoryHandle(p);
|
||||
if (dirHandle) {
|
||||
return {
|
||||
size: 0,
|
||||
mtime: Date.now(),
|
||||
ctime: Date.now(),
|
||||
type: "folder",
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async stat(p: string): Promise<FSAPIStat | null> {
|
||||
return await this.trystat(p);
|
||||
}
|
||||
|
||||
async mkdir(p: string): Promise<void> {
|
||||
const parts = p.split("/").filter((part) => part !== "");
|
||||
let currentHandle = this.rootHandle;
|
||||
|
||||
for (const part of parts) {
|
||||
currentHandle = await currentHandle.getDirectoryHandle(part, { create: true });
|
||||
}
|
||||
}
|
||||
|
||||
async remove(p: string): Promise<void> {
|
||||
const resolved = await this.resolvePath(p);
|
||||
if (!resolved) return;
|
||||
|
||||
await resolved.dirHandle.removeEntry(resolved.fileName, { recursive: true });
|
||||
}
|
||||
|
||||
async read(p: string): Promise<string> {
|
||||
const fileHandle = await this.getFileHandle(p);
|
||||
if (!fileHandle) {
|
||||
throw new Error(`File not found: ${p}`);
|
||||
}
|
||||
|
||||
const file = await fileHandle.getFile();
|
||||
return await file.text();
|
||||
}
|
||||
|
||||
async readBinary(p: string): Promise<ArrayBuffer> {
|
||||
const fileHandle = await this.getFileHandle(p);
|
||||
if (!fileHandle) {
|
||||
throw new Error(`File not found: ${p}`);
|
||||
}
|
||||
|
||||
const file = await fileHandle.getFile();
|
||||
return await file.arrayBuffer();
|
||||
}
|
||||
|
||||
async write(p: string, data: string, options?: UXDataWriteOptions): Promise<void> {
|
||||
const resolved = await this.resolvePath(p);
|
||||
if (!resolved) {
|
||||
throw new Error(`Invalid path: ${p}`);
|
||||
}
|
||||
|
||||
// Ensure parent directory exists
|
||||
await this.mkdir(p.split("/").slice(0, -1).join("/"));
|
||||
|
||||
const fileHandle = await resolved.dirHandle.getFileHandle(resolved.fileName, { create: true });
|
||||
const writable = await fileHandle.createWritable();
|
||||
await writable.write(data);
|
||||
await writable.close();
|
||||
}
|
||||
|
||||
async writeBinary(p: string, data: ArrayBuffer, options?: UXDataWriteOptions): Promise<void> {
|
||||
const resolved = await this.resolvePath(p);
|
||||
if (!resolved) {
|
||||
throw new Error(`Invalid path: ${p}`);
|
||||
}
|
||||
|
||||
// Ensure parent directory exists
|
||||
await this.mkdir(p.split("/").slice(0, -1).join("/"));
|
||||
|
||||
const fileHandle = await resolved.dirHandle.getFileHandle(resolved.fileName, { create: true });
|
||||
const writable = await fileHandle.createWritable();
|
||||
await writable.write(data);
|
||||
await writable.close();
|
||||
}
|
||||
|
||||
async append(p: string, data: string, options?: UXDataWriteOptions): Promise<void> {
|
||||
const existing = await this.exists(p);
|
||||
if (existing) {
|
||||
const currentContent = await this.read(p);
|
||||
await this.write(p, currentContent + data, options);
|
||||
} else {
|
||||
await this.write(p, data, options);
|
||||
}
|
||||
}
|
||||
|
||||
async list(basePath: string): Promise<{ files: string[]; folders: string[] }> {
|
||||
const dirHandle = await this.getDirectoryHandle(basePath);
|
||||
if (!dirHandle) {
|
||||
return { files: [], folders: [] };
|
||||
}
|
||||
|
||||
const files: string[] = [];
|
||||
const folders: string[] = [];
|
||||
|
||||
// Use AsyncIterator instead of .values() for better compatibility
|
||||
for await (const [name, entry] of (dirHandle as any).entries()) {
|
||||
const entryPath = basePath ? `${basePath}/${name}` : name;
|
||||
|
||||
if (entry.kind === "directory") {
|
||||
folders.push(entryPath);
|
||||
} else if (entry.kind === "file") {
|
||||
files.push(entryPath);
|
||||
}
|
||||
}
|
||||
|
||||
return { files, folders };
|
||||
}
|
||||
}
|
||||
17
src/apps/webapp/adapters/FSAPITypeGuardAdapter.ts
Normal file
17
src/apps/webapp/adapters/FSAPITypeGuardAdapter.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { ITypeGuardAdapter } from "../../../lib/src/serviceModules/adapters";
|
||||
import type { FSAPIFile, FSAPIFolder } from "./FSAPITypes";
|
||||
|
||||
/**
|
||||
* Type guard adapter implementation for FileSystem API
|
||||
*/
|
||||
export class FSAPITypeGuardAdapter implements ITypeGuardAdapter<FSAPIFile, FSAPIFolder> {
|
||||
isFile(file: any): file is FSAPIFile {
|
||||
return (
|
||||
file && typeof file === "object" && "path" in file && "stat" in file && "handle" in file && !file.isFolder
|
||||
);
|
||||
}
|
||||
|
||||
isFolder(item: any): item is FSAPIFolder {
|
||||
return item && typeof item === "object" && "path" in item && item.isFolder === true && "handle" in item;
|
||||
}
|
||||
}
|
||||
24
src/apps/webapp/adapters/FSAPITypes.ts
Normal file
24
src/apps/webapp/adapters/FSAPITypes.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { FilePath, UXStat } from "../../../lib/src/common/types";
|
||||
|
||||
/**
|
||||
* FileSystem API file representation
|
||||
*/
|
||||
export type FSAPIFile = {
|
||||
path: FilePath;
|
||||
stat: UXStat;
|
||||
handle: FileSystemFileHandle;
|
||||
};
|
||||
|
||||
/**
|
||||
* FileSystem API folder representation
|
||||
*/
|
||||
export type FSAPIFolder = {
|
||||
path: FilePath;
|
||||
isFolder: true;
|
||||
handle: FileSystemDirectoryHandle;
|
||||
};
|
||||
|
||||
/**
|
||||
* FileSystem API stat type (compatible with UXStat)
|
||||
*/
|
||||
export type FSAPIStat = UXStat;
|
||||
123
src/apps/webapp/adapters/FSAPIVaultAdapter.ts
Normal file
123
src/apps/webapp/adapters/FSAPIVaultAdapter.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import type { FilePath, UXDataWriteOptions } from "../../../lib/src/common/types";
|
||||
import type { IVaultAdapter } from "../../../lib/src/serviceModules/adapters";
|
||||
import type { FSAPIFile, FSAPIFolder } from "./FSAPITypes";
|
||||
|
||||
/**
|
||||
* Vault adapter implementation for FileSystem API
|
||||
*/
|
||||
export class FSAPIVaultAdapter implements IVaultAdapter<FSAPIFile> {
|
||||
constructor(private rootHandle: FileSystemDirectoryHandle) {}
|
||||
|
||||
async read(file: FSAPIFile): Promise<string> {
|
||||
const fileObject = await file.handle.getFile();
|
||||
return await fileObject.text();
|
||||
}
|
||||
|
||||
async cachedRead(file: FSAPIFile): Promise<string> {
|
||||
// No caching in webapp version, just read directly
|
||||
return await this.read(file);
|
||||
}
|
||||
|
||||
async readBinary(file: FSAPIFile): Promise<ArrayBuffer> {
|
||||
const fileObject = await file.handle.getFile();
|
||||
return await fileObject.arrayBuffer();
|
||||
}
|
||||
|
||||
async modify(file: FSAPIFile, data: string, options?: UXDataWriteOptions): Promise<void> {
|
||||
const writable = await file.handle.createWritable();
|
||||
await writable.write(data);
|
||||
await writable.close();
|
||||
}
|
||||
|
||||
async modifyBinary(file: FSAPIFile, data: ArrayBuffer, options?: UXDataWriteOptions): Promise<void> {
|
||||
const writable = await file.handle.createWritable();
|
||||
await writable.write(data);
|
||||
await writable.close();
|
||||
}
|
||||
|
||||
async create(p: string, data: string, options?: UXDataWriteOptions): Promise<FSAPIFile> {
|
||||
const parts = p.split("/").filter((part) => part !== "");
|
||||
const fileName = parts[parts.length - 1];
|
||||
|
||||
// Navigate to parent directory, creating as needed
|
||||
let currentHandle = this.rootHandle;
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
currentHandle = await currentHandle.getDirectoryHandle(parts[i], { create: true });
|
||||
}
|
||||
|
||||
// Create the file
|
||||
const fileHandle = await currentHandle.getFileHandle(fileName, { create: true });
|
||||
const writable = await fileHandle.createWritable();
|
||||
await writable.write(data);
|
||||
await writable.close();
|
||||
|
||||
// Get file metadata
|
||||
const fileObject = await fileHandle.getFile();
|
||||
|
||||
return {
|
||||
path: p as FilePath,
|
||||
stat: {
|
||||
size: fileObject.size,
|
||||
mtime: fileObject.lastModified,
|
||||
ctime: fileObject.lastModified,
|
||||
type: "file",
|
||||
},
|
||||
handle: fileHandle,
|
||||
};
|
||||
}
|
||||
|
||||
async createBinary(p: string, data: ArrayBuffer, options?: UXDataWriteOptions): Promise<FSAPIFile> {
|
||||
const parts = p.split("/").filter((part) => part !== "");
|
||||
const fileName = parts[parts.length - 1];
|
||||
|
||||
// Navigate to parent directory, creating as needed
|
||||
let currentHandle = this.rootHandle;
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
currentHandle = await currentHandle.getDirectoryHandle(parts[i], { create: true });
|
||||
}
|
||||
|
||||
// Create the file
|
||||
const fileHandle = await currentHandle.getFileHandle(fileName, { create: true });
|
||||
const writable = await fileHandle.createWritable();
|
||||
await writable.write(data);
|
||||
await writable.close();
|
||||
|
||||
// Get file metadata
|
||||
const fileObject = await fileHandle.getFile();
|
||||
|
||||
return {
|
||||
path: p as FilePath,
|
||||
stat: {
|
||||
size: fileObject.size,
|
||||
mtime: fileObject.lastModified,
|
||||
ctime: fileObject.lastModified,
|
||||
type: "file",
|
||||
},
|
||||
handle: fileHandle,
|
||||
};
|
||||
}
|
||||
|
||||
async delete(file: FSAPIFile | FSAPIFolder, force = false): Promise<void> {
|
||||
const parts = file.path.split("/").filter((part) => part !== "");
|
||||
const name = parts[parts.length - 1];
|
||||
|
||||
// Navigate to parent directory
|
||||
let currentHandle = this.rootHandle;
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
currentHandle = await currentHandle.getDirectoryHandle(parts[i]);
|
||||
}
|
||||
|
||||
// Remove the entry
|
||||
await currentHandle.removeEntry(name, { recursive: force });
|
||||
}
|
||||
|
||||
async trash(file: FSAPIFile | FSAPIFolder, force = false): Promise<void> {
|
||||
// In webapp, trash is the same as delete (no recycle bin)
|
||||
await this.delete(file, force);
|
||||
}
|
||||
|
||||
trigger(name: string, ...data: any[]): any {
|
||||
// No-op in webapp version (no event system yet)
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
209
src/apps/webapp/index.html
Normal file
209
src/apps/webapp/index.html
Normal file
@@ -0,0 +1,209 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Self-hosted LiveSync WebApp</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
padding: 40px;
|
||||
max-width: 600px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #333;
|
||||
margin-bottom: 10px;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #666;
|
||||
margin-bottom: 30px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
#status {
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
#status.error {
|
||||
background: #fee;
|
||||
color: #c33;
|
||||
border: 1px solid #fcc;
|
||||
}
|
||||
|
||||
#status.warning {
|
||||
background: #ffeaa7;
|
||||
color: #d63031;
|
||||
border: 1px solid #fdcb6e;
|
||||
}
|
||||
|
||||
#status.success {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
|
||||
#status.info {
|
||||
background: #d1ecf1;
|
||||
color: #0c5460;
|
||||
border: 1px solid #bee5eb;
|
||||
}
|
||||
|
||||
.info-section {
|
||||
margin-top: 30px;
|
||||
padding: 20px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.info-section h2 {
|
||||
font-size: 18px;
|
||||
margin-bottom: 15px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.info-section ul {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.info-section li {
|
||||
padding: 8px 0;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.info-section li::before {
|
||||
content: "•";
|
||||
color: #667eea;
|
||||
font-weight: bold;
|
||||
display: inline-block;
|
||||
width: 1em;
|
||||
margin-left: -1em;
|
||||
padding-right: 0.5em;
|
||||
}
|
||||
|
||||
.feature-list {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.feature-list h3 {
|
||||
font-size: 16px;
|
||||
margin-bottom: 10px;
|
||||
color: #444;
|
||||
}
|
||||
|
||||
code {
|
||||
background: #e9ecef;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 30px;
|
||||
text-align: center;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.footer a {
|
||||
color: #667eea;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.footer a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.console-link {
|
||||
margin-top: 20px;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.container {
|
||||
padding: 30px 20px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>🔄 Self-hosted LiveSync</h1>
|
||||
<p class="subtitle">Browser-based Self-hosted LiveSync using FileSystem API</p>
|
||||
|
||||
<div id="status" class="info">
|
||||
Initialising...
|
||||
</div>
|
||||
|
||||
<div class="info-section">
|
||||
<h2>About This Application</h2>
|
||||
<ul>
|
||||
<li>Runs entirely in your browser</li>
|
||||
<li>Uses FileSystem API to access your local vault</li>
|
||||
<li>Syncs with CouchDB server (like Obsidian plugin)</li>
|
||||
<li>Settings stored in <code>.livesync/settings.json</code></li>
|
||||
<li>Real-time file watching with FileSystemObserver (Chrome 124+)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="info-section">
|
||||
<h2>How to Use</h2>
|
||||
<ul>
|
||||
<li>Grant directory access when prompted</li>
|
||||
<li>Create <code>.livesync/settings.json</code> in your vault folder. (Compatible with Obsidian's Self-hosted LiveSync)</li>
|
||||
<li>Add your CouchDB connection details</li>
|
||||
<li>Your files will be synced automatically</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="console-link">
|
||||
💡 Open browser console (F12) for detailed logs
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>
|
||||
Powered by
|
||||
<a href="https://github.com/vrtmrz/obsidian-livesync" target="_blank">
|
||||
Self-hosted LiveSync
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="./main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
345
src/apps/webapp/main.ts
Normal file
345
src/apps/webapp/main.ts
Normal file
@@ -0,0 +1,345 @@
|
||||
/**
|
||||
* Self-hosted LiveSync WebApp
|
||||
* Browser-based version of Self-hosted LiveSync plugin using FileSystem API
|
||||
*/
|
||||
|
||||
import { BrowserServiceHub } from "../../lib/src/services/BrowserServices";
|
||||
import { LiveSyncBaseCore } from "../../LiveSyncBaseCore";
|
||||
import { ServiceContext } from "../../lib/src/services/base/ServiceBase";
|
||||
import { initialiseServiceModulesFSAPI } from "./serviceModules/FSAPIServiceModules";
|
||||
import type { ObsidianLiveSyncSettings } from "../../lib/src/common/types";
|
||||
import type { BrowserAPIService } from "../../lib/src/services/implements/browser/BrowserAPIService";
|
||||
import type { InjectableSettingService } from "../../lib/src/services/implements/injectable/InjectableSettingService";
|
||||
// import { SetupManager } from "@/modules/features/SetupManager";
|
||||
// import { ModuleObsidianSettingsAsMarkdown } from "@/modules/features/ModuleObsidianSettingAsMarkdown";
|
||||
// import { ModuleSetupObsidian } from "@/modules/features/ModuleSetupObsidian";
|
||||
// import { ModuleObsidianMenu } from "@/modules/essentialObsidian/ModuleObsidianMenu";
|
||||
|
||||
const SETTINGS_DIR = ".livesync";
|
||||
const SETTINGS_FILE = "settings.json";
|
||||
const DB_NAME = "livesync-webapp";
|
||||
|
||||
/**
|
||||
* Default settings for the webapp
|
||||
*/
|
||||
const DEFAULT_SETTINGS: Partial<ObsidianLiveSyncSettings> = {
|
||||
liveSync: false,
|
||||
syncOnSave: true,
|
||||
syncOnStart: false,
|
||||
savingDelay: 200,
|
||||
lessInformationInLog: false,
|
||||
gcDelay: 0,
|
||||
periodicReplication: false,
|
||||
periodicReplicationInterval: 60,
|
||||
isConfigured: false,
|
||||
// CouchDB settings - user needs to configure these
|
||||
couchDB_URI: "",
|
||||
couchDB_USER: "",
|
||||
couchDB_PASSWORD: "",
|
||||
couchDB_DBNAME: "",
|
||||
// Disable features not needed in webapp
|
||||
usePluginSync: false,
|
||||
autoSweepPlugins: false,
|
||||
autoSweepPluginsPeriodic: false,
|
||||
};
|
||||
|
||||
class LiveSyncWebApp {
|
||||
private rootHandle: FileSystemDirectoryHandle | null = null;
|
||||
private core: LiveSyncBaseCore<ServiceContext, any> | null = null;
|
||||
private serviceHub: BrowserServiceHub<ServiceContext> | null = null;
|
||||
|
||||
async initialize() {
|
||||
console.log("Self-hosted LiveSync WebApp");
|
||||
console.log("Initializing...");
|
||||
|
||||
// Request directory access
|
||||
await this.requestDirectoryAccess();
|
||||
|
||||
if (!this.rootHandle) {
|
||||
throw new Error("Failed to get directory access");
|
||||
}
|
||||
|
||||
console.log(`Vault directory: ${this.rootHandle.name}`);
|
||||
|
||||
// Create service context and hub
|
||||
const context = new ServiceContext();
|
||||
this.serviceHub = new BrowserServiceHub<ServiceContext>();
|
||||
|
||||
// Setup API service
|
||||
(this.serviceHub.API as BrowserAPIService<ServiceContext>).getSystemVaultName.setHandler(
|
||||
() => this.rootHandle?.name || "livesync-webapp"
|
||||
);
|
||||
|
||||
// Setup settings handlers - save to .livesync folder
|
||||
const settingService = this.serviceHub.setting as InjectableSettingService<ServiceContext>;
|
||||
|
||||
settingService.saveData.setHandler(async (data: ObsidianLiveSyncSettings) => {
|
||||
try {
|
||||
await this.saveSettingsToFile(data);
|
||||
console.log("[Settings] Saved to .livesync/settings.json");
|
||||
} catch (error) {
|
||||
console.error("[Settings] Failed to save:", error);
|
||||
}
|
||||
});
|
||||
|
||||
settingService.loadData.setHandler(async (): Promise<ObsidianLiveSyncSettings | undefined> => {
|
||||
try {
|
||||
const data = await this.loadSettingsFromFile();
|
||||
if (data) {
|
||||
console.log("[Settings] Loaded from .livesync/settings.json");
|
||||
return { ...DEFAULT_SETTINGS, ...data } as ObsidianLiveSyncSettings;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("[Settings] Failed to load, using defaults");
|
||||
}
|
||||
return DEFAULT_SETTINGS as ObsidianLiveSyncSettings;
|
||||
});
|
||||
|
||||
// Create LiveSync core
|
||||
this.core = new LiveSyncBaseCore(
|
||||
this.serviceHub,
|
||||
(core, serviceHub) => {
|
||||
return initialiseServiceModulesFSAPI(this.rootHandle!, core, serviceHub);
|
||||
},
|
||||
(core) => [
|
||||
// new ModuleObsidianEvents(this, core),
|
||||
// new ModuleObsidianSettingDialogue(this, core),
|
||||
// new ModuleObsidianMenu(core),
|
||||
// new ModuleSetupObsidian(core),
|
||||
// new ModuleObsidianSettingsAsMarkdown(core),
|
||||
// new ModuleLog(this, core),
|
||||
// new ModuleObsidianDocumentHistory(this, core),
|
||||
// new ModuleInteractiveConflictResolver(this, core),
|
||||
// new ModuleObsidianGlobalHistory(this, core),
|
||||
// new ModuleDev(this, core),
|
||||
// new ModuleReplicateTest(this, core),
|
||||
// new ModuleIntegratedTest(this, core),
|
||||
// new SetupManager(core),
|
||||
],
|
||||
() => [],// No add-ons
|
||||
() => [],
|
||||
);
|
||||
|
||||
// Start the core
|
||||
await this.start();
|
||||
}
|
||||
|
||||
private async saveSettingsToFile(data: ObsidianLiveSyncSettings): Promise<void> {
|
||||
if (!this.rootHandle) return;
|
||||
|
||||
try {
|
||||
// Create .livesync directory if it doesn't exist
|
||||
const livesyncDir = await this.rootHandle.getDirectoryHandle(SETTINGS_DIR, { create: true });
|
||||
|
||||
// Create/overwrite settings.json
|
||||
const fileHandle = await livesyncDir.getFileHandle(SETTINGS_FILE, { create: true });
|
||||
const writable = await fileHandle.createWritable();
|
||||
await writable.write(JSON.stringify(data, null, 2));
|
||||
await writable.close();
|
||||
} catch (error) {
|
||||
console.error("[Settings] Error saving to file:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async loadSettingsFromFile(): Promise<Partial<ObsidianLiveSyncSettings> | null> {
|
||||
if (!this.rootHandle) return null;
|
||||
|
||||
try {
|
||||
const livesyncDir = await this.rootHandle.getDirectoryHandle(SETTINGS_DIR);
|
||||
const fileHandle = await livesyncDir.getFileHandle(SETTINGS_FILE);
|
||||
const file = await fileHandle.getFile();
|
||||
const text = await file.text();
|
||||
return JSON.parse(text);
|
||||
} catch (error) {
|
||||
// File doesn't exist yet
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async requestDirectoryAccess() {
|
||||
try {
|
||||
// Check if we have a cached directory handle
|
||||
const cached = await this.loadCachedDirectoryHandle();
|
||||
if (cached) {
|
||||
// Verify permission (cast to any for compatibility)
|
||||
try {
|
||||
const permission = await (cached as any).queryPermission({ mode: "readwrite" });
|
||||
if (permission === "granted") {
|
||||
this.rootHandle = cached;
|
||||
console.log("[Directory] Using cached directory handle");
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
// queryPermission might not be supported, try to use anyway
|
||||
console.log("[Directory] Could not verify permission, requesting new access");
|
||||
}
|
||||
}
|
||||
|
||||
// Request new directory access
|
||||
console.log("[Directory] Requesting directory access...");
|
||||
this.rootHandle = await (window as any).showDirectoryPicker({
|
||||
mode: "readwrite",
|
||||
startIn: "documents",
|
||||
});
|
||||
|
||||
// Save the handle for next time
|
||||
await this.saveCachedDirectoryHandle(this.rootHandle);
|
||||
console.log("[Directory] Directory access granted");
|
||||
} catch (error) {
|
||||
console.error("[Directory] Failed to get directory access:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async saveCachedDirectoryHandle(handle: FileSystemDirectoryHandle) {
|
||||
try {
|
||||
// Use IndexedDB to store the directory handle
|
||||
const db = await this.openHandleDB();
|
||||
const transaction = db.transaction(["handles"], "readwrite");
|
||||
const store = transaction.objectStore("handles");
|
||||
await new Promise((resolve, reject) => {
|
||||
const request = store.put(handle, "rootHandle");
|
||||
request.onsuccess = resolve;
|
||||
request.onerror = reject;
|
||||
});
|
||||
db.close();
|
||||
} catch (error) {
|
||||
console.error("[Directory] Failed to cache handle:", error);
|
||||
}
|
||||
}
|
||||
|
||||
private async loadCachedDirectoryHandle(): Promise<FileSystemDirectoryHandle | null> {
|
||||
try {
|
||||
const db = await this.openHandleDB();
|
||||
const transaction = db.transaction(["handles"], "readonly");
|
||||
const store = transaction.objectStore("handles");
|
||||
const handle = await new Promise<FileSystemDirectoryHandle | null>((resolve, reject) => {
|
||||
const request = store.get("rootHandle");
|
||||
request.onsuccess = () => resolve(request.result || null);
|
||||
request.onerror = reject;
|
||||
});
|
||||
db.close();
|
||||
return handle;
|
||||
} catch (error) {
|
||||
console.error("[Directory] Failed to load cached handle:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async openHandleDB(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open("livesync-webapp-handles", 1);
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = (event.target as IDBOpenDBRequest).result;
|
||||
if (!db.objectStoreNames.contains("handles")) {
|
||||
db.createObjectStore("handles");
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private async start() {
|
||||
if (!this.core) {
|
||||
throw new Error("Core not initialized");
|
||||
}
|
||||
|
||||
try {
|
||||
console.log("[Starting] Initializing LiveSync...");
|
||||
|
||||
const loadResult = await this.core.services.control.onLoad();
|
||||
if (!loadResult) {
|
||||
console.error("[Error] Failed to initialize LiveSync");
|
||||
this.showError("Failed to initialize LiveSync");
|
||||
return;
|
||||
}
|
||||
|
||||
await this.core.services.control.onReady();
|
||||
|
||||
console.log("[Ready] LiveSync is running");
|
||||
|
||||
// Check if configured
|
||||
const settings = this.core.services.setting.currentSettings();
|
||||
if (!settings.isConfigured) {
|
||||
console.warn("[Warning] LiveSync is not configured yet");
|
||||
this.showWarning("Please configure CouchDB connection in settings");
|
||||
} else {
|
||||
console.log("[Info] LiveSync is configured and ready");
|
||||
console.log(`[Info] Database: ${settings.couchDB_URI}/${settings.couchDB_DBNAME}`);
|
||||
this.showSuccess("LiveSync is ready!");
|
||||
}
|
||||
|
||||
// Scan the directory to populate file cache
|
||||
const fileAccess = (this.core as any)._serviceModules?.storageAccess?.vaultAccess;
|
||||
if (fileAccess?.fsapiAdapter) {
|
||||
console.log("[Scanning] Scanning vault directory...");
|
||||
await fileAccess.fsapiAdapter.scanDirectory();
|
||||
const files = await fileAccess.fsapiAdapter.getFiles();
|
||||
console.log(`[Scanning] Found ${files.length} files`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Error] Failed to start:", error);
|
||||
this.showError(`Failed to start: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
async shutdown() {
|
||||
if (this.core) {
|
||||
console.log("[Shutdown] Shutting down...");
|
||||
|
||||
// Stop file watching
|
||||
const storageEventManager = (this.core as any)._serviceModules?.storageAccess?.storageEventManager;
|
||||
if (storageEventManager?.cleanup) {
|
||||
await storageEventManager.cleanup();
|
||||
}
|
||||
|
||||
await this.core.services.control.onUnload();
|
||||
console.log("[Shutdown] Complete");
|
||||
}
|
||||
}
|
||||
|
||||
private showError(message: string) {
|
||||
const statusEl = document.getElementById("status");
|
||||
if (statusEl) {
|
||||
statusEl.className = "error";
|
||||
statusEl.textContent = `Error: ${message}`;
|
||||
}
|
||||
}
|
||||
|
||||
private showWarning(message: string) {
|
||||
const statusEl = document.getElementById("status");
|
||||
if (statusEl) {
|
||||
statusEl.className = "warning";
|
||||
statusEl.textContent = `Warning: ${message}`;
|
||||
}
|
||||
}
|
||||
|
||||
private showSuccess(message: string) {
|
||||
const statusEl = document.getElementById("status");
|
||||
if (statusEl) {
|
||||
statusEl.className = "success";
|
||||
statusEl.textContent = message;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize on load
|
||||
const app = new LiveSyncWebApp();
|
||||
|
||||
window.addEventListener("load", async () => {
|
||||
try {
|
||||
await app.initialize();
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize:", error);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle page unload
|
||||
window.addEventListener("beforeunload", () => {
|
||||
void app.shutdown();
|
||||
});
|
||||
|
||||
// Export for debugging
|
||||
(window as any).livesyncApp = app;
|
||||
281
src/apps/webapp/managers/FSAPIStorageEventManagerAdapter.ts
Normal file
281
src/apps/webapp/managers/FSAPIStorageEventManagerAdapter.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
import type { FilePath, UXFileInfoStub, UXInternalFileInfoStub } from "../../../lib/src/common/types";
|
||||
import type { FileEventItem } from "../../../lib/src/common/types";
|
||||
import type { IStorageEventManagerAdapter } from "../../../lib/src/managers/adapters";
|
||||
import type {
|
||||
IStorageEventTypeGuardAdapter,
|
||||
IStorageEventPersistenceAdapter,
|
||||
IStorageEventWatchAdapter,
|
||||
IStorageEventStatusAdapter,
|
||||
IStorageEventConverterAdapter,
|
||||
IStorageEventWatchHandlers,
|
||||
} from "../../../lib/src/managers/adapters";
|
||||
import type { FileEventItemSentinel } from "../../../lib/src/managers/StorageEventManager";
|
||||
import type { FSAPIFile, FSAPIFolder } from "../adapters/FSAPITypes";
|
||||
|
||||
/**
|
||||
* FileSystem API-specific type guard adapter
|
||||
*/
|
||||
class FSAPITypeGuardAdapter implements IStorageEventTypeGuardAdapter<FSAPIFile, FSAPIFolder> {
|
||||
isFile(file: any): file is FSAPIFile {
|
||||
return (
|
||||
file && typeof file === "object" && "path" in file && "stat" in file && "handle" in file && !file.isFolder
|
||||
);
|
||||
}
|
||||
|
||||
isFolder(item: any): item is FSAPIFolder {
|
||||
return item && typeof item === "object" && "path" in item && item.isFolder === true && "handle" in item;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* FileSystem API-specific persistence adapter (IndexedDB-based snapshot)
|
||||
*/
|
||||
class FSAPIPersistenceAdapter implements IStorageEventPersistenceAdapter {
|
||||
private dbName = "livesync-webapp-snapshot";
|
||||
private storeName = "snapshots";
|
||||
private snapshotKey = "file-events";
|
||||
|
||||
private async openDB(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(this.dbName, 1);
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = (event.target as IDBOpenDBRequest).result;
|
||||
if (!db.objectStoreNames.contains(this.storeName)) {
|
||||
db.createObjectStore(this.storeName);
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async saveSnapshot(snapshot: (FileEventItem | FileEventItemSentinel)[]): Promise<void> {
|
||||
try {
|
||||
const db = await this.openDB();
|
||||
const transaction = db.transaction([this.storeName], "readwrite");
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const request = store.put(snapshot, this.snapshotKey);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
|
||||
db.close();
|
||||
} catch (error) {
|
||||
console.error("Failed to save snapshot:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async loadSnapshot(): Promise<(FileEventItem | FileEventItemSentinel)[] | null> {
|
||||
try {
|
||||
const db = await this.openDB();
|
||||
const transaction = db.transaction([this.storeName], "readonly");
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
|
||||
const result = await new Promise<(FileEventItem | FileEventItemSentinel)[] | null>((resolve, reject) => {
|
||||
const request = store.get(this.snapshotKey);
|
||||
request.onsuccess = () => resolve(request.result || null);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
|
||||
db.close();
|
||||
return result;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* FileSystem API-specific status adapter (console logging)
|
||||
*/
|
||||
class FSAPIStatusAdapter implements IStorageEventStatusAdapter {
|
||||
private lastUpdate = 0;
|
||||
private updateInterval = 5000; // Update every 5 seconds
|
||||
|
||||
updateStatus(status: { batched: number; processing: number; totalQueued: number }): void {
|
||||
const now = Date.now();
|
||||
if (now - this.lastUpdate > this.updateInterval) {
|
||||
if (status.totalQueued > 0 || status.processing > 0) {
|
||||
console.log(
|
||||
`[StorageEventManager] Batched: ${status.batched}, Processing: ${status.processing}, Total Queued: ${status.totalQueued}`
|
||||
);
|
||||
}
|
||||
this.lastUpdate = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* FileSystem API-specific converter adapter
|
||||
*/
|
||||
class FSAPIConverterAdapter implements IStorageEventConverterAdapter<FSAPIFile> {
|
||||
toFileInfo(file: FSAPIFile, deleted?: boolean): UXFileInfoStub {
|
||||
const pathParts = file.path.split("/");
|
||||
const name = pathParts[pathParts.length - 1] || file.handle.name;
|
||||
|
||||
return {
|
||||
name: name,
|
||||
path: file.path,
|
||||
stat: file.stat,
|
||||
deleted: deleted,
|
||||
isFolder: false,
|
||||
};
|
||||
}
|
||||
|
||||
toInternalFileInfo(p: FilePath): UXInternalFileInfoStub {
|
||||
const pathParts = p.split("/");
|
||||
const name = pathParts[pathParts.length - 1] || "";
|
||||
|
||||
return {
|
||||
name: name,
|
||||
path: p,
|
||||
isInternal: true,
|
||||
stat: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* FileSystem API-specific watch adapter using FileSystemObserver (Chrome only)
|
||||
*/
|
||||
class FSAPIWatchAdapter implements IStorageEventWatchAdapter {
|
||||
private observer: any = null; // FileSystemObserver type
|
||||
|
||||
constructor(private rootHandle: FileSystemDirectoryHandle) {}
|
||||
|
||||
async beginWatch(handlers: IStorageEventWatchHandlers): Promise<void> {
|
||||
// Use FileSystemObserver if available (Chrome 124+)
|
||||
if (typeof (window as any).FileSystemObserver === "undefined") {
|
||||
console.log("[FSAPIWatchAdapter] FileSystemObserver not available, file watching disabled");
|
||||
console.log("[FSAPIWatchAdapter] Consider using Chrome 124+ for real-time file watching");
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
try {
|
||||
const FileSystemObserver = (window as any).FileSystemObserver;
|
||||
|
||||
this.observer = new FileSystemObserver(async (records: any[]) => {
|
||||
for (const record of records) {
|
||||
const handle = record.root;
|
||||
const changedHandle = record.changedHandle;
|
||||
const relativePathComponents = record.relativePathComponents;
|
||||
const type = record.type; // "appeared", "disappeared", "modified", "moved", "unknown", "errored"
|
||||
|
||||
// Build relative path
|
||||
const relativePath = relativePathComponents ? relativePathComponents.join("/") : "";
|
||||
|
||||
// Skip .livesync directory to avoid infinite loops
|
||||
if (relativePath.startsWith(".livesync/") || relativePath === ".livesync") {
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`[FileSystemObserver] ${type}: ${relativePath}`);
|
||||
|
||||
// Convert to our event handlers
|
||||
try {
|
||||
if (type === "appeared" || type === "modified") {
|
||||
if (changedHandle && changedHandle.kind === "file") {
|
||||
const file = await changedHandle.getFile();
|
||||
const fileInfo = {
|
||||
path: relativePath as any,
|
||||
stat: {
|
||||
size: file.size,
|
||||
mtime: file.lastModified,
|
||||
ctime: file.lastModified,
|
||||
type: "file" as const,
|
||||
},
|
||||
handle: changedHandle,
|
||||
};
|
||||
|
||||
if (type === "appeared") {
|
||||
await handlers.onCreate(fileInfo, undefined);
|
||||
} else {
|
||||
await handlers.onChange(fileInfo, undefined);
|
||||
}
|
||||
}
|
||||
} else if (type === "disappeared") {
|
||||
const fileInfo = {
|
||||
path: relativePath as any,
|
||||
stat: {
|
||||
size: 0,
|
||||
mtime: Date.now(),
|
||||
ctime: Date.now(),
|
||||
type: "file" as const,
|
||||
},
|
||||
handle: null as any,
|
||||
};
|
||||
await handlers.onDelete(fileInfo, undefined);
|
||||
} else if (type === "moved") {
|
||||
// Handle as delete + create
|
||||
// Note: FileSystemObserver provides both old and new paths in some cases
|
||||
// For simplicity, we'll treat it as a modification
|
||||
if (changedHandle && changedHandle.kind === "file") {
|
||||
const file = await changedHandle.getFile();
|
||||
const fileInfo = {
|
||||
path: relativePath as any,
|
||||
stat: {
|
||||
size: file.size,
|
||||
mtime: file.lastModified,
|
||||
ctime: file.lastModified,
|
||||
type: "file" as const,
|
||||
},
|
||||
handle: changedHandle,
|
||||
};
|
||||
await handlers.onChange(fileInfo, undefined);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[FileSystemObserver] Error processing ${type} event for ${relativePath}:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Start observing
|
||||
await this.observer.observe(this.rootHandle, { recursive: true });
|
||||
console.log("[FSAPIWatchAdapter] FileSystemObserver started successfully");
|
||||
} catch (error) {
|
||||
console.error("[FSAPIWatchAdapter] Failed to start FileSystemObserver:", error);
|
||||
console.log("[FSAPIWatchAdapter] Falling back to manual sync mode");
|
||||
}
|
||||
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
async stopWatch(): Promise<void> {
|
||||
if (this.observer) {
|
||||
try {
|
||||
this.observer.disconnect();
|
||||
this.observer = null;
|
||||
console.log("[FSAPIWatchAdapter] FileSystemObserver stopped");
|
||||
} catch (error) {
|
||||
console.error("[FSAPIWatchAdapter] Error stopping observer:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Composite adapter for FileSystem API StorageEventManager
|
||||
*/
|
||||
export class FSAPIStorageEventManagerAdapter implements IStorageEventManagerAdapter<FSAPIFile, FSAPIFolder> {
|
||||
readonly typeGuard: FSAPITypeGuardAdapter;
|
||||
readonly persistence: FSAPIPersistenceAdapter;
|
||||
readonly watch: FSAPIWatchAdapter;
|
||||
readonly status: FSAPIStatusAdapter;
|
||||
readonly converter: FSAPIConverterAdapter;
|
||||
|
||||
constructor(rootHandle: FileSystemDirectoryHandle) {
|
||||
this.typeGuard = new FSAPITypeGuardAdapter();
|
||||
this.persistence = new FSAPIPersistenceAdapter();
|
||||
this.watch = new FSAPIWatchAdapter(rootHandle);
|
||||
this.status = new FSAPIStatusAdapter();
|
||||
this.converter = new FSAPIConverterAdapter();
|
||||
}
|
||||
}
|
||||
39
src/apps/webapp/managers/StorageEventManagerFSAPI.ts
Normal file
39
src/apps/webapp/managers/StorageEventManagerFSAPI.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
StorageEventManagerBase,
|
||||
type StorageEventManagerBaseDependencies,
|
||||
} from "../../../lib/src/managers/StorageEventManager";
|
||||
import { FSAPIStorageEventManagerAdapter } from "./FSAPIStorageEventManagerAdapter";
|
||||
import type { IMinimumLiveSyncCommands, LiveSyncBaseCore } from "../../../LiveSyncBaseCore";
|
||||
import type { ServiceContext } from "../../../lib/src/services/base/ServiceBase";
|
||||
|
||||
export class StorageEventManagerFSAPI extends StorageEventManagerBase<FSAPIStorageEventManagerAdapter> {
|
||||
core: LiveSyncBaseCore<ServiceContext, IMinimumLiveSyncCommands>;
|
||||
private fsapiAdapter: FSAPIStorageEventManagerAdapter;
|
||||
|
||||
constructor(
|
||||
rootHandle: FileSystemDirectoryHandle,
|
||||
core: LiveSyncBaseCore<ServiceContext, IMinimumLiveSyncCommands>,
|
||||
dependencies: StorageEventManagerBaseDependencies
|
||||
) {
|
||||
const adapter = new FSAPIStorageEventManagerAdapter(rootHandle);
|
||||
super(adapter, dependencies);
|
||||
this.fsapiAdapter = adapter;
|
||||
this.core = core;
|
||||
}
|
||||
|
||||
/**
|
||||
* Override _watchVaultRawEvents for webapp-specific logic
|
||||
* In webapp, we don't have internal files like Obsidian's .obsidian folder
|
||||
*/
|
||||
protected override async _watchVaultRawEvents(path: string) {
|
||||
// No-op in webapp version
|
||||
// Internal file handling is not needed
|
||||
}
|
||||
|
||||
async cleanup() {
|
||||
// Stop file watching
|
||||
if (this.fsapiAdapter?.watch) {
|
||||
await (this.fsapiAdapter.watch as any).stopWatch?.();
|
||||
}
|
||||
}
|
||||
}
|
||||
21
src/apps/webapp/package.json
Normal file
21
src/apps/webapp/package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "livesync-webapp",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"description": "Browser-based Obsidian LiveSync using FileSystem API",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {},
|
||||
"devDependencies": {
|
||||
"typescript": "5.9.3",
|
||||
"vite": "^7.3.1"
|
||||
},
|
||||
"imports": {
|
||||
"../../src/worker/bgWorker.ts": "../../src/worker/bgWorker.mock.ts",
|
||||
"@lib/worker/bgWorker.ts": "@lib/worker/bgWorker.mock.ts"
|
||||
}
|
||||
}
|
||||
15
src/apps/webapp/serviceModules/DatabaseFileAccess.ts
Normal file
15
src/apps/webapp/serviceModules/DatabaseFileAccess.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import {
|
||||
ServiceDatabaseFileAccessBase,
|
||||
type ServiceDatabaseFileAccessDependencies,
|
||||
} from "../../../lib/src/serviceModules/ServiceDatabaseFileAccessBase";
|
||||
import type { DatabaseFileAccess } from "../../../lib/src/interfaces/DatabaseFileAccess";
|
||||
|
||||
/**
|
||||
* FileSystem API-specific implementation of ServiceDatabaseFileAccess
|
||||
* Same as Obsidian version, no platform-specific changes needed
|
||||
*/
|
||||
export class ServiceDatabaseFileAccessFSAPI extends ServiceDatabaseFileAccessBase implements DatabaseFileAccess {
|
||||
constructor(services: ServiceDatabaseFileAccessDependencies) {
|
||||
super(services);
|
||||
}
|
||||
}
|
||||
104
src/apps/webapp/serviceModules/FSAPIServiceModules.ts
Normal file
104
src/apps/webapp/serviceModules/FSAPIServiceModules.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import type { InjectableServiceHub } from "../../../lib/src/services/implements/injectable/InjectableServiceHub";
|
||||
import { ServiceRebuilder } from "../../../lib/src/serviceModules/Rebuilder";
|
||||
import { ServiceFileHandler } from "../../../serviceModules/FileHandler";
|
||||
import { StorageAccessManager } from "../../../lib/src/managers/StorageProcessingManager";
|
||||
import type { ServiceModules } from "../../../types";
|
||||
import type { LiveSyncBaseCore } from "../../../LiveSyncBaseCore";
|
||||
import type { ServiceContext } from "../../../lib/src/services/base/ServiceBase";
|
||||
import { FileAccessFSAPI } from "./FileAccessFSAPI";
|
||||
import { ServiceFileAccessFSAPI } from "./ServiceFileAccessImpl";
|
||||
import { ServiceDatabaseFileAccessFSAPI } from "./DatabaseFileAccess";
|
||||
import { StorageEventManagerFSAPI } from "../managers/StorageEventManagerFSAPI";
|
||||
|
||||
/**
|
||||
* Initialize service modules for FileSystem API webapp version
|
||||
* This is the webapp equivalent of ObsidianLiveSyncPlugin.initialiseServiceModules
|
||||
*
|
||||
* @param rootHandle - The root FileSystemDirectoryHandle for the vault
|
||||
* @param core - The LiveSyncBaseCore instance
|
||||
* @param services - The service hub
|
||||
* @returns ServiceModules containing all initialized service modules
|
||||
*/
|
||||
export function initialiseServiceModulesFSAPI(
|
||||
rootHandle: FileSystemDirectoryHandle,
|
||||
core: LiveSyncBaseCore<ServiceContext, any>,
|
||||
services: InjectableServiceHub<ServiceContext>
|
||||
): ServiceModules {
|
||||
const storageAccessManager = new StorageAccessManager();
|
||||
|
||||
// FileSystem API-specific file access
|
||||
const vaultAccess = new FileAccessFSAPI(rootHandle, {
|
||||
storageAccessManager: storageAccessManager,
|
||||
vaultService: services.vault,
|
||||
settingService: services.setting,
|
||||
APIService: services.API,
|
||||
pathService: services.path,
|
||||
});
|
||||
|
||||
// FileSystem API-specific storage event manager
|
||||
const storageEventManager = new StorageEventManagerFSAPI(rootHandle, core, {
|
||||
fileProcessing: services.fileProcessing,
|
||||
setting: services.setting,
|
||||
vaultService: services.vault,
|
||||
storageAccessManager: storageAccessManager,
|
||||
APIService: services.API,
|
||||
});
|
||||
|
||||
// Storage access using FileSystem API adapter
|
||||
const storageAccess = new ServiceFileAccessFSAPI({
|
||||
API: services.API,
|
||||
setting: services.setting,
|
||||
fileProcessing: services.fileProcessing,
|
||||
vault: services.vault,
|
||||
appLifecycle: services.appLifecycle,
|
||||
storageEventManager: storageEventManager,
|
||||
storageAccessManager: storageAccessManager,
|
||||
vaultAccess: vaultAccess,
|
||||
});
|
||||
|
||||
// Database file access (platform-independent)
|
||||
const databaseFileAccess = new ServiceDatabaseFileAccessFSAPI({
|
||||
API: services.API,
|
||||
database: services.database,
|
||||
path: services.path,
|
||||
storageAccess: storageAccess,
|
||||
vault: services.vault,
|
||||
});
|
||||
|
||||
// File handler (platform-independent)
|
||||
const fileHandler = new (ServiceFileHandler as any)({
|
||||
API: services.API,
|
||||
databaseFileAccess: databaseFileAccess,
|
||||
conflict: services.conflict,
|
||||
setting: services.setting,
|
||||
fileProcessing: services.fileProcessing,
|
||||
vault: services.vault,
|
||||
path: services.path,
|
||||
replication: services.replication,
|
||||
storageAccess: storageAccess,
|
||||
});
|
||||
|
||||
// Rebuilder (platform-independent)
|
||||
const rebuilder = new ServiceRebuilder({
|
||||
API: services.API,
|
||||
database: services.database,
|
||||
appLifecycle: services.appLifecycle,
|
||||
setting: services.setting,
|
||||
remote: services.remote,
|
||||
databaseEvents: services.databaseEvents,
|
||||
replication: services.replication,
|
||||
replicator: services.replicator,
|
||||
UI: services.UI,
|
||||
vault: services.vault,
|
||||
fileHandler: fileHandler,
|
||||
storageAccess: storageAccess,
|
||||
control: services.control,
|
||||
});
|
||||
|
||||
return {
|
||||
rebuilder,
|
||||
fileHandler,
|
||||
databaseFileAccess,
|
||||
storageAccess,
|
||||
};
|
||||
}
|
||||
20
src/apps/webapp/serviceModules/FileAccessFSAPI.ts
Normal file
20
src/apps/webapp/serviceModules/FileAccessFSAPI.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { FileAccessBase, type FileAccessBaseDependencies } from "../../../lib/src/serviceModules/FileAccessBase";
|
||||
import { FSAPIFileSystemAdapter } from "../adapters/FSAPIFileSystemAdapter";
|
||||
|
||||
/**
|
||||
* FileSystem API-specific implementation of FileAccessBase
|
||||
* Uses FSAPIFileSystemAdapter for browser file operations
|
||||
*/
|
||||
export class FileAccessFSAPI extends FileAccessBase<FSAPIFileSystemAdapter> {
|
||||
constructor(rootHandle: FileSystemDirectoryHandle, dependencies: FileAccessBaseDependencies) {
|
||||
const adapter = new FSAPIFileSystemAdapter(rootHandle);
|
||||
super(adapter, dependencies);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expose the adapter for accessing scanDirectory and other methods
|
||||
*/
|
||||
get fsapiAdapter(): FSAPIFileSystemAdapter {
|
||||
return this.adapter;
|
||||
}
|
||||
}
|
||||
15
src/apps/webapp/serviceModules/ServiceFileAccessImpl.ts
Normal file
15
src/apps/webapp/serviceModules/ServiceFileAccessImpl.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import {
|
||||
ServiceFileAccessBase,
|
||||
type StorageAccessBaseDependencies,
|
||||
} from "../../../lib/src/serviceModules/ServiceFileAccessBase";
|
||||
import { FSAPIFileSystemAdapter } from "../adapters/FSAPIFileSystemAdapter";
|
||||
|
||||
/**
|
||||
* FileSystem API-specific implementation of ServiceFileAccess
|
||||
* Uses FSAPIFileSystemAdapter for platform-specific operations
|
||||
*/
|
||||
export class ServiceFileAccessFSAPI extends ServiceFileAccessBase<FSAPIFileSystemAdapter> {
|
||||
constructor(services: StorageAccessBaseDependencies<FSAPIFileSystemAdapter>) {
|
||||
super(services);
|
||||
}
|
||||
}
|
||||
7
src/apps/webapp/svelte.config.js
Normal file
7
src/apps/webapp/svelte.config.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
|
||||
|
||||
export default {
|
||||
// Consult https://svelte.dev/docs#compile-time-svelte-preprocess
|
||||
// for more information about preprocessors
|
||||
preprocess: vitePreprocess(),
|
||||
};
|
||||
32
src/apps/webapp/tsconfig.json
Normal file
32
src/apps/webapp/tsconfig.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": false,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
|
||||
/* Path mapping */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["../../*"],
|
||||
"@lib/*": ["../../lib/src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["*.ts", "**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
34
src/apps/webapp/vite.config.ts
Normal file
34
src/apps/webapp/vite.config.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { defineConfig } from "vite";
|
||||
import { svelte } from "@sveltejs/vite-plugin-svelte";
|
||||
import path from "node:path";
|
||||
import { readFileSync } from "node:fs";
|
||||
const packageJson = JSON.parse(readFileSync("../../../package.json", "utf-8"));
|
||||
const manifestJson = JSON.parse(readFileSync("../../../manifest.json", "utf-8"));
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [svelte()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "../../"),
|
||||
"@lib": path.resolve(__dirname, "../../lib/src"),
|
||||
},
|
||||
},
|
||||
base: "./",
|
||||
build: {
|
||||
outDir: "dist",
|
||||
emptyOutDir: true,
|
||||
rollupOptions: {
|
||||
input: {
|
||||
index: path.resolve(__dirname, "index.html"),
|
||||
},
|
||||
},
|
||||
},
|
||||
define: {
|
||||
MANIFEST_VERSION: JSON.stringify(process.env.MANIFEST_VERSION || manifestJson.version || "0.0.0"),
|
||||
PACKAGE_VERSION: JSON.stringify(process.env.PACKAGE_VERSION || packageJson.version || "0.0.0"),
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
open: true,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user