Compare commits
2 Commits
home-icon-
...
new-storag
| Author | SHA1 | Date | |
|---|---|---|---|
| 61f28f9add | |||
|
|
574520d9b3 |
5
.gitignore
vendored
@@ -51,7 +51,6 @@ vendor/
|
||||
# Build logs
|
||||
build_logs/
|
||||
|
||||
# PWA icon files generated by capacitor-assets
|
||||
icons
|
||||
|
||||
android/app/src/main/assets/public
|
||||
android/app/src/main/res
|
||||
|
||||
|
||||
@@ -343,11 +343,7 @@ Prerequisites: macOS with Xcode installed
|
||||
3. Copy the assets:
|
||||
|
||||
```bash
|
||||
# It makes no sense why capacitor-assets will not run without these but it actually changes the contents.
|
||||
mkdir -p ios/App/App/Assets.xcassets/AppIcon.appiconset
|
||||
echo '{"images":[]}' > ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json
|
||||
mkdir -p ios/App/App/Assets.xcassets/Splash.imageset
|
||||
echo '{"images":[]}' > ios/App/App/Assets.xcassets/Splash.imageset/Contents.json
|
||||
npx capacitor-assets generate --ios
|
||||
```
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"appId": "app.timesafari.app",
|
||||
"appId": "app.timesafari",
|
||||
"appName": "TimeSafari",
|
||||
"webDir": "dist",
|
||||
"bundledWebRuntime": false,
|
||||
|
||||
BIN
android/app/src/main/res/drawable-land-hdpi/splash.png
Normal file
|
After Width: | Height: | Size: 7.5 KiB |
BIN
android/app/src/main/res/drawable-land-mdpi/splash.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
android/app/src/main/res/drawable-land-xhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 9.0 KiB |
BIN
android/app/src/main/res/drawable-land-xxhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
android/app/src/main/res/drawable-land-xxxhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
android/app/src/main/res/drawable-port-hdpi/splash.png
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
BIN
android/app/src/main/res/drawable-port-mdpi/splash.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
android/app/src/main/res/drawable-port-xhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 9.6 KiB |
BIN
android/app/src/main/res/drawable-port-xxhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
android/app/src/main/res/drawable-port-xxxhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
android/app/src/main/res/drawable/splash.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
@@ -1,9 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background>
|
||||
<inset android:drawable="@mipmap/ic_launcher_background" android:inset="16.7%" />
|
||||
</background>
|
||||
<foreground>
|
||||
<inset android:drawable="@mipmap/ic_launcher_foreground" android:inset="16.7%" />
|
||||
</foreground>
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
@@ -1,9 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background>
|
||||
<inset android:drawable="@mipmap/ic_launcher_background" android:inset="16.7%" />
|
||||
</background>
|
||||
<foreground>
|
||||
<inset android:drawable="@mipmap/ic_launcher_foreground" android:inset="16.7%" />
|
||||
</foreground>
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 15 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
assets/icon-only.jpg
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
assets/icon.png
|
Before Width: | Height: | Size: 99 KiB |
389
docs/dexie-to-sqlite-mapping.md
Normal file
@@ -0,0 +1,389 @@
|
||||
# Dexie to SQLite Mapping Guide
|
||||
|
||||
## Schema Mapping
|
||||
|
||||
### Current Dexie Schema
|
||||
```typescript
|
||||
// Current Dexie schema
|
||||
const db = new Dexie('TimeSafariDB');
|
||||
|
||||
db.version(1).stores({
|
||||
accounts: 'did, publicKeyHex, createdAt, updatedAt',
|
||||
settings: 'key, value, updatedAt',
|
||||
contacts: 'id, did, name, createdAt, updatedAt'
|
||||
});
|
||||
```
|
||||
|
||||
### New SQLite Schema
|
||||
```sql
|
||||
-- New SQLite schema
|
||||
CREATE TABLE accounts (
|
||||
did TEXT PRIMARY KEY,
|
||||
public_key_hex TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE contacts (
|
||||
id TEXT PRIMARY KEY,
|
||||
did TEXT NOT NULL,
|
||||
name TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (did) REFERENCES accounts(did)
|
||||
);
|
||||
|
||||
-- Indexes for performance
|
||||
CREATE INDEX idx_accounts_created_at ON accounts(created_at);
|
||||
CREATE INDEX idx_contacts_did ON contacts(did);
|
||||
CREATE INDEX idx_settings_updated_at ON settings(updated_at);
|
||||
```
|
||||
|
||||
## Query Mapping
|
||||
|
||||
### 1. Account Operations
|
||||
|
||||
#### Get Account by DID
|
||||
```typescript
|
||||
// Dexie
|
||||
const account = await db.accounts.get(did);
|
||||
|
||||
// SQLite
|
||||
const account = await db.selectOne(`
|
||||
SELECT * FROM accounts WHERE did = ?
|
||||
`, [did]);
|
||||
```
|
||||
|
||||
#### Get All Accounts
|
||||
```typescript
|
||||
// Dexie
|
||||
const accounts = await db.accounts.toArray();
|
||||
|
||||
// SQLite
|
||||
const accounts = await db.selectAll(`
|
||||
SELECT * FROM accounts ORDER BY created_at DESC
|
||||
`);
|
||||
```
|
||||
|
||||
#### Add Account
|
||||
```typescript
|
||||
// Dexie
|
||||
await db.accounts.add({
|
||||
did,
|
||||
publicKeyHex,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now()
|
||||
});
|
||||
|
||||
// SQLite
|
||||
await db.execute(`
|
||||
INSERT INTO accounts (did, public_key_hex, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`, [did, publicKeyHex, Date.now(), Date.now()]);
|
||||
```
|
||||
|
||||
#### Update Account
|
||||
```typescript
|
||||
// Dexie
|
||||
await db.accounts.update(did, {
|
||||
publicKeyHex,
|
||||
updatedAt: Date.now()
|
||||
});
|
||||
|
||||
// SQLite
|
||||
await db.execute(`
|
||||
UPDATE accounts
|
||||
SET public_key_hex = ?, updated_at = ?
|
||||
WHERE did = ?
|
||||
`, [publicKeyHex, Date.now(), did]);
|
||||
```
|
||||
|
||||
### 2. Settings Operations
|
||||
|
||||
#### Get Setting
|
||||
```typescript
|
||||
// Dexie
|
||||
const setting = await db.settings.get(key);
|
||||
|
||||
// SQLite
|
||||
const setting = await db.selectOne(`
|
||||
SELECT * FROM settings WHERE key = ?
|
||||
`, [key]);
|
||||
```
|
||||
|
||||
#### Set Setting
|
||||
```typescript
|
||||
// Dexie
|
||||
await db.settings.put({
|
||||
key,
|
||||
value,
|
||||
updatedAt: Date.now()
|
||||
});
|
||||
|
||||
// SQLite
|
||||
await db.execute(`
|
||||
INSERT INTO settings (key, value, updated_at)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET
|
||||
value = excluded.value,
|
||||
updated_at = excluded.updated_at
|
||||
`, [key, value, Date.now()]);
|
||||
```
|
||||
|
||||
### 3. Contact Operations
|
||||
|
||||
#### Get Contacts by Account
|
||||
```typescript
|
||||
// Dexie
|
||||
const contacts = await db.contacts
|
||||
.where('did')
|
||||
.equals(accountDid)
|
||||
.toArray();
|
||||
|
||||
// SQLite
|
||||
const contacts = await db.selectAll(`
|
||||
SELECT * FROM contacts
|
||||
WHERE did = ?
|
||||
ORDER BY created_at DESC
|
||||
`, [accountDid]);
|
||||
```
|
||||
|
||||
#### Add Contact
|
||||
```typescript
|
||||
// Dexie
|
||||
await db.contacts.add({
|
||||
id: generateId(),
|
||||
did: accountDid,
|
||||
name,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now()
|
||||
});
|
||||
|
||||
// SQLite
|
||||
await db.execute(`
|
||||
INSERT INTO contacts (id, did, name, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`, [generateId(), accountDid, name, Date.now(), Date.now()]);
|
||||
```
|
||||
|
||||
## Transaction Mapping
|
||||
|
||||
### Batch Operations
|
||||
```typescript
|
||||
// Dexie
|
||||
await db.transaction('rw', [db.accounts, db.contacts], async () => {
|
||||
await db.accounts.add(account);
|
||||
await db.contacts.bulkAdd(contacts);
|
||||
});
|
||||
|
||||
// SQLite
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.execute(`
|
||||
INSERT INTO accounts (did, public_key_hex, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`, [account.did, account.publicKeyHex, account.createdAt, account.updatedAt]);
|
||||
|
||||
for (const contact of contacts) {
|
||||
await tx.execute(`
|
||||
INSERT INTO contacts (id, did, name, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`, [contact.id, contact.did, contact.name, contact.createdAt, contact.updatedAt]);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Migration Helper Functions
|
||||
|
||||
### 1. Data Export (Dexie to JSON)
|
||||
```typescript
|
||||
async function exportDexieData(): Promise<MigrationData> {
|
||||
const db = new Dexie('TimeSafariDB');
|
||||
|
||||
return {
|
||||
accounts: await db.accounts.toArray(),
|
||||
settings: await db.settings.toArray(),
|
||||
contacts: await db.contacts.toArray(),
|
||||
metadata: {
|
||||
version: '1.0.0',
|
||||
timestamp: Date.now(),
|
||||
dexieVersion: Dexie.version
|
||||
}
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Data Import (JSON to SQLite)
|
||||
```typescript
|
||||
async function importToSQLite(data: MigrationData): Promise<void> {
|
||||
const db = await getSQLiteConnection();
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
// Import accounts
|
||||
for (const account of data.accounts) {
|
||||
await tx.execute(`
|
||||
INSERT INTO accounts (did, public_key_hex, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`, [account.did, account.publicKeyHex, account.createdAt, account.updatedAt]);
|
||||
}
|
||||
|
||||
// Import settings
|
||||
for (const setting of data.settings) {
|
||||
await tx.execute(`
|
||||
INSERT INTO settings (key, value, updated_at)
|
||||
VALUES (?, ?, ?)
|
||||
`, [setting.key, setting.value, setting.updatedAt]);
|
||||
}
|
||||
|
||||
// Import contacts
|
||||
for (const contact of data.contacts) {
|
||||
await tx.execute(`
|
||||
INSERT INTO contacts (id, did, name, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`, [contact.id, contact.did, contact.name, contact.createdAt, contact.updatedAt]);
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Verification
|
||||
```typescript
|
||||
async function verifyMigration(dexieData: MigrationData): Promise<boolean> {
|
||||
const db = await getSQLiteConnection();
|
||||
|
||||
// Verify account count
|
||||
const accountCount = await db.selectValue(
|
||||
'SELECT COUNT(*) FROM accounts'
|
||||
);
|
||||
if (accountCount !== dexieData.accounts.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify settings count
|
||||
const settingsCount = await db.selectValue(
|
||||
'SELECT COUNT(*) FROM settings'
|
||||
);
|
||||
if (settingsCount !== dexieData.settings.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify contacts count
|
||||
const contactsCount = await db.selectValue(
|
||||
'SELECT COUNT(*) FROM contacts'
|
||||
);
|
||||
if (contactsCount !== dexieData.contacts.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify data integrity
|
||||
for (const account of dexieData.accounts) {
|
||||
const migratedAccount = await db.selectOne(
|
||||
'SELECT * FROM accounts WHERE did = ?',
|
||||
[account.did]
|
||||
);
|
||||
if (!migratedAccount ||
|
||||
migratedAccount.public_key_hex !== account.publicKeyHex) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### 1. Indexing
|
||||
- Dexie automatically creates indexes based on the schema
|
||||
- SQLite requires explicit index creation
|
||||
- Added indexes for frequently queried fields
|
||||
|
||||
### 2. Batch Operations
|
||||
- Dexie has built-in bulk operations
|
||||
- SQLite uses transactions for batch operations
|
||||
- Consider chunking large datasets
|
||||
|
||||
### 3. Query Optimization
|
||||
- Dexie uses IndexedDB's native indexing
|
||||
- SQLite requires explicit query optimization
|
||||
- Use prepared statements for repeated queries
|
||||
|
||||
## Error Handling
|
||||
|
||||
### 1. Common Errors
|
||||
```typescript
|
||||
// Dexie errors
|
||||
try {
|
||||
await db.accounts.add(account);
|
||||
} catch (error) {
|
||||
if (error instanceof Dexie.ConstraintError) {
|
||||
// Handle duplicate key
|
||||
}
|
||||
}
|
||||
|
||||
// SQLite errors
|
||||
try {
|
||||
await db.execute(`
|
||||
INSERT INTO accounts (did, public_key_hex, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`, [account.did, account.publicKeyHex, account.createdAt, account.updatedAt]);
|
||||
} catch (error) {
|
||||
if (error.code === 'SQLITE_CONSTRAINT') {
|
||||
// Handle duplicate key
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Transaction Recovery
|
||||
```typescript
|
||||
// Dexie transaction
|
||||
try {
|
||||
await db.transaction('rw', db.accounts, async () => {
|
||||
// Operations
|
||||
});
|
||||
} catch (error) {
|
||||
// Dexie automatically rolls back
|
||||
}
|
||||
|
||||
// SQLite transaction
|
||||
const db = await getSQLiteConnection();
|
||||
try {
|
||||
await db.transaction(async (tx) => {
|
||||
// Operations
|
||||
});
|
||||
} catch (error) {
|
||||
// SQLite automatically rolls back
|
||||
await db.execute('ROLLBACK');
|
||||
}
|
||||
```
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
1. **Preparation**
|
||||
- Export all Dexie data
|
||||
- Verify data integrity
|
||||
- Create SQLite schema
|
||||
- Setup indexes
|
||||
|
||||
2. **Migration**
|
||||
- Import data in transactions
|
||||
- Verify each batch
|
||||
- Handle errors gracefully
|
||||
- Maintain backup
|
||||
|
||||
3. **Verification**
|
||||
- Compare record counts
|
||||
- Verify data integrity
|
||||
- Test common queries
|
||||
- Validate relationships
|
||||
|
||||
4. **Cleanup**
|
||||
- Remove Dexie database
|
||||
- Clear IndexedDB storage
|
||||
- Update application code
|
||||
- Remove old dependencies
|
||||
307
docs/storage-implementation-checklist.md
Normal file
@@ -0,0 +1,307 @@
|
||||
# Storage Implementation Checklist
|
||||
|
||||
## Core Services
|
||||
|
||||
### 1. Storage Service Layer
|
||||
- [ ] Create base `StorageService` interface
|
||||
- [ ] Define common methods for all platforms
|
||||
- [ ] Add platform-specific method signatures
|
||||
- [ ] Include error handling types
|
||||
- [ ] Add migration support methods
|
||||
|
||||
- [ ] Implement platform-specific services
|
||||
- [ ] `WebSQLiteService` (wa-sqlite)
|
||||
- [ ] Database initialization
|
||||
- [ ] VFS setup
|
||||
- [ ] Connection management
|
||||
- [ ] Query builder
|
||||
- [ ] `NativeSQLiteService` (iOS/Android)
|
||||
- [ ] SQLCipher integration
|
||||
- [ ] Native bridge setup
|
||||
- [ ] File system access
|
||||
- [ ] `ElectronSQLiteService`
|
||||
- [ ] Node SQLite integration
|
||||
- [ ] IPC communication
|
||||
- [ ] File system access
|
||||
|
||||
### 2. Migration Services
|
||||
- [ ] Implement `MigrationService`
|
||||
- [ ] Backup creation
|
||||
- [ ] Data verification
|
||||
- [ ] Rollback procedures
|
||||
- [ ] Progress tracking
|
||||
- [ ] Create `MigrationUI` components
|
||||
- [ ] Progress indicators
|
||||
- [ ] Error handling
|
||||
- [ ] User notifications
|
||||
- [ ] Manual triggers
|
||||
|
||||
### 3. Security Layer
|
||||
- [ ] Implement `EncryptionService`
|
||||
- [ ] Key management
|
||||
- [ ] Encryption/decryption
|
||||
- [ ] Secure storage
|
||||
- [ ] Add `BiometricService`
|
||||
- [ ] Platform detection
|
||||
- [ ] Authentication flow
|
||||
- [ ] Fallback mechanisms
|
||||
|
||||
## Platform-Specific Implementation
|
||||
|
||||
### Web Platform
|
||||
- [ ] Setup wa-sqlite
|
||||
- [ ] Install dependencies
|
||||
```json
|
||||
{
|
||||
"@wa-sqlite/sql.js": "^0.8.12",
|
||||
"@wa-sqlite/sql.js-httpvfs": "^0.8.12"
|
||||
}
|
||||
```
|
||||
(May not need httpvfs. Here's one recommended install method: `npm add github:rhashimoto/wa-sqlite`)
|
||||
- [ ] Configure VFS for IndexedDB (eg. IDBBatchAtomicVFS)
|
||||
- [ ] Setup worker threads
|
||||
- [ ] Implement connection pooling
|
||||
|
||||
- [ ] Update build configuration
|
||||
- [ ] Modify `vite.config.ts`
|
||||
- [ ] Add worker configuration
|
||||
- [ ] Update chunk splitting
|
||||
- [ ] Configure asset handling
|
||||
|
||||
- [ ] Implement IndexedDB fallback
|
||||
- [ ] Create fallback service
|
||||
- [ ] Add data synchronization
|
||||
- [ ] Handle quota exceeded
|
||||
|
||||
### iOS Platform
|
||||
- [ ] Setup SQLCipher
|
||||
- [ ] Install pod dependencies
|
||||
- [ ] Configure encryption
|
||||
- [ ] Setup keychain access
|
||||
- [ ] Implement secure storage
|
||||
|
||||
- [ ] Update Capacitor config
|
||||
- [ ] Modify `capacitor.config.ts`
|
||||
- [ ] Add iOS permissions
|
||||
- [ ] Configure backup
|
||||
- [ ] Setup app groups
|
||||
|
||||
### Android Platform
|
||||
- [ ] Setup SQLCipher
|
||||
- [ ] Add Gradle dependencies
|
||||
- [ ] Configure encryption
|
||||
- [ ] Setup keystore
|
||||
- [ ] Implement secure storage
|
||||
|
||||
- [ ] Update Capacitor config
|
||||
- [ ] Modify `capacitor.config.ts`
|
||||
- [ ] Add Android permissions
|
||||
- [ ] Configure backup
|
||||
- [ ] Setup file provider
|
||||
|
||||
### Electron Platform
|
||||
- [ ] Setup Node SQLite
|
||||
- [ ] Install dependencies
|
||||
- [ ] Configure IPC
|
||||
- [ ] Setup file system access
|
||||
- [ ] Implement secure storage
|
||||
|
||||
- [ ] Update Electron config
|
||||
- [ ] Modify `electron.config.ts`
|
||||
- [ ] Add security policies
|
||||
- [ ] Configure file access
|
||||
- [ ] Setup auto-updates
|
||||
|
||||
## Data Models and Types
|
||||
|
||||
### 1. Database Schema
|
||||
- [ ] Define tables
|
||||
```sql
|
||||
-- Accounts table
|
||||
CREATE TABLE accounts (
|
||||
did TEXT PRIMARY KEY,
|
||||
public_key_hex TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- Settings table
|
||||
CREATE TABLE settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- Contacts table
|
||||
CREATE TABLE contacts (
|
||||
id TEXT PRIMARY KEY,
|
||||
did TEXT NOT NULL,
|
||||
name TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (did) REFERENCES accounts(did)
|
||||
);
|
||||
```
|
||||
|
||||
- [ ] Create indexes
|
||||
- [ ] Define constraints
|
||||
- [ ] Add triggers
|
||||
- [ ] Setup migrations
|
||||
|
||||
### 2. Type Definitions
|
||||
- [ ] Create interfaces
|
||||
```typescript
|
||||
interface Account {
|
||||
did: string;
|
||||
publicKeyHex: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
interface Setting {
|
||||
key: string;
|
||||
value: string;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
interface Contact {
|
||||
id: string;
|
||||
did: string;
|
||||
name?: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] Add validation
|
||||
- [ ] Create DTOs
|
||||
- [ ] Define enums
|
||||
- [ ] Add type guards
|
||||
|
||||
## UI Components
|
||||
|
||||
### 1. Migration UI
|
||||
- [ ] Create components
|
||||
- [ ] `MigrationProgress.vue`
|
||||
- [ ] `MigrationError.vue`
|
||||
- [ ] `MigrationSettings.vue`
|
||||
- [ ] `MigrationStatus.vue`
|
||||
|
||||
### 2. Settings UI
|
||||
- [ ] Update components
|
||||
- [ ] Add storage settings
|
||||
- [ ] Add migration controls
|
||||
- [ ] Add backup options
|
||||
- [ ] Add security settings
|
||||
|
||||
### 3. Error Handling UI
|
||||
- [ ] Create components
|
||||
- [ ] `StorageError.vue`
|
||||
- [ ] `QuotaExceeded.vue`
|
||||
- [ ] `MigrationFailed.vue`
|
||||
- [ ] `RecoveryOptions.vue`
|
||||
|
||||
## Testing
|
||||
|
||||
### 1. Unit Tests
|
||||
- [ ] Test services
|
||||
- [ ] Storage service tests
|
||||
- [ ] Migration service tests
|
||||
- [ ] Security service tests
|
||||
- [ ] Platform detection tests
|
||||
|
||||
### 2. Integration Tests
|
||||
- [ ] Test migrations
|
||||
- [ ] Web platform tests
|
||||
- [ ] iOS platform tests
|
||||
- [ ] Android platform tests
|
||||
- [ ] Electron platform tests
|
||||
|
||||
### 3. E2E Tests
|
||||
- [ ] Test workflows
|
||||
- [ ] Account management
|
||||
- [ ] Settings management
|
||||
- [ ] Contact management
|
||||
- [ ] Migration process
|
||||
|
||||
## Documentation
|
||||
|
||||
### 1. Technical Documentation
|
||||
- [ ] Update architecture docs
|
||||
- [ ] Add API documentation
|
||||
- [ ] Create migration guides
|
||||
- [ ] Document security measures
|
||||
|
||||
### 2. User Documentation
|
||||
- [ ] Update user guides
|
||||
- [ ] Add troubleshooting guides
|
||||
- [ ] Create FAQ
|
||||
- [ ] Document new features
|
||||
|
||||
## Deployment
|
||||
|
||||
### 1. Build Process
|
||||
- [ ] Update build scripts
|
||||
- [ ] Add platform-specific builds
|
||||
- [ ] Configure CI/CD
|
||||
- [ ] Setup automated testing
|
||||
|
||||
### 2. Release Process
|
||||
- [ ] Create release checklist
|
||||
- [ ] Add version management
|
||||
- [ ] Setup rollback procedures
|
||||
- [ ] Configure monitoring
|
||||
|
||||
## Monitoring and Analytics
|
||||
|
||||
### 1. Error Tracking
|
||||
- [ ] Setup error logging
|
||||
- [ ] Add performance monitoring
|
||||
- [ ] Configure alerts
|
||||
- [ ] Create dashboards
|
||||
|
||||
### 2. Usage Analytics
|
||||
- [ ] Add storage metrics
|
||||
- [ ] Track migration success
|
||||
- [ ] Monitor performance
|
||||
- [ ] Collect user feedback
|
||||
|
||||
## Security Audit
|
||||
|
||||
### 1. Code Review
|
||||
- [ ] Review encryption
|
||||
- [ ] Check access controls
|
||||
- [ ] Verify data handling
|
||||
- [ ] Audit dependencies
|
||||
|
||||
### 2. Penetration Testing
|
||||
- [ ] Test data access
|
||||
- [ ] Verify encryption
|
||||
- [ ] Check authentication
|
||||
- [ ] Review permissions
|
||||
|
||||
## Success Criteria
|
||||
|
||||
### 1. Performance
|
||||
- [ ] Query response time < 100ms
|
||||
- [ ] Migration time < 5s per 1000 records
|
||||
- [ ] Storage overhead < 10%
|
||||
- [ ] Memory usage < 50MB
|
||||
|
||||
### 2. Reliability
|
||||
- [ ] 99.9% uptime
|
||||
- [ ] Zero data loss
|
||||
- [ ] Automatic recovery
|
||||
- [ ] Backup verification
|
||||
|
||||
### 3. Security
|
||||
- [ ] AES-256 encryption
|
||||
- [ ] Secure key storage
|
||||
- [ ] Access control
|
||||
- [ ] Audit logging
|
||||
|
||||
### 4. User Experience
|
||||
- [ ] Smooth migration
|
||||
- [ ] Clear error messages
|
||||
- [ ] Progress indicators
|
||||
- [ ] Recovery options
|
||||
13
ios/.gitignore
vendored
@@ -11,16 +11,3 @@ capacitor-cordova-ios-plugins
|
||||
# Generated Config files
|
||||
App/App/capacitor.config.json
|
||||
App/App/config.xml
|
||||
|
||||
# User-specific Xcode files
|
||||
App/App.xcodeproj/xcuserdata/*.xcuserdatad/
|
||||
App/App.xcodeproj/*.xcuserstate
|
||||
|
||||
fastlane/report.xml
|
||||
fastlane/Preview.html
|
||||
fastlane/screenshots
|
||||
fastlane/test_output
|
||||
|
||||
# Generated Icons from capacitor-assets (also Contents.json which is confusing; see BUILDING.md)
|
||||
App/App/Assets.xcassets/AppIcon.appiconset
|
||||
App/App/Assets.xcassets/Splash.imageset
|
||||
|
||||
@@ -380,7 +380,6 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 18;
|
||||
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
||||
ENABLE_APP_SANDBOX = NO;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
@@ -407,7 +406,6 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 18;
|
||||
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
||||
ENABLE_APP_SANDBOX = NO;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
|
||||
|
After Width: | Height: | Size: 116 KiB |
14
ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"idiom": "universal",
|
||||
"size": "1024x1024",
|
||||
"filename": "AppIcon-512@2x.png",
|
||||
"platform": "ios"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
23
ios/App/App/Assets.xcassets/Splash.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "splash-2732x2732-2.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "splash-2732x2732-1.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "splash-2732x2732.png",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
BIN
ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-1.png
vendored
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-2.png
vendored
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732.png
vendored
Normal file
|
After Width: | Height: | Size: 40 KiB |
29
main.js
@@ -1,29 +0,0 @@
|
||||
const { app, BrowserWindow } = require('electron');
|
||||
const path = require('path');
|
||||
|
||||
function createWindow() {
|
||||
const win = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 800,
|
||||
webPreferences: {
|
||||
nodeIntegration: true,
|
||||
contextIsolation: false
|
||||
}
|
||||
});
|
||||
|
||||
win.loadFile(path.join(__dirname, 'dist-electron/www/index.html'));
|
||||
}
|
||||
|
||||
app.whenReady().then(createWindow);
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
}
|
||||
});
|
||||
@@ -165,7 +165,7 @@
|
||||
},
|
||||
"main": "./dist-electron/main.js",
|
||||
"build": {
|
||||
"appId": "app.timesafari.app",
|
||||
"appId": "app.timesafari",
|
||||
"productName": "TimeSafari",
|
||||
"directories": {
|
||||
"output": "dist-electron-packages"
|
||||
|
||||
@@ -51,7 +51,7 @@ const { existsSync } = require('fs');
|
||||
*/
|
||||
function checkCommand(command, errorMessage) {
|
||||
try {
|
||||
execSync(command, { stdio: 'ignore' });
|
||||
execSync(command + ' --version', { stdio: 'ignore' });
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error(`❌ ${errorMessage}`);
|
||||
@@ -164,10 +164,10 @@ function main() {
|
||||
|
||||
// Check required command line tools
|
||||
// These are essential for building and testing the application
|
||||
success &= checkCommand('node --version', 'Node.js is required');
|
||||
success &= checkCommand('npm --version', 'npm is required');
|
||||
success &= checkCommand('gradle --version', 'Gradle is required for Android builds');
|
||||
success &= checkCommand('xcodebuild --help', 'Xcode is required for iOS builds');
|
||||
success &= checkCommand('node', 'Node.js is required');
|
||||
success &= checkCommand('npm', 'npm is required');
|
||||
success &= checkCommand('gradle', 'Gradle is required for Android builds');
|
||||
success &= checkCommand('xcodebuild', 'Xcode is required for iOS builds');
|
||||
|
||||
// Check platform-specific development environments
|
||||
success &= checkAndroidSetup();
|
||||
|
||||
@@ -170,7 +170,7 @@ const executeDeeplink = async (url, description, log) => {
|
||||
|
||||
try {
|
||||
// Stop the app before executing the deep link
|
||||
execSync('adb shell am force-stop app.timesafari.app');
|
||||
execSync('adb shell am force-stop app.timesafari');
|
||||
await new Promise(resolve => setTimeout(resolve, 1000)); // Wait 1s
|
||||
|
||||
execSync(`adb shell am start -W -a android.intent.action.VIEW -d "${url}" -c android.intent.category.BROWSABLE`);
|
||||
|
||||
@@ -14,37 +14,22 @@
|
||||
class="flex items-center justify-between gap-2 text-lg bg-slate-200 border border-slate-300 border-b-0 rounded-t-md px-3 sm:px-4 py-1 sm:py-2"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<router-link
|
||||
v-if="record.issuerDid && !isHiddenDid(record.issuerDid)"
|
||||
:to="{
|
||||
path: '/did/' + encodeURIComponent(record.issuerDid),
|
||||
}"
|
||||
title="More details about this person"
|
||||
>
|
||||
<div v-if="record.issuerDid">
|
||||
<EntityIcon
|
||||
:entity-id="record.issuerDid"
|
||||
class="rounded-full bg-white overflow-hidden !size-[2rem] object-cover"
|
||||
/>
|
||||
</router-link>
|
||||
<font-awesome
|
||||
v-else-if="isHiddenDid(record.issuerDid)"
|
||||
icon="eye-slash"
|
||||
class="text-slate-400 !size-[2rem] cursor-pointer"
|
||||
@click="notifyHiddenPerson"
|
||||
/>
|
||||
<font-awesome
|
||||
v-else
|
||||
icon="person-circle-question"
|
||||
class="text-slate-400 !size-[2rem] cursor-pointer"
|
||||
@click="notifyUnknownPerson"
|
||||
/>
|
||||
</div>
|
||||
<div v-else>
|
||||
<font-awesome
|
||||
icon="person-circle-question"
|
||||
class="text-slate-300 text-[2rem]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3
|
||||
v-if="record.issuer.known"
|
||||
class="font-semibold leading-tight"
|
||||
>
|
||||
{{ record.issuer.displayName }}
|
||||
<h3 class="font-semibold">
|
||||
{{ record.issuer.known ? record.issuer.displayName : "" }}
|
||||
</h3>
|
||||
<p class="ms-auto text-xs text-slate-500 italic">
|
||||
{{ friendlyDate }}
|
||||
@@ -61,7 +46,7 @@
|
||||
<!-- Record Image -->
|
||||
<div
|
||||
v-if="record.image"
|
||||
class="bg-cover mb-4 -mt-3 sm:-mt-4 -mx-3 sm:-mx-4"
|
||||
class="bg-cover mb-6 -mt-3 sm:-mt-4 -mx-3 sm:-mx-4"
|
||||
:style="`background-image: url(${record.image});`"
|
||||
>
|
||||
<a
|
||||
@@ -78,55 +63,33 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="relative flex justify-between gap-4 max-w-[40rem] mx-auto mb-3"
|
||||
class="relative flex justify-between gap-4 max-w-[40rem] mx-auto mb-5"
|
||||
>
|
||||
<!-- Source -->
|
||||
<div
|
||||
class="w-[7rem] sm:w-[12rem] text-center bg-white border border-slate-200 rounded p-2 sm:p-3"
|
||||
class="w-[8rem] sm:w-[12rem] text-center bg-white border border-slate-200 rounded p-2 sm:p-3"
|
||||
>
|
||||
<div class="relative w-fit mx-auto">
|
||||
<div>
|
||||
<!-- Project Icon -->
|
||||
<div v-if="record.providerPlanName">
|
||||
<router-link
|
||||
:to="{
|
||||
path: '/project/' + encodeURIComponent(record.providerPlanHandleId || ''),
|
||||
}"
|
||||
title="View project details"
|
||||
>
|
||||
<ProjectIcon
|
||||
:entity-id="record.providerPlanHandleId || ''"
|
||||
:icon-size="48"
|
||||
class="rounded size-[3rem] sm:size-[4rem] *:w-full *:h-full"
|
||||
/>
|
||||
</router-link>
|
||||
<ProjectIcon
|
||||
:entity-id="record.providerPlanName"
|
||||
:icon-size="48"
|
||||
class="rounded size-[3rem] sm:size-[4rem] *:w-full *:h-full"
|
||||
/>
|
||||
</div>
|
||||
<!-- Identicon for DIDs -->
|
||||
<div v-else-if="record.agentDid">
|
||||
<router-link
|
||||
v-if="!isHiddenDid(record.agentDid)"
|
||||
:to="{
|
||||
path: '/did/' + encodeURIComponent(record.agentDid),
|
||||
}"
|
||||
title="More details about this person"
|
||||
>
|
||||
<EntityIcon
|
||||
:entity-id="record.agentDid"
|
||||
:profile-image-url="record.issuer.profileImageUrl"
|
||||
class="rounded-full bg-slate-100 overflow-hidden !size-[3rem] sm:!size-[4rem]"
|
||||
/>
|
||||
</router-link>
|
||||
<font-awesome
|
||||
v-else
|
||||
@click="notifyHiddenPerson"
|
||||
icon="eye-slash"
|
||||
class="text-slate-300 !size-[3rem] sm:!size-[4rem]"
|
||||
<EntityIcon
|
||||
:entity-id="record.agentDid"
|
||||
:profile-image-url="record.issuer.profileImageUrl"
|
||||
class="rounded-full bg-slate-100 overflow-hidden !size-[3rem] sm:!size-[4rem]"
|
||||
/>
|
||||
</div>
|
||||
<!-- Unknown Person -->
|
||||
<div v-else>
|
||||
<font-awesome
|
||||
@click="notifyUnknownPerson"
|
||||
icon="person-circle-question"
|
||||
class="text-slate-300 text-[3rem] sm:text-[4rem]"
|
||||
/>
|
||||
@@ -147,9 +110,9 @@
|
||||
|
||||
<!-- Arrow -->
|
||||
<div
|
||||
class="absolute inset-x-[7rem] sm:inset-x-[12rem] mx-2 top-1/2 -translate-y-1/2"
|
||||
class="absolute inset-x-[8rem] sm:inset-x-[12rem] mx-2 top-1/2 -translate-y-1/2"
|
||||
>
|
||||
<div class="text-sm text-center leading-none font-semibold pe-2 sm:pe-4">
|
||||
<div class="text-sm text-center leading-none font-semibold pe-[15px]">
|
||||
{{ fetchAmount }}
|
||||
</div>
|
||||
|
||||
@@ -166,51 +129,29 @@
|
||||
|
||||
<!-- Destination -->
|
||||
<div
|
||||
class="w-[7rem] sm:w-[12rem] text-center bg-white border border-slate-200 rounded p-2 sm:p-3"
|
||||
class="w-[8rem] sm:w-[12rem] text-center bg-white border border-slate-200 rounded p-2 sm:p-3"
|
||||
>
|
||||
<div class="relative w-fit mx-auto">
|
||||
<div>
|
||||
<!-- Project Icon -->
|
||||
<div v-if="record.recipientProjectName">
|
||||
<router-link
|
||||
:to="{
|
||||
path: '/project/' + encodeURIComponent(record.fulfillsPlanHandleId || ''),
|
||||
}"
|
||||
title="View project details"
|
||||
>
|
||||
<ProjectIcon
|
||||
:entity-id="record.fulfillsPlanHandleId || ''"
|
||||
:icon-size="48"
|
||||
class="rounded size-[3rem] sm:size-[4rem] *:w-full *:h-full"
|
||||
/>
|
||||
</router-link>
|
||||
<ProjectIcon
|
||||
:entity-id="record.recipientProjectName"
|
||||
:icon-size="48"
|
||||
class="rounded size-[3rem] sm:size-[4rem] *:w-full *:h-full"
|
||||
/>
|
||||
</div>
|
||||
<!-- Identicon for DIDs -->
|
||||
<div v-else-if="record.recipientDid">
|
||||
<router-link
|
||||
v-if="!isHiddenDid(record.recipientDid)"
|
||||
:to="{
|
||||
path: '/did/' + encodeURIComponent(record.recipientDid),
|
||||
}"
|
||||
title="More details about this person"
|
||||
>
|
||||
<EntityIcon
|
||||
:entity-id="record.recipientDid"
|
||||
:profile-image-url="record.receiver.profileImageUrl"
|
||||
class="rounded-full bg-slate-100 overflow-hidden !size-[3rem] sm:!size-[4rem]"
|
||||
/>
|
||||
</router-link>
|
||||
<font-awesome
|
||||
v-else
|
||||
@click="notifyHiddenPerson"
|
||||
icon="eye-slash"
|
||||
class="text-slate-300 !size-[3rem] sm:!size-[4rem]"
|
||||
<EntityIcon
|
||||
:entity-id="record.recipientDid"
|
||||
:profile-image-url="record.receiver.profileImageUrl"
|
||||
class="rounded-full bg-slate-100 overflow-hidden !size-[3rem] sm:!size-[4rem]"
|
||||
/>
|
||||
</div>
|
||||
<!-- Unknown Person -->
|
||||
<div v-else>
|
||||
<font-awesome
|
||||
@click="notifyUnknownPerson"
|
||||
icon="person-circle-question"
|
||||
class="text-slate-300 text-[3rem] sm:text-[4rem]"
|
||||
/>
|
||||
@@ -245,9 +186,8 @@ import { Component, Prop, Vue, Emit } from "vue-facing-decorator";
|
||||
import { GiveRecordWithContactInfo } from "../types";
|
||||
import EntityIcon from "./EntityIcon.vue";
|
||||
import { isGiveClaimType, notifyWhyCannotConfirm } from "../libs/util";
|
||||
import { containsHiddenDid, isHiddenDid } from "../libs/endorserServer";
|
||||
import { containsHiddenDid } from "../libs/endorserServer";
|
||||
import ProjectIcon from "./ProjectIcon.vue";
|
||||
import { NotificationIface } from "../constants/app";
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
@@ -262,33 +202,6 @@ export default class ActivityListItem extends Vue {
|
||||
@Prop() activeDid!: string;
|
||||
@Prop() confirmerIdList?: string[];
|
||||
|
||||
isHiddenDid = isHiddenDid;
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||
|
||||
notifyHiddenPerson() {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "warning",
|
||||
title: "Person Outside Your Network",
|
||||
text: "This person is not visible to you.",
|
||||
},
|
||||
3000
|
||||
);
|
||||
}
|
||||
|
||||
notifyUnknownPerson() {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "warning",
|
||||
title: "Unidentified Person",
|
||||
text: "Nobody specific was recognized.",
|
||||
},
|
||||
3000
|
||||
);
|
||||
}
|
||||
|
||||
@Emit()
|
||||
cacheImage(image: string) {
|
||||
return image;
|
||||
|
||||
@@ -136,7 +136,7 @@ export default class DataExportSection extends Vue {
|
||||
transform: (table, value, key) => {
|
||||
if (table === "contacts") {
|
||||
// Dexie inserts a number 0 when some are undefined, so we need to totally remove them.
|
||||
Object.keys(value).forEach((prop) => {
|
||||
Object.keys(value).forEach(prop => {
|
||||
if (value[prop] === undefined) {
|
||||
delete value[prop];
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<div class="text-lg text-center font-bold relative">
|
||||
<h1 id="ViewHeading" class="text-center font-bold">
|
||||
<span v-if="uploading">Uploading Image…</span>
|
||||
<span v-else-if="blob">{{ crop ? 'Crop Image' : 'Preview Image' }}</span>
|
||||
<span v-else-if="blob">Crop Image</span>
|
||||
<span v-else-if="showCameraPreview">Upload Image</span>
|
||||
<span v-else>Add Photo</span>
|
||||
</h1>
|
||||
@@ -119,21 +119,12 @@
|
||||
playsinline
|
||||
muted
|
||||
></video>
|
||||
<div class="absolute bottom-4 inset-x-0 flex items-center justify-center gap-4">
|
||||
<button
|
||||
class="bg-white text-slate-800 p-3 rounded-full text-2xl leading-none"
|
||||
@click="capturePhoto"
|
||||
>
|
||||
<font-awesome icon="camera" class="w-[1em]" />
|
||||
</button>
|
||||
<button
|
||||
v-if="platformCapabilities.isMobile"
|
||||
class="bg-white text-slate-800 p-3 rounded-full text-2xl leading-none"
|
||||
@click="rotateCamera"
|
||||
>
|
||||
<font-awesome icon="rotate" class="w-[1em]" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
class="absolute bottom-4 left-1/2 -translate-x-1/2 bg-white text-slate-800 p-3 rounded-full text-2xl leading-none"
|
||||
@click="capturePhoto"
|
||||
>
|
||||
<font-awesome icon="camera" class="w-[1em]" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -271,11 +262,6 @@ const inputImageFileNameRef = ref<Blob>();
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
defaultCameraMode: {
|
||||
type: String,
|
||||
default: 'environment',
|
||||
validator: (value: string) => ['environment', 'user'].includes(value)
|
||||
}
|
||||
},
|
||||
})
|
||||
export default class ImageMethodDialog extends Vue {
|
||||
@@ -317,9 +303,6 @@ export default class ImageMethodDialog extends Vue {
|
||||
/** Camera stream reference */
|
||||
private cameraStream: MediaStream | null = null;
|
||||
|
||||
/** Current camera facing mode */
|
||||
private currentFacingMode: 'environment' | 'user' = 'environment';
|
||||
|
||||
private platformService = PlatformServiceFactory.getInstance();
|
||||
URL = window.URL || window.webkitURL;
|
||||
|
||||
@@ -379,16 +362,15 @@ export default class ImageMethodDialog extends Vue {
|
||||
}
|
||||
|
||||
open(setImageFn: (arg: string) => void, claimType: string, crop?: boolean) {
|
||||
logger.debug("ImageMethodDialog.open called");
|
||||
this.claimType = claimType;
|
||||
this.crop = !!crop;
|
||||
this.imageCallback = setImageFn;
|
||||
this.visible = true;
|
||||
this.currentFacingMode = this.defaultCameraMode as 'environment' | 'user';
|
||||
|
||||
// Start camera preview immediately
|
||||
logger.debug("Starting camera preview from open()");
|
||||
this.startCameraPreview();
|
||||
// Start camera preview immediately if not on mobile
|
||||
if (!this.platformCapabilities.isNativeApp) {
|
||||
this.startCameraPreview();
|
||||
}
|
||||
}
|
||||
|
||||
async uploadImageFile(event: Event) {
|
||||
@@ -457,21 +439,46 @@ export default class ImageMethodDialog extends Vue {
|
||||
logger.debug("startCameraPreview called");
|
||||
logger.debug("Current showCameraPreview state:", this.showCameraPreview);
|
||||
logger.debug("Platform capabilities:", this.platformCapabilities);
|
||||
logger.debug("MediaDevices available:", !!navigator.mediaDevices);
|
||||
logger.debug("getUserMedia available:", !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia));
|
||||
|
||||
if (this.platformCapabilities.isNativeApp) {
|
||||
logger.debug("Using platform service for mobile device");
|
||||
this.cameraState = "initializing";
|
||||
this.cameraStateMessage = "Using platform camera service...";
|
||||
try {
|
||||
const result = await this.platformService.takePicture();
|
||||
this.blob = result.blob;
|
||||
this.fileName = result.fileName;
|
||||
this.cameraState = "ready";
|
||||
this.cameraStateMessage = "Photo captured successfully";
|
||||
} catch (error) {
|
||||
logger.error("Error taking picture:", error);
|
||||
this.cameraState = "error";
|
||||
this.cameraStateMessage =
|
||||
error instanceof Error ? error.message : "Failed to take picture";
|
||||
this.error =
|
||||
error instanceof Error ? error.message : "Failed to take picture";
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "Failed to take picture. Please try again.",
|
||||
},
|
||||
5000,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug("Starting camera preview for desktop browser");
|
||||
try {
|
||||
this.cameraState = "initializing";
|
||||
this.cameraStateMessage = "Requesting camera access...";
|
||||
this.showCameraPreview = true;
|
||||
await this.$nextTick();
|
||||
|
||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||
throw new Error("Camera API not available in this browser");
|
||||
}
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: this.currentFacingMode },
|
||||
video: { facingMode: "environment" },
|
||||
});
|
||||
logger.debug("Camera access granted");
|
||||
this.cameraStream = stream;
|
||||
@@ -486,41 +493,31 @@ export default class ImageMethodDialog extends Vue {
|
||||
await new Promise((resolve) => {
|
||||
videoElement.onloadedmetadata = () => {
|
||||
videoElement.play().then(() => {
|
||||
logger.debug("Video element started playing");
|
||||
resolve(true);
|
||||
}).catch(error => {
|
||||
logger.error("Error playing video:", error);
|
||||
throw error;
|
||||
});
|
||||
};
|
||||
});
|
||||
} else {
|
||||
logger.error("Video element not found");
|
||||
throw new Error("Video element not found");
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Error starting camera preview:", error);
|
||||
let errorMessage =
|
||||
error instanceof Error ? error.message : "Failed to access camera";
|
||||
if (
|
||||
error instanceof Error && (
|
||||
error.name === "NotReadableError" ||
|
||||
error.name === "TrackStartError"
|
||||
)) {
|
||||
) {
|
||||
errorMessage =
|
||||
"Camera is in use by another application. Please close any other apps or browser tabs using the camera and try again.";
|
||||
} else if (
|
||||
error instanceof Error && (
|
||||
error.name === "NotAllowedError" ||
|
||||
error.name === "PermissionDeniedError"
|
||||
)) {
|
||||
) {
|
||||
errorMessage =
|
||||
"Camera access was denied. Please allow camera access in your browser settings.";
|
||||
}
|
||||
this.cameraState = "error";
|
||||
this.cameraStateMessage = errorMessage;
|
||||
this.error = errorMessage;
|
||||
this.showCameraPreview = false;
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
@@ -530,6 +527,7 @@ export default class ImageMethodDialog extends Vue {
|
||||
},
|
||||
5000,
|
||||
);
|
||||
this.showCameraPreview = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -581,20 +579,6 @@ export default class ImageMethodDialog extends Vue {
|
||||
}
|
||||
}
|
||||
|
||||
async rotateCamera() {
|
||||
// Toggle between front and back cameras
|
||||
this.currentFacingMode = this.currentFacingMode === 'environment' ? 'user' : 'environment';
|
||||
|
||||
// Stop current stream
|
||||
if (this.cameraStream) {
|
||||
this.cameraStream.getTracks().forEach(track => track.stop());
|
||||
this.cameraStream = null;
|
||||
}
|
||||
|
||||
// Start new stream with updated facing mode
|
||||
await this.startCameraPreview();
|
||||
}
|
||||
|
||||
private createBlobURL(blob: Blob): string {
|
||||
return URL.createObjectURL(blob);
|
||||
}
|
||||
@@ -629,7 +613,6 @@ export default class ImageMethodDialog extends Vue {
|
||||
5000,
|
||||
);
|
||||
this.uploading = false;
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
formData.append("image", this.blob, this.fileName || "photo.jpg");
|
||||
@@ -684,7 +667,6 @@ export default class ImageMethodDialog extends Vue {
|
||||
);
|
||||
this.uploading = false;
|
||||
this.blob = undefined;
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -296,7 +296,7 @@ export default class MembersList extends Vue {
|
||||
this.decryptedMembers.length === 0 ||
|
||||
this.decryptedMembers[0].member.memberId !== this.members[0].memberId
|
||||
) {
|
||||
return "Your password is not the same as the organizer. Retry or have them check their password.";
|
||||
return "Your password is not the same as the organizer. Reload or have them check their password.";
|
||||
} else {
|
||||
// the first (organizer) member was decrypted OK
|
||||
return "";
|
||||
@@ -337,7 +337,7 @@ export default class MembersList extends Vue {
|
||||
group: "alert",
|
||||
type: "info",
|
||||
title: "Contact Exists",
|
||||
text: "They are in your contacts. To remove them, use the contacts page.",
|
||||
text: "They are in your contacts. If you want to remove them, you must do that from the contacts screen.",
|
||||
},
|
||||
10000,
|
||||
);
|
||||
@@ -347,7 +347,7 @@ export default class MembersList extends Vue {
|
||||
group: "alert",
|
||||
type: "info",
|
||||
title: "Contact Available",
|
||||
text: "This is to add them to your contacts. To remove them later, use the contacts page.",
|
||||
text: "This is to add them to your contacts. If you want to remove them later, you must do that from the contacts screen.",
|
||||
},
|
||||
10000,
|
||||
);
|
||||
|
||||
@@ -90,10 +90,7 @@ db.on("populate", async () => {
|
||||
try {
|
||||
await db.settings.add(DEFAULT_SETTINGS);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"Error populating the database with default settings:",
|
||||
error,
|
||||
);
|
||||
console.error("Error populating the database with default settings:", error);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -108,7 +105,7 @@ async function safeOpenDatabase(retries = 1, delay = 500): Promise<void> {
|
||||
|
||||
// Create a promise that rejects after 5 seconds
|
||||
const timeoutPromise = new Promise((_, reject) => {
|
||||
setTimeout(() => reject(new Error("Database open timed out")), 500);
|
||||
setTimeout(() => reject(new Error('Database open timed out')), 500);
|
||||
});
|
||||
|
||||
// Race between the open operation and the timeout
|
||||
@@ -126,7 +123,7 @@ async function safeOpenDatabase(retries = 1, delay = 500): Promise<void> {
|
||||
console.error(`Attempt ${i + 1}: Database open failed:`, error);
|
||||
if (i < retries - 1) {
|
||||
console.log(`Attempt ${i + 1}: Waiting ${delay}ms before retry...`);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
@@ -148,16 +145,10 @@ export async function updateDefaultSettings(
|
||||
await safeOpenDatabase();
|
||||
} catch (openError: unknown) {
|
||||
console.error("Failed to open database:", openError);
|
||||
const errorMessage =
|
||||
openError instanceof Error ? openError.message : String(openError);
|
||||
throw new Error(
|
||||
`Database connection failed: ${errorMessage}. Please try again or restart the app.`,
|
||||
);
|
||||
const errorMessage = openError instanceof Error ? openError.message : String(openError);
|
||||
throw new Error(`Database connection failed: ${errorMessage}. Please try again or restart the app.`);
|
||||
}
|
||||
const result = await db.settings.update(
|
||||
MASTER_SETTINGS_KEY,
|
||||
settingsChanges,
|
||||
);
|
||||
const result = await db.settings.update(MASTER_SETTINGS_KEY, settingsChanges);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("Error updating default settings:", error);
|
||||
|
||||
293
src/db/sqlite/init.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
/**
|
||||
* SQLite Database Initialization
|
||||
*
|
||||
* This module handles database initialization, including:
|
||||
* - Database connection management
|
||||
* - Schema creation and migration
|
||||
* - Connection pooling and lifecycle
|
||||
* - Error handling and recovery
|
||||
*/
|
||||
|
||||
import { Database, SQLite3 } from '@wa-sqlite/sql.js';
|
||||
import { DATABASE_SCHEMA, SQLiteTable } from './types';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
// ============================================================================
|
||||
// Database Connection Management
|
||||
// ============================================================================
|
||||
|
||||
export interface DatabaseConnection {
|
||||
db: Database;
|
||||
sqlite3: SQLite3;
|
||||
isOpen: boolean;
|
||||
lastUsed: number;
|
||||
}
|
||||
|
||||
let connection: DatabaseConnection | null = null;
|
||||
const CONNECTION_TIMEOUT = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
/**
|
||||
* Initialize the SQLite database connection
|
||||
*/
|
||||
export async function initDatabase(): Promise<DatabaseConnection> {
|
||||
if (connection?.isOpen) {
|
||||
connection.lastUsed = Date.now();
|
||||
return connection;
|
||||
}
|
||||
|
||||
try {
|
||||
const sqlite3 = await import('@wa-sqlite/sql.js');
|
||||
const db = await sqlite3.open(':memory:'); // TODO: Configure storage location
|
||||
|
||||
// Enable foreign keys
|
||||
await db.exec('PRAGMA foreign_keys = ON;');
|
||||
|
||||
// Configure for better performance
|
||||
await db.exec(`
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA synchronous = NORMAL;
|
||||
PRAGMA cache_size = -2000; -- Use 2MB of cache
|
||||
`);
|
||||
|
||||
connection = {
|
||||
db,
|
||||
sqlite3,
|
||||
isOpen: true,
|
||||
lastUsed: Date.now()
|
||||
};
|
||||
|
||||
// Start connection cleanup interval
|
||||
startConnectionCleanup();
|
||||
|
||||
return connection;
|
||||
} catch (error) {
|
||||
logger.error('[SQLite] Database initialization failed:', error);
|
||||
throw new Error('Failed to initialize database');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the database connection
|
||||
*/
|
||||
export async function closeDatabase(): Promise<void> {
|
||||
if (!connection?.isOpen) return;
|
||||
|
||||
try {
|
||||
await connection.db.close();
|
||||
connection.isOpen = false;
|
||||
connection = null;
|
||||
} catch (error) {
|
||||
logger.error('[SQLite] Database close failed:', error);
|
||||
throw new Error('Failed to close database');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup inactive connections
|
||||
*/
|
||||
function startConnectionCleanup(): void {
|
||||
setInterval(() => {
|
||||
if (connection && Date.now() - connection.lastUsed > CONNECTION_TIMEOUT) {
|
||||
closeDatabase().catch(error => {
|
||||
logger.error('[SQLite] Connection cleanup failed:', error);
|
||||
});
|
||||
}
|
||||
}, 60000); // Check every minute
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Schema Management
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Create the database schema
|
||||
*/
|
||||
export async function createSchema(): Promise<void> {
|
||||
const { db } = await initDatabase();
|
||||
|
||||
try {
|
||||
await db.transaction(async () => {
|
||||
for (const table of DATABASE_SCHEMA) {
|
||||
await createTable(db, table);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('[SQLite] Schema creation failed:', error);
|
||||
throw new Error('Failed to create database schema');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a single table
|
||||
*/
|
||||
async function createTable(db: Database, table: SQLiteTable): Promise<void> {
|
||||
const columnDefs = table.columns.map(col => {
|
||||
const constraints = [
|
||||
col.primaryKey ? 'PRIMARY KEY' : '',
|
||||
col.unique ? 'UNIQUE' : '',
|
||||
!col.nullable ? 'NOT NULL' : '',
|
||||
col.references ? `REFERENCES ${col.references.table}(${col.references.column})` : '',
|
||||
col.default !== undefined ? `DEFAULT ${formatDefaultValue(col.default)}` : ''
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return `${col.name} ${col.type} ${constraints}`.trim();
|
||||
});
|
||||
|
||||
const createTableSQL = `
|
||||
CREATE TABLE IF NOT EXISTS ${table.name} (
|
||||
${columnDefs.join(',\n ')}
|
||||
);
|
||||
`;
|
||||
|
||||
await db.exec(createTableSQL);
|
||||
|
||||
// Create indexes
|
||||
if (table.indexes) {
|
||||
for (const index of table.indexes) {
|
||||
const createIndexSQL = `
|
||||
CREATE INDEX IF NOT EXISTS ${index.name}
|
||||
ON ${table.name} (${index.columns.join(', ')})
|
||||
${index.unique ? 'UNIQUE' : ''};
|
||||
`;
|
||||
await db.exec(createIndexSQL);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format default value for SQL
|
||||
*/
|
||||
function formatDefaultValue(value: unknown): string {
|
||||
if (value === null) return 'NULL';
|
||||
if (typeof value === 'string') return `'${value.replace(/'/g, "''")}'`;
|
||||
if (typeof value === 'number') return value.toString();
|
||||
if (typeof value === 'boolean') return value ? '1' : '0';
|
||||
throw new Error(`Unsupported default value type: ${typeof value}`);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Database Health Checks
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Check database health
|
||||
*/
|
||||
export async function checkDatabaseHealth(): Promise<{
|
||||
isHealthy: boolean;
|
||||
tables: string[];
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const { db } = await initDatabase();
|
||||
|
||||
// Check if we can query the database
|
||||
const tables = await db.selectAll<{ name: string }>(`
|
||||
SELECT name FROM sqlite_master
|
||||
WHERE type='table' AND name NOT LIKE 'sqlite_%'
|
||||
`);
|
||||
|
||||
return {
|
||||
isHealthy: true,
|
||||
tables: tables.map(t => t.name)
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('[SQLite] Health check failed:', error);
|
||||
return {
|
||||
isHealthy: false,
|
||||
tables: [],
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify database integrity
|
||||
*/
|
||||
export async function verifyDatabaseIntegrity(): Promise<{
|
||||
isIntegrityOk: boolean;
|
||||
errors: string[];
|
||||
}> {
|
||||
const { db } = await initDatabase();
|
||||
const errors: string[] = [];
|
||||
|
||||
try {
|
||||
// Run integrity check
|
||||
const result = await db.selectAll<{ integrity_check: string }>('PRAGMA integrity_check;');
|
||||
|
||||
if (result[0]?.integrity_check !== 'ok') {
|
||||
errors.push('Database integrity check failed');
|
||||
}
|
||||
|
||||
// Check foreign key constraints
|
||||
const fkResult = await db.selectAll<{ table: string; rowid: number; parent: string; fkid: number }>(`
|
||||
PRAGMA foreign_key_check;
|
||||
`);
|
||||
|
||||
if (fkResult.length > 0) {
|
||||
errors.push('Foreign key constraint violations found');
|
||||
}
|
||||
|
||||
return {
|
||||
isIntegrityOk: errors.length === 0,
|
||||
errors
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('[SQLite] Integrity check failed:', error);
|
||||
return {
|
||||
isIntegrityOk: false,
|
||||
errors: [error instanceof Error ? error.message : 'Unknown error']
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Database Backup and Recovery
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Create a database backup
|
||||
*/
|
||||
export async function createBackup(): Promise<Uint8Array> {
|
||||
const { db } = await initDatabase();
|
||||
|
||||
try {
|
||||
// Export the database to a binary array
|
||||
return await db.export();
|
||||
} catch (error) {
|
||||
logger.error('[SQLite] Backup creation failed:', error);
|
||||
throw new Error('Failed to create database backup');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore database from backup
|
||||
*/
|
||||
export async function restoreFromBackup(backup: Uint8Array): Promise<void> {
|
||||
const { db } = await initDatabase();
|
||||
|
||||
try {
|
||||
// Close current connection
|
||||
await closeDatabase();
|
||||
|
||||
// Create new connection and import backup
|
||||
const sqlite3 = await import('@wa-sqlite/sql.js');
|
||||
const newDb = await sqlite3.open(backup);
|
||||
|
||||
// Verify integrity
|
||||
const { isIntegrityOk, errors } = await verifyDatabaseIntegrity();
|
||||
if (!isIntegrityOk) {
|
||||
throw new Error(`Backup integrity check failed: ${errors.join(', ')}`);
|
||||
}
|
||||
|
||||
// Replace current connection
|
||||
connection = {
|
||||
db: newDb,
|
||||
sqlite3,
|
||||
isOpen: true,
|
||||
lastUsed: Date.now()
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('[SQLite] Backup restoration failed:', error);
|
||||
throw new Error('Failed to restore database from backup');
|
||||
}
|
||||
}
|
||||
374
src/db/sqlite/migration.ts
Normal file
@@ -0,0 +1,374 @@
|
||||
/**
|
||||
* SQLite Migration Utilities
|
||||
*
|
||||
* This module handles the migration of data from Dexie to SQLite,
|
||||
* including data transformation, validation, and rollback capabilities.
|
||||
*/
|
||||
|
||||
import { Database } from '@wa-sqlite/sql.js';
|
||||
import { initDatabase, createSchema, createBackup } from './init';
|
||||
import {
|
||||
MigrationData,
|
||||
MigrationResult,
|
||||
SQLiteAccount,
|
||||
SQLiteContact,
|
||||
SQLiteContactMethod,
|
||||
SQLiteSettings,
|
||||
SQLiteLog,
|
||||
SQLiteSecret,
|
||||
isSQLiteAccount,
|
||||
isSQLiteContact,
|
||||
isSQLiteSettings
|
||||
} from './types';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
// ============================================================================
|
||||
// Migration Types
|
||||
// ============================================================================
|
||||
|
||||
interface MigrationContext {
|
||||
db: Database;
|
||||
startTime: number;
|
||||
stats: MigrationResult['stats'];
|
||||
errors: Error[];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Migration Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Migrate data from Dexie to SQLite
|
||||
*/
|
||||
export async function migrateFromDexie(data: MigrationData): Promise<MigrationResult> {
|
||||
const startTime = Date.now();
|
||||
const context: MigrationContext = {
|
||||
db: (await initDatabase()).db,
|
||||
startTime,
|
||||
stats: {
|
||||
accounts: 0,
|
||||
contacts: 0,
|
||||
contactMethods: 0,
|
||||
settings: 0,
|
||||
logs: 0,
|
||||
secrets: 0
|
||||
},
|
||||
errors: []
|
||||
};
|
||||
|
||||
try {
|
||||
// Create backup before migration
|
||||
const backup = await createBackup();
|
||||
|
||||
// Create schema if needed
|
||||
await createSchema();
|
||||
|
||||
// Perform migration in a transaction
|
||||
await context.db.transaction(async () => {
|
||||
// Migrate in order of dependencies
|
||||
await migrateAccounts(context, data.accounts);
|
||||
await migrateContacts(context, data.contacts);
|
||||
await migrateContactMethods(context, data.contactMethods);
|
||||
await migrateSettings(context, data.settings);
|
||||
await migrateLogs(context, data.logs);
|
||||
await migrateSecrets(context, data.secrets);
|
||||
});
|
||||
|
||||
// Verify migration
|
||||
const verificationResult = await verifyMigration(context, data);
|
||||
if (!verificationResult.success) {
|
||||
throw new Error(`Migration verification failed: ${verificationResult.error}`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
stats: context.stats,
|
||||
duration: Date.now() - startTime
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
logger.error('[SQLite] Migration failed:', error);
|
||||
|
||||
// Attempt rollback
|
||||
try {
|
||||
await rollbackMigration(backup);
|
||||
} catch (rollbackError) {
|
||||
logger.error('[SQLite] Rollback failed:', rollbackError);
|
||||
context.errors.push(new Error('Migration and rollback failed'));
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error : new Error('Unknown migration error'),
|
||||
stats: context.stats,
|
||||
duration: Date.now() - startTime
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Migration Helpers
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Migrate accounts
|
||||
*/
|
||||
async function migrateAccounts(context: MigrationContext, accounts: SQLiteAccount[]): Promise<void> {
|
||||
for (const account of accounts) {
|
||||
try {
|
||||
if (!isSQLiteAccount(account)) {
|
||||
throw new Error(`Invalid account data: ${JSON.stringify(account)}`);
|
||||
}
|
||||
|
||||
await context.db.exec(`
|
||||
INSERT INTO accounts (
|
||||
did, public_key_hex, created_at, updated_at,
|
||||
identity_json, mnemonic_encrypted, passkey_cred_id_hex, derivation_path
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, [
|
||||
account.did,
|
||||
account.public_key_hex,
|
||||
account.created_at,
|
||||
account.updated_at,
|
||||
account.identity_json || null,
|
||||
account.mnemonic_encrypted || null,
|
||||
account.passkey_cred_id_hex || null,
|
||||
account.derivation_path || null
|
||||
]);
|
||||
|
||||
context.stats.accounts++;
|
||||
} catch (error) {
|
||||
context.errors.push(new Error(`Failed to migrate account ${account.did}: ${error}`));
|
||||
throw error; // Re-throw to trigger transaction rollback
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate contacts
|
||||
*/
|
||||
async function migrateContacts(context: MigrationContext, contacts: SQLiteContact[]): Promise<void> {
|
||||
for (const contact of contacts) {
|
||||
try {
|
||||
if (!isSQLiteContact(contact)) {
|
||||
throw new Error(`Invalid contact data: ${JSON.stringify(contact)}`);
|
||||
}
|
||||
|
||||
await context.db.exec(`
|
||||
INSERT INTO contacts (
|
||||
id, did, name, notes, profile_image_url,
|
||||
public_key_base64, next_pub_key_hash_b64,
|
||||
sees_me, registered, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, [
|
||||
contact.id,
|
||||
contact.did,
|
||||
contact.name || null,
|
||||
contact.notes || null,
|
||||
contact.profile_image_url || null,
|
||||
contact.public_key_base64 || null,
|
||||
contact.next_pub_key_hash_b64 || null,
|
||||
contact.sees_me ? 1 : 0,
|
||||
contact.registered ? 1 : 0,
|
||||
contact.created_at,
|
||||
contact.updated_at
|
||||
]);
|
||||
|
||||
context.stats.contacts++;
|
||||
} catch (error) {
|
||||
context.errors.push(new Error(`Failed to migrate contact ${contact.id}: ${error}`));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate contact methods
|
||||
*/
|
||||
async function migrateContactMethods(
|
||||
context: MigrationContext,
|
||||
methods: SQLiteContactMethod[]
|
||||
): Promise<void> {
|
||||
for (const method of methods) {
|
||||
try {
|
||||
await context.db.exec(`
|
||||
INSERT INTO contact_methods (
|
||||
id, contact_id, label, type, value,
|
||||
created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`, [
|
||||
method.id,
|
||||
method.contact_id,
|
||||
method.label,
|
||||
method.type,
|
||||
method.value,
|
||||
method.created_at,
|
||||
method.updated_at
|
||||
]);
|
||||
|
||||
context.stats.contactMethods++;
|
||||
} catch (error) {
|
||||
context.errors.push(new Error(`Failed to migrate contact method ${method.id}: ${error}`));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate settings
|
||||
*/
|
||||
async function migrateSettings(context: MigrationContext, settings: SQLiteSettings[]): Promise<void> {
|
||||
for (const setting of settings) {
|
||||
try {
|
||||
if (!isSQLiteSettings(setting)) {
|
||||
throw new Error(`Invalid settings data: ${JSON.stringify(setting)}`);
|
||||
}
|
||||
|
||||
await context.db.exec(`
|
||||
INSERT INTO settings (
|
||||
key, account_did, value_json, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?)
|
||||
`, [
|
||||
setting.key,
|
||||
setting.account_did || null,
|
||||
setting.value_json,
|
||||
setting.created_at,
|
||||
setting.updated_at
|
||||
]);
|
||||
|
||||
context.stats.settings++;
|
||||
} catch (error) {
|
||||
context.errors.push(new Error(`Failed to migrate setting ${setting.key}: ${error}`));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate logs
|
||||
*/
|
||||
async function migrateLogs(context: MigrationContext, logs: SQLiteLog[]): Promise<void> {
|
||||
for (const log of logs) {
|
||||
try {
|
||||
await context.db.exec(`
|
||||
INSERT INTO logs (
|
||||
id, level, message, metadata_json, created_at
|
||||
) VALUES (?, ?, ?, ?, ?)
|
||||
`, [
|
||||
log.id,
|
||||
log.level,
|
||||
log.message,
|
||||
log.metadata_json || null,
|
||||
log.created_at
|
||||
]);
|
||||
|
||||
context.stats.logs++;
|
||||
} catch (error) {
|
||||
context.errors.push(new Error(`Failed to migrate log ${log.id}: ${error}`));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate secrets
|
||||
*/
|
||||
async function migrateSecrets(context: MigrationContext, secrets: SQLiteSecret[]): Promise<void> {
|
||||
for (const secret of secrets) {
|
||||
try {
|
||||
await context.db.exec(`
|
||||
INSERT INTO secrets (
|
||||
key, value_encrypted, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?)
|
||||
`, [
|
||||
secret.key,
|
||||
secret.value_encrypted,
|
||||
secret.created_at,
|
||||
secret.updated_at
|
||||
]);
|
||||
|
||||
context.stats.secrets++;
|
||||
} catch (error) {
|
||||
context.errors.push(new Error(`Failed to migrate secret ${secret.key}: ${error}`));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Verification and Rollback
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Verify migration success
|
||||
*/
|
||||
async function verifyMigration(
|
||||
context: MigrationContext,
|
||||
data: MigrationData
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
// Verify counts
|
||||
const counts = await context.db.selectAll<{ table: string; count: number }>(`
|
||||
SELECT 'accounts' as table, COUNT(*) as count FROM accounts
|
||||
UNION ALL
|
||||
SELECT 'contacts', COUNT(*) FROM contacts
|
||||
UNION ALL
|
||||
SELECT 'contact_methods', COUNT(*) FROM contact_methods
|
||||
UNION ALL
|
||||
SELECT 'settings', COUNT(*) FROM settings
|
||||
UNION ALL
|
||||
SELECT 'logs', COUNT(*) FROM logs
|
||||
UNION ALL
|
||||
SELECT 'secrets', COUNT(*) FROM secrets
|
||||
`);
|
||||
|
||||
const countMap = new Map(counts.map(c => [c.table, c.count]));
|
||||
|
||||
if (countMap.get('accounts') !== data.accounts.length) {
|
||||
return { success: false, error: 'Account count mismatch' };
|
||||
}
|
||||
if (countMap.get('contacts') !== data.contacts.length) {
|
||||
return { success: false, error: 'Contact count mismatch' };
|
||||
}
|
||||
if (countMap.get('contact_methods') !== data.contactMethods.length) {
|
||||
return { success: false, error: 'Contact method count mismatch' };
|
||||
}
|
||||
if (countMap.get('settings') !== data.settings.length) {
|
||||
return { success: false, error: 'Settings count mismatch' };
|
||||
}
|
||||
if (countMap.get('logs') !== data.logs.length) {
|
||||
return { success: false, error: 'Log count mismatch' };
|
||||
}
|
||||
if (countMap.get('secrets') !== data.secrets.length) {
|
||||
return { success: false, error: 'Secret count mismatch' };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown verification error'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rollback migration
|
||||
*/
|
||||
async function rollbackMigration(backup: Uint8Array): Promise<void> {
|
||||
const { db } = await initDatabase();
|
||||
|
||||
try {
|
||||
// Close current connection
|
||||
await db.close();
|
||||
|
||||
// Restore from backup
|
||||
const sqlite3 = await import('@wa-sqlite/sql.js');
|
||||
await sqlite3.open(backup);
|
||||
|
||||
logger.info('[SQLite] Migration rollback successful');
|
||||
} catch (error) {
|
||||
logger.error('[SQLite] Migration rollback failed:', error);
|
||||
throw new Error('Failed to rollback migration');
|
||||
}
|
||||
}
|
||||
449
src/db/sqlite/operations.ts
Normal file
@@ -0,0 +1,449 @@
|
||||
/**
|
||||
* SQLite Database Operations
|
||||
*
|
||||
* This module provides utility functions for common database operations,
|
||||
* including CRUD operations, queries, and transactions.
|
||||
*/
|
||||
|
||||
import { Database } from '@wa-sqlite/sql.js';
|
||||
import { initDatabase } from './init';
|
||||
import {
|
||||
SQLiteAccount,
|
||||
SQLiteContact,
|
||||
SQLiteContactMethod,
|
||||
SQLiteSettings,
|
||||
SQLiteLog,
|
||||
SQLiteSecret,
|
||||
isSQLiteAccount,
|
||||
isSQLiteContact,
|
||||
isSQLiteSettings
|
||||
} from './types';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
// ============================================================================
|
||||
// Transaction Helpers
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Execute a function within a transaction
|
||||
*/
|
||||
export async function withTransaction<T>(
|
||||
operation: (db: Database) => Promise<T>
|
||||
): Promise<T> {
|
||||
const { db } = await initDatabase();
|
||||
|
||||
try {
|
||||
return await db.transaction(operation);
|
||||
} catch (error) {
|
||||
logger.error('[SQLite] Transaction failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a function with retries
|
||||
*/
|
||||
export async function withRetry<T>(
|
||||
operation: () => Promise<T>,
|
||||
maxRetries = 3,
|
||||
delay = 1000
|
||||
): Promise<T> {
|
||||
let lastError: Error | undefined;
|
||||
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error(String(error));
|
||||
|
||||
if (i < maxRetries - 1) {
|
||||
await new Promise(resolve => setTimeout(resolve, delay * (i + 1)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Account Operations
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get account by DID
|
||||
*/
|
||||
export async function getAccountByDid(did: string): Promise<SQLiteAccount | null> {
|
||||
const { db } = await initDatabase();
|
||||
|
||||
const accounts = await db.selectAll<SQLiteAccount>(
|
||||
'SELECT * FROM accounts WHERE did = ?',
|
||||
[did]
|
||||
);
|
||||
|
||||
return accounts[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all accounts
|
||||
*/
|
||||
export async function getAllAccounts(): Promise<SQLiteAccount[]> {
|
||||
const { db } = await initDatabase();
|
||||
|
||||
return db.selectAll<SQLiteAccount>(
|
||||
'SELECT * FROM accounts ORDER BY created_at DESC'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update account
|
||||
*/
|
||||
export async function upsertAccount(account: SQLiteAccount): Promise<void> {
|
||||
if (!isSQLiteAccount(account)) {
|
||||
throw new Error('Invalid account data');
|
||||
}
|
||||
|
||||
await withTransaction(async (db) => {
|
||||
const existing = await db.selectOne<{ did: string }>(
|
||||
'SELECT did FROM accounts WHERE did = ?',
|
||||
[account.did]
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
await db.exec(`
|
||||
UPDATE accounts SET
|
||||
public_key_hex = ?,
|
||||
updated_at = ?,
|
||||
identity_json = ?,
|
||||
mnemonic_encrypted = ?,
|
||||
passkey_cred_id_hex = ?,
|
||||
derivation_path = ?
|
||||
WHERE did = ?
|
||||
`, [
|
||||
account.public_key_hex,
|
||||
Date.now(),
|
||||
account.identity_json || null,
|
||||
account.mnemonic_encrypted || null,
|
||||
account.passkey_cred_id_hex || null,
|
||||
account.derivation_path || null,
|
||||
account.did
|
||||
]);
|
||||
} else {
|
||||
await db.exec(`
|
||||
INSERT INTO accounts (
|
||||
did, public_key_hex, created_at, updated_at,
|
||||
identity_json, mnemonic_encrypted, passkey_cred_id_hex, derivation_path
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, [
|
||||
account.did,
|
||||
account.public_key_hex,
|
||||
account.created_at,
|
||||
account.updated_at,
|
||||
account.identity_json || null,
|
||||
account.mnemonic_encrypted || null,
|
||||
account.passkey_cred_id_hex || null,
|
||||
account.derivation_path || null
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Contact Operations
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get contact by ID
|
||||
*/
|
||||
export async function getContactById(id: string): Promise<SQLiteContact | null> {
|
||||
const { db } = await initDatabase();
|
||||
|
||||
const contacts = await db.selectAll<SQLiteContact>(
|
||||
'SELECT * FROM contacts WHERE id = ?',
|
||||
[id]
|
||||
);
|
||||
|
||||
return contacts[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get contacts by account DID
|
||||
*/
|
||||
export async function getContactsByAccountDid(did: string): Promise<SQLiteContact[]> {
|
||||
const { db } = await initDatabase();
|
||||
|
||||
return db.selectAll<SQLiteContact>(
|
||||
'SELECT * FROM contacts WHERE did = ? ORDER BY created_at DESC',
|
||||
[did]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get contact methods for a contact
|
||||
*/
|
||||
export async function getContactMethods(contactId: string): Promise<SQLiteContactMethod[]> {
|
||||
const { db } = await initDatabase();
|
||||
|
||||
return db.selectAll<SQLiteContactMethod>(
|
||||
'SELECT * FROM contact_methods WHERE contact_id = ? ORDER BY created_at DESC',
|
||||
[contactId]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update contact with methods
|
||||
*/
|
||||
export async function upsertContact(
|
||||
contact: SQLiteContact,
|
||||
methods: SQLiteContactMethod[] = []
|
||||
): Promise<void> {
|
||||
if (!isSQLiteContact(contact)) {
|
||||
throw new Error('Invalid contact data');
|
||||
}
|
||||
|
||||
await withTransaction(async (db) => {
|
||||
const existing = await db.selectOne<{ id: string }>(
|
||||
'SELECT id FROM contacts WHERE id = ?',
|
||||
[contact.id]
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
await db.exec(`
|
||||
UPDATE contacts SET
|
||||
did = ?,
|
||||
name = ?,
|
||||
notes = ?,
|
||||
profile_image_url = ?,
|
||||
public_key_base64 = ?,
|
||||
next_pub_key_hash_b64 = ?,
|
||||
sees_me = ?,
|
||||
registered = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
`, [
|
||||
contact.did,
|
||||
contact.name || null,
|
||||
contact.notes || null,
|
||||
contact.profile_image_url || null,
|
||||
contact.public_key_base64 || null,
|
||||
contact.next_pub_key_hash_b64 || null,
|
||||
contact.sees_me ? 1 : 0,
|
||||
contact.registered ? 1 : 0,
|
||||
Date.now(),
|
||||
contact.id
|
||||
]);
|
||||
} else {
|
||||
await db.exec(`
|
||||
INSERT INTO contacts (
|
||||
id, did, name, notes, profile_image_url,
|
||||
public_key_base64, next_pub_key_hash_b64,
|
||||
sees_me, registered, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, [
|
||||
contact.id,
|
||||
contact.did,
|
||||
contact.name || null,
|
||||
contact.notes || null,
|
||||
contact.profile_image_url || null,
|
||||
contact.public_key_base64 || null,
|
||||
contact.next_pub_key_hash_b64 || null,
|
||||
contact.sees_me ? 1 : 0,
|
||||
contact.registered ? 1 : 0,
|
||||
contact.created_at,
|
||||
contact.updated_at
|
||||
]);
|
||||
}
|
||||
|
||||
// Update contact methods
|
||||
if (methods.length > 0) {
|
||||
// Delete existing methods
|
||||
await db.exec(
|
||||
'DELETE FROM contact_methods WHERE contact_id = ?',
|
||||
[contact.id]
|
||||
);
|
||||
|
||||
// Insert new methods
|
||||
for (const method of methods) {
|
||||
await db.exec(`
|
||||
INSERT INTO contact_methods (
|
||||
id, contact_id, label, type, value,
|
||||
created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`, [
|
||||
method.id,
|
||||
contact.id,
|
||||
method.label,
|
||||
method.type,
|
||||
method.value,
|
||||
method.created_at,
|
||||
method.updated_at
|
||||
]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Settings Operations
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get setting by key
|
||||
*/
|
||||
export async function getSetting(key: string): Promise<SQLiteSettings | null> {
|
||||
const { db } = await initDatabase();
|
||||
|
||||
const settings = await db.selectAll<SQLiteSettings>(
|
||||
'SELECT * FROM settings WHERE key = ?',
|
||||
[key]
|
||||
);
|
||||
|
||||
return settings[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get settings by account DID
|
||||
*/
|
||||
export async function getSettingsByAccountDid(did: string): Promise<SQLiteSettings[]> {
|
||||
const { db } = await initDatabase();
|
||||
|
||||
return db.selectAll<SQLiteSettings>(
|
||||
'SELECT * FROM settings WHERE account_did = ? ORDER BY updated_at DESC',
|
||||
[did]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set setting value
|
||||
*/
|
||||
export async function setSetting(setting: SQLiteSettings): Promise<void> {
|
||||
if (!isSQLiteSettings(setting)) {
|
||||
throw new Error('Invalid settings data');
|
||||
}
|
||||
|
||||
await withTransaction(async (db) => {
|
||||
const existing = await db.selectOne<{ key: string }>(
|
||||
'SELECT key FROM settings WHERE key = ?',
|
||||
[setting.key]
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
await db.exec(`
|
||||
UPDATE settings SET
|
||||
account_did = ?,
|
||||
value_json = ?,
|
||||
updated_at = ?
|
||||
WHERE key = ?
|
||||
`, [
|
||||
setting.account_did || null,
|
||||
setting.value_json,
|
||||
Date.now(),
|
||||
setting.key
|
||||
]);
|
||||
} else {
|
||||
await db.exec(`
|
||||
INSERT INTO settings (
|
||||
key, account_did, value_json, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?)
|
||||
`, [
|
||||
setting.key,
|
||||
setting.account_did || null,
|
||||
setting.value_json,
|
||||
setting.created_at,
|
||||
setting.updated_at
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Log Operations
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Add log entry
|
||||
*/
|
||||
export async function addLog(log: SQLiteLog): Promise<void> {
|
||||
await withTransaction(async (db) => {
|
||||
await db.exec(`
|
||||
INSERT INTO logs (
|
||||
id, level, message, metadata_json, created_at
|
||||
) VALUES (?, ?, ?, ?, ?)
|
||||
`, [
|
||||
log.id,
|
||||
log.level,
|
||||
log.message,
|
||||
log.metadata_json || null,
|
||||
log.created_at
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get logs by level
|
||||
*/
|
||||
export async function getLogsByLevel(
|
||||
level: string,
|
||||
limit = 100,
|
||||
offset = 0
|
||||
): Promise<SQLiteLog[]> {
|
||||
const { db } = await initDatabase();
|
||||
|
||||
return db.selectAll<SQLiteLog>(
|
||||
'SELECT * FROM logs WHERE level = ? ORDER BY created_at DESC LIMIT ? OFFSET ?',
|
||||
[level, limit, offset]
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Secret Operations
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get secret by key
|
||||
*/
|
||||
export async function getSecret(key: string): Promise<SQLiteSecret | null> {
|
||||
const { db } = await initDatabase();
|
||||
|
||||
const secrets = await db.selectAll<SQLiteSecret>(
|
||||
'SELECT * FROM secrets WHERE key = ?',
|
||||
[key]
|
||||
);
|
||||
|
||||
return secrets[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set secret value
|
||||
*/
|
||||
export async function setSecret(secret: SQLiteSecret): Promise<void> {
|
||||
await withTransaction(async (db) => {
|
||||
const existing = await db.selectOne<{ key: string }>(
|
||||
'SELECT key FROM secrets WHERE key = ?',
|
||||
[secret.key]
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
await db.exec(`
|
||||
UPDATE secrets SET
|
||||
value_encrypted = ?,
|
||||
updated_at = ?
|
||||
WHERE key = ?
|
||||
`, [
|
||||
secret.value_encrypted,
|
||||
Date.now(),
|
||||
secret.key
|
||||
]);
|
||||
} else {
|
||||
await db.exec(`
|
||||
INSERT INTO secrets (
|
||||
key, value_encrypted, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?)
|
||||
`, [
|
||||
secret.key,
|
||||
secret.value_encrypted,
|
||||
secret.created_at,
|
||||
secret.updated_at
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
349
src/db/sqlite/types.ts
Normal file
@@ -0,0 +1,349 @@
|
||||
/**
|
||||
* SQLite Type Definitions
|
||||
*
|
||||
* This file defines the type system for the SQLite implementation,
|
||||
* mapping from the existing Dexie types to SQLite-compatible types.
|
||||
* It includes both the database schema types and the runtime types.
|
||||
*/
|
||||
|
||||
import { SQLiteCompatibleType } from '@jlongster/sql.js';
|
||||
|
||||
// ============================================================================
|
||||
// Base Types and Utilities
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* SQLite column type mapping
|
||||
*/
|
||||
export type SQLiteColumnType =
|
||||
| 'INTEGER' // For numbers, booleans, dates
|
||||
| 'TEXT' // For strings, JSON
|
||||
| 'BLOB' // For binary data
|
||||
| 'REAL' // For floating point numbers
|
||||
| 'NULL'; // For null values
|
||||
|
||||
/**
|
||||
* SQLite column definition
|
||||
*/
|
||||
export interface SQLiteColumn {
|
||||
name: string;
|
||||
type: SQLiteColumnType;
|
||||
nullable?: boolean;
|
||||
primaryKey?: boolean;
|
||||
unique?: boolean;
|
||||
references?: {
|
||||
table: string;
|
||||
column: string;
|
||||
};
|
||||
default?: SQLiteCompatibleType;
|
||||
}
|
||||
|
||||
/**
|
||||
* SQLite table definition
|
||||
*/
|
||||
export interface SQLiteTable {
|
||||
name: string;
|
||||
columns: SQLiteColumn[];
|
||||
indexes?: Array<{
|
||||
name: string;
|
||||
columns: string[];
|
||||
unique?: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Account Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* SQLite-compatible Account type
|
||||
* Maps from the Dexie Account type
|
||||
*/
|
||||
export interface SQLiteAccount {
|
||||
did: string; // TEXT PRIMARY KEY
|
||||
public_key_hex: string; // TEXT NOT NULL
|
||||
created_at: number; // INTEGER NOT NULL
|
||||
updated_at: number; // INTEGER NOT NULL
|
||||
identity_json?: string; // TEXT (encrypted JSON)
|
||||
mnemonic_encrypted?: string; // TEXT (encrypted)
|
||||
passkey_cred_id_hex?: string; // TEXT
|
||||
derivation_path?: string; // TEXT
|
||||
}
|
||||
|
||||
export const ACCOUNTS_TABLE: SQLiteTable = {
|
||||
name: 'accounts',
|
||||
columns: [
|
||||
{ name: 'did', type: 'TEXT', primaryKey: true },
|
||||
{ name: 'public_key_hex', type: 'TEXT', nullable: false },
|
||||
{ name: 'created_at', type: 'INTEGER', nullable: false },
|
||||
{ name: 'updated_at', type: 'INTEGER', nullable: false },
|
||||
{ name: 'identity_json', type: 'TEXT' },
|
||||
{ name: 'mnemonic_encrypted', type: 'TEXT' },
|
||||
{ name: 'passkey_cred_id_hex', type: 'TEXT' },
|
||||
{ name: 'derivation_path', type: 'TEXT' }
|
||||
],
|
||||
indexes: [
|
||||
{ name: 'idx_accounts_created_at', columns: ['created_at'] },
|
||||
{ name: 'idx_accounts_updated_at', columns: ['updated_at'] }
|
||||
]
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Contact Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* SQLite-compatible ContactMethod type
|
||||
*/
|
||||
export interface SQLiteContactMethod {
|
||||
id: string; // TEXT PRIMARY KEY
|
||||
contact_id: string; // TEXT NOT NULL
|
||||
label: string; // TEXT NOT NULL
|
||||
type: string; // TEXT NOT NULL
|
||||
value: string; // TEXT NOT NULL
|
||||
created_at: number; // INTEGER NOT NULL
|
||||
updated_at: number; // INTEGER NOT NULL
|
||||
}
|
||||
|
||||
/**
|
||||
* SQLite-compatible Contact type
|
||||
*/
|
||||
export interface SQLiteContact {
|
||||
id: string; // TEXT PRIMARY KEY
|
||||
did: string; // TEXT NOT NULL
|
||||
name?: string; // TEXT
|
||||
notes?: string; // TEXT
|
||||
profile_image_url?: string; // TEXT
|
||||
public_key_base64?: string; // TEXT
|
||||
next_pub_key_hash_b64?: string; // TEXT
|
||||
sees_me?: boolean; // INTEGER (0 or 1)
|
||||
registered?: boolean; // INTEGER (0 or 1)
|
||||
created_at: number; // INTEGER NOT NULL
|
||||
updated_at: number; // INTEGER NOT NULL
|
||||
}
|
||||
|
||||
export const CONTACTS_TABLE: SQLiteTable = {
|
||||
name: 'contacts',
|
||||
columns: [
|
||||
{ name: 'id', type: 'TEXT', primaryKey: true },
|
||||
{ name: 'did', type: 'TEXT', nullable: false },
|
||||
{ name: 'name', type: 'TEXT' },
|
||||
{ name: 'notes', type: 'TEXT' },
|
||||
{ name: 'profile_image_url', type: 'TEXT' },
|
||||
{ name: 'public_key_base64', type: 'TEXT' },
|
||||
{ name: 'next_pub_key_hash_b64', type: 'TEXT' },
|
||||
{ name: 'sees_me', type: 'INTEGER' },
|
||||
{ name: 'registered', type: 'INTEGER' },
|
||||
{ name: 'created_at', type: 'INTEGER', nullable: false },
|
||||
{ name: 'updated_at', type: 'INTEGER', nullable: false }
|
||||
],
|
||||
indexes: [
|
||||
{ name: 'idx_contacts_did', columns: ['did'] },
|
||||
{ name: 'idx_contacts_created_at', columns: ['created_at'] }
|
||||
]
|
||||
};
|
||||
|
||||
export const CONTACT_METHODS_TABLE: SQLiteTable = {
|
||||
name: 'contact_methods',
|
||||
columns: [
|
||||
{ name: 'id', type: 'TEXT', primaryKey: true },
|
||||
{ name: 'contact_id', type: 'TEXT', nullable: false,
|
||||
references: { table: 'contacts', column: 'id' } },
|
||||
{ name: 'label', type: 'TEXT', nullable: false },
|
||||
{ name: 'type', type: 'TEXT', nullable: false },
|
||||
{ name: 'value', type: 'TEXT', nullable: false },
|
||||
{ name: 'created_at', type: 'INTEGER', nullable: false },
|
||||
{ name: 'updated_at', type: 'INTEGER', nullable: false }
|
||||
],
|
||||
indexes: [
|
||||
{ name: 'idx_contact_methods_contact_id', columns: ['contact_id'] }
|
||||
]
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Settings Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* SQLite-compatible Settings type
|
||||
*/
|
||||
export interface SQLiteSettings {
|
||||
key: string; // TEXT PRIMARY KEY
|
||||
account_did?: string; // TEXT
|
||||
value_json: string; // TEXT NOT NULL (JSON stringified)
|
||||
created_at: number; // INTEGER NOT NULL
|
||||
updated_at: number; // INTEGER NOT NULL
|
||||
}
|
||||
|
||||
export const SETTINGS_TABLE: SQLiteTable = {
|
||||
name: 'settings',
|
||||
columns: [
|
||||
{ name: 'key', type: 'TEXT', primaryKey: true },
|
||||
{ name: 'account_did', type: 'TEXT' },
|
||||
{ name: 'value_json', type: 'TEXT', nullable: false },
|
||||
{ name: 'created_at', type: 'INTEGER', nullable: false },
|
||||
{ name: 'updated_at', type: 'INTEGER', nullable: false }
|
||||
],
|
||||
indexes: [
|
||||
{ name: 'idx_settings_account_did', columns: ['account_did'] },
|
||||
{ name: 'idx_settings_updated_at', columns: ['updated_at'] }
|
||||
]
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Log Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* SQLite-compatible Log type
|
||||
*/
|
||||
export interface SQLiteLog {
|
||||
id: string; // TEXT PRIMARY KEY
|
||||
level: string; // TEXT NOT NULL
|
||||
message: string; // TEXT NOT NULL
|
||||
metadata_json?: string; // TEXT (JSON stringified)
|
||||
created_at: number; // INTEGER NOT NULL
|
||||
}
|
||||
|
||||
export const LOGS_TABLE: SQLiteTable = {
|
||||
name: 'logs',
|
||||
columns: [
|
||||
{ name: 'id', type: 'TEXT', primaryKey: true },
|
||||
{ name: 'level', type: 'TEXT', nullable: false },
|
||||
{ name: 'message', type: 'TEXT', nullable: false },
|
||||
{ name: 'metadata_json', type: 'TEXT' },
|
||||
{ name: 'created_at', type: 'INTEGER', nullable: false }
|
||||
],
|
||||
indexes: [
|
||||
{ name: 'idx_logs_level', columns: ['level'] },
|
||||
{ name: 'idx_logs_created_at', columns: ['created_at'] }
|
||||
]
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Secret Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* SQLite-compatible Secret type
|
||||
* Note: This table should be encrypted at the database level
|
||||
*/
|
||||
export interface SQLiteSecret {
|
||||
key: string; // TEXT PRIMARY KEY
|
||||
value_encrypted: string; // TEXT NOT NULL (encrypted)
|
||||
created_at: number; // INTEGER NOT NULL
|
||||
updated_at: number; // INTEGER NOT NULL
|
||||
}
|
||||
|
||||
export const SECRETS_TABLE: SQLiteTable = {
|
||||
name: 'secrets',
|
||||
columns: [
|
||||
{ name: 'key', type: 'TEXT', primaryKey: true },
|
||||
{ name: 'value_encrypted', type: 'TEXT', nullable: false },
|
||||
{ name: 'created_at', type: 'INTEGER', nullable: false },
|
||||
{ name: 'updated_at', type: 'INTEGER', nullable: false }
|
||||
],
|
||||
indexes: [
|
||||
{ name: 'idx_secrets_updated_at', columns: ['updated_at'] }
|
||||
]
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Database Schema
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Complete database schema definition
|
||||
*/
|
||||
export const DATABASE_SCHEMA: SQLiteTable[] = [
|
||||
ACCOUNTS_TABLE,
|
||||
CONTACTS_TABLE,
|
||||
CONTACT_METHODS_TABLE,
|
||||
SETTINGS_TABLE,
|
||||
LOGS_TABLE,
|
||||
SECRETS_TABLE
|
||||
];
|
||||
|
||||
// ============================================================================
|
||||
// Type Guards and Validators
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Type guard for SQLiteAccount
|
||||
*/
|
||||
export function isSQLiteAccount(value: unknown): value is SQLiteAccount {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
typeof (value as SQLiteAccount).did === 'string' &&
|
||||
typeof (value as SQLiteAccount).public_key_hex === 'string' &&
|
||||
typeof (value as SQLiteAccount).created_at === 'number' &&
|
||||
typeof (value as SQLiteAccount).updated_at === 'number'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for SQLiteContact
|
||||
*/
|
||||
export function isSQLiteContact(value: unknown): value is SQLiteContact {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
typeof (value as SQLiteContact).id === 'string' &&
|
||||
typeof (value as SQLiteContact).did === 'string' &&
|
||||
typeof (value as SQLiteContact).created_at === 'number' &&
|
||||
typeof (value as SQLiteContact).updated_at === 'number'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for SQLiteSettings
|
||||
*/
|
||||
export function isSQLiteSettings(value: unknown): value is SQLiteSettings {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
typeof (value as SQLiteSettings).key === 'string' &&
|
||||
typeof (value as SQLiteSettings).value_json === 'string' &&
|
||||
typeof (value as SQLiteSettings).created_at === 'number' &&
|
||||
typeof (value as SQLiteSettings).updated_at === 'number'
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Migration Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Type for migration data from Dexie to SQLite
|
||||
*/
|
||||
export interface MigrationData {
|
||||
accounts: SQLiteAccount[];
|
||||
contacts: SQLiteContact[];
|
||||
contactMethods: SQLiteContactMethod[];
|
||||
settings: SQLiteSettings[];
|
||||
logs: SQLiteLog[];
|
||||
secrets: SQLiteSecret[];
|
||||
metadata: {
|
||||
version: string;
|
||||
timestamp: number;
|
||||
source: 'dexie';
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Migration result type
|
||||
*/
|
||||
export interface MigrationResult {
|
||||
success: boolean;
|
||||
error?: Error;
|
||||
stats: {
|
||||
accounts: number;
|
||||
contacts: number;
|
||||
contactMethods: number;
|
||||
settings: number;
|
||||
logs: number;
|
||||
secrets: number;
|
||||
};
|
||||
duration: number;
|
||||
}
|
||||
@@ -549,13 +549,11 @@ export const generateSaveAndActivateIdentity = async (): Promise<string> => {
|
||||
mnemonic: mnemonic,
|
||||
publicKeyHex: newId.keys[0].publicKeyHex,
|
||||
});
|
||||
|
||||
|
||||
await updateDefaultSettings({ activeDid: newId.did });
|
||||
} catch (error) {
|
||||
console.error("Failed to update default settings:", error);
|
||||
throw new Error(
|
||||
"Failed to set default settings. Please try again or restart the app.",
|
||||
);
|
||||
throw new Error("Failed to set default settings. Please try again or restart the app.");
|
||||
}
|
||||
await updateAccountSettings(newId.did, { isRegistered: false });
|
||||
return newId.did;
|
||||
|
||||
215
src/main.ts
@@ -1,215 +0,0 @@
|
||||
import { createPinia } from "pinia";
|
||||
import { App as VueApp, ComponentPublicInstance, createApp } from "vue";
|
||||
import App from "./App.vue";
|
||||
import "./registerServiceWorker";
|
||||
import router from "./router";
|
||||
import axios from "axios";
|
||||
import VueAxios from "vue-axios";
|
||||
import Notifications from "notiwind";
|
||||
import "./assets/styles/tailwind.css";
|
||||
|
||||
import { library } from "@fortawesome/fontawesome-svg-core";
|
||||
import {
|
||||
faArrowDown,
|
||||
faArrowLeft,
|
||||
faArrowRight,
|
||||
faArrowRotateBackward,
|
||||
faArrowUpRightFromSquare,
|
||||
faArrowUp,
|
||||
faBan,
|
||||
faBitcoinSign,
|
||||
faBurst,
|
||||
faCalendar,
|
||||
faCamera,
|
||||
faCameraRotate,
|
||||
faCaretDown,
|
||||
faChair,
|
||||
faCheck,
|
||||
faChevronDown,
|
||||
faChevronLeft,
|
||||
faChevronRight,
|
||||
faChevronUp,
|
||||
faCircle,
|
||||
faCircleCheck,
|
||||
faCircleInfo,
|
||||
faCircleQuestion,
|
||||
faCircleUser,
|
||||
faClock,
|
||||
faCoins,
|
||||
faComment,
|
||||
faCopy,
|
||||
faDollar,
|
||||
faEllipsis,
|
||||
faEllipsisVertical,
|
||||
faEnvelopeOpenText,
|
||||
faEraser,
|
||||
faEye,
|
||||
faEyeSlash,
|
||||
faFileContract,
|
||||
faFileLines,
|
||||
faFilter,
|
||||
faFloppyDisk,
|
||||
faFolderOpen,
|
||||
faForward,
|
||||
faGift,
|
||||
faGlobe,
|
||||
faHammer,
|
||||
faHand,
|
||||
faHandHoldingDollar,
|
||||
faHandHoldingHeart,
|
||||
faHouseChimney,
|
||||
faImage,
|
||||
faImagePortrait,
|
||||
faLeftRight,
|
||||
faLightbulb,
|
||||
faLink,
|
||||
faLocationDot,
|
||||
faLongArrowAltLeft,
|
||||
faLongArrowAltRight,
|
||||
faMagnifyingGlass,
|
||||
faMessage,
|
||||
faMinus,
|
||||
faPen,
|
||||
faPersonCircleCheck,
|
||||
faPersonCircleQuestion,
|
||||
faPlus,
|
||||
faQuestion,
|
||||
faQrcode,
|
||||
faRightFromBracket,
|
||||
faRotate,
|
||||
faShareNodes,
|
||||
faSpinner,
|
||||
faSquare,
|
||||
faSquareCaretDown,
|
||||
faSquareCaretUp,
|
||||
faSquarePlus,
|
||||
faTrashCan,
|
||||
faTriangleExclamation,
|
||||
faUser,
|
||||
faUsers,
|
||||
faXmark,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
library.add(
|
||||
faArrowDown,
|
||||
faArrowLeft,
|
||||
faArrowRight,
|
||||
faArrowRotateBackward,
|
||||
faArrowUpRightFromSquare,
|
||||
faArrowUp,
|
||||
faBan,
|
||||
faBitcoinSign,
|
||||
faBurst,
|
||||
faCalendar,
|
||||
faCamera,
|
||||
faCameraRotate,
|
||||
faCaretDown,
|
||||
faChair,
|
||||
faCheck,
|
||||
faChevronDown,
|
||||
faChevronLeft,
|
||||
faChevronRight,
|
||||
faChevronUp,
|
||||
faCircle,
|
||||
faCircleCheck,
|
||||
faCircleInfo,
|
||||
faCircleQuestion,
|
||||
faCircleUser,
|
||||
faClock,
|
||||
faCoins,
|
||||
faComment,
|
||||
faCopy,
|
||||
faDollar,
|
||||
faEllipsis,
|
||||
faEllipsisVertical,
|
||||
faEnvelopeOpenText,
|
||||
faEraser,
|
||||
faEye,
|
||||
faEyeSlash,
|
||||
faFileContract,
|
||||
faFileLines,
|
||||
faFilter,
|
||||
faFloppyDisk,
|
||||
faFolderOpen,
|
||||
faForward,
|
||||
faGift,
|
||||
faGlobe,
|
||||
faHammer,
|
||||
faHand,
|
||||
faHandHoldingDollar,
|
||||
faHandHoldingHeart,
|
||||
faHouseChimney,
|
||||
faImage,
|
||||
faImagePortrait,
|
||||
faLeftRight,
|
||||
faLightbulb,
|
||||
faLink,
|
||||
faLocationDot,
|
||||
faLongArrowAltLeft,
|
||||
faLongArrowAltRight,
|
||||
faMagnifyingGlass,
|
||||
faMessage,
|
||||
faMinus,
|
||||
faPen,
|
||||
faPersonCircleCheck,
|
||||
faPersonCircleQuestion,
|
||||
faPlus,
|
||||
faQrcode,
|
||||
faQuestion,
|
||||
faRotate,
|
||||
faRightFromBracket,
|
||||
faShareNodes,
|
||||
faSpinner,
|
||||
faSquare,
|
||||
faSquareCaretDown,
|
||||
faSquareCaretUp,
|
||||
faSquarePlus,
|
||||
faTrashCan,
|
||||
faTriangleExclamation,
|
||||
faUser,
|
||||
faUsers,
|
||||
faXmark,
|
||||
);
|
||||
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import Camera from "simple-vue-camera";
|
||||
import { logger } from "./utils/logger";
|
||||
|
||||
// Can trigger this with a 'throw' inside some top-level function, eg. on the HomeView
|
||||
function setupGlobalErrorHandler(app: VueApp) {
|
||||
// @ts-expect-error 'cause we cannot see why config is not defined
|
||||
app.config.errorHandler = (
|
||||
err: Error,
|
||||
instance: ComponentPublicInstance | null,
|
||||
info: string,
|
||||
) => {
|
||||
logger.error(
|
||||
"Ouch! Global Error Handler.",
|
||||
"Error:",
|
||||
err,
|
||||
"- Error toString:",
|
||||
err.toString(),
|
||||
"- Info:",
|
||||
info,
|
||||
"- Instance:",
|
||||
instance,
|
||||
);
|
||||
// Want to show a nice notiwind notification but can't figure out how.
|
||||
alert(
|
||||
(err.message || "Something bad happened") +
|
||||
" - Try reloading or restarting the app.",
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
const app = createApp(App)
|
||||
.component("fa", FontAwesomeIcon)
|
||||
.component("camera", Camera)
|
||||
.use(createPinia())
|
||||
.use(VueAxios, axios)
|
||||
.use(router)
|
||||
.use(Notifications);
|
||||
|
||||
setupGlobalErrorHandler(app);
|
||||
|
||||
app.mount("#app");
|
||||
@@ -26,8 +26,6 @@ export interface PlatformCapabilities {
|
||||
hasFileDownload: boolean;
|
||||
/** Whether the platform requires special file handling instructions */
|
||||
needsFileHandlingInstructions: boolean;
|
||||
/** Whether the platform is a native app (Capacitor, Electron, etc.) */
|
||||
isNativeApp: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -94,12 +92,6 @@ export interface PlatformService {
|
||||
*/
|
||||
pickImage(): Promise<ImageResult>;
|
||||
|
||||
/**
|
||||
* Rotates the camera between front and back cameras.
|
||||
* @returns Promise that resolves when the camera is rotated
|
||||
*/
|
||||
rotateCamera(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Handles deep link URLs for the application.
|
||||
* @param url - The deep link URL to handle
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
PlatformCapabilities,
|
||||
} from "../PlatformService";
|
||||
import { Filesystem, Directory, Encoding } from "@capacitor/filesystem";
|
||||
import { Camera, CameraResultType, CameraSource, CameraDirection } from "@capacitor/camera";
|
||||
import { Camera, CameraResultType, CameraSource } from "@capacitor/camera";
|
||||
import { Share } from "@capacitor/share";
|
||||
import { logger } from "../../utils/logger";
|
||||
|
||||
@@ -16,9 +16,6 @@ import { logger } from "../../utils/logger";
|
||||
* - Platform-specific features
|
||||
*/
|
||||
export class CapacitorPlatformService implements PlatformService {
|
||||
/** Current camera direction */
|
||||
private currentDirection: CameraDirection = 'BACK';
|
||||
|
||||
/**
|
||||
* Gets the capabilities of the Capacitor platform
|
||||
* @returns Platform capabilities object
|
||||
@@ -31,7 +28,6 @@ export class CapacitorPlatformService implements PlatformService {
|
||||
isIOS: /iPad|iPhone|iPod/.test(navigator.userAgent),
|
||||
hasFileDownload: false,
|
||||
needsFileHandlingInstructions: true,
|
||||
isNativeApp: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -405,7 +401,6 @@ export class CapacitorPlatformService implements PlatformService {
|
||||
allowEditing: true,
|
||||
resultType: CameraResultType.Base64,
|
||||
source: CameraSource.Camera,
|
||||
direction: this.currentDirection,
|
||||
});
|
||||
|
||||
const blob = await this.processImageData(image.base64String);
|
||||
@@ -471,15 +466,6 @@ export class CapacitorPlatformService implements PlatformService {
|
||||
return new Blob(byteArrays, { type: "image/jpeg" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotates the camera between front and back cameras.
|
||||
* @returns Promise that resolves when the camera is rotated
|
||||
*/
|
||||
async rotateCamera(): Promise<void> {
|
||||
this.currentDirection = this.currentDirection === 'BACK' ? 'FRONT' : 'BACK';
|
||||
logger.debug(`Camera rotated to ${this.currentDirection} camera`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles deep link URLs for the application.
|
||||
* Note: Capacitor handles deep links automatically.
|
||||
|
||||
@@ -115,7 +115,6 @@
|
||||
<ImageMethodDialog
|
||||
ref="imageMethodDialog"
|
||||
:is-registered="isRegistered"
|
||||
default-camera-mode="user"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
|
||||
@@ -357,8 +357,7 @@ export default class ContactQRScan extends Vue {
|
||||
}
|
||||
|
||||
const contactInfo = decodedJwt.payload.own;
|
||||
const did = contactInfo.did || decodedJwt.payload.iss;
|
||||
if (!did) {
|
||||
if (!contactInfo.did) {
|
||||
logger.warn("Invalid contact info - missing DID");
|
||||
this.$notify({
|
||||
group: "alert",
|
||||
@@ -371,7 +370,7 @@ export default class ContactQRScan extends Vue {
|
||||
|
||||
// Create contact object
|
||||
const contact = {
|
||||
did: did,
|
||||
did: contactInfo.did,
|
||||
name: contactInfo.name || "",
|
||||
email: contactInfo.email || "",
|
||||
phone: contactInfo.phone || "",
|
||||
|
||||
@@ -152,6 +152,30 @@
|
||||
@camera-on="onCameraOn"
|
||||
@camera-off="onCameraOff"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="absolute bottom-4 inset-x-0 flex justify-center items-center"
|
||||
>
|
||||
<!-- Camera Stop Button -->
|
||||
<button
|
||||
class="text-center text-slate-600 leading-none bg-white p-2 rounded-full drop-shadow-lg"
|
||||
title="Stop camera"
|
||||
@click="stopScanning"
|
||||
>
|
||||
<font-awesome icon="xmark" class="size-6" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex items-center justify-center aspect-square overflow-hidden bg-slate-800 w-[90vw] max-w-[calc((100vh-env(safe-area-inset-top)-env(safe-area-inset-bottom))*0.4)] mx-auto"
|
||||
>
|
||||
<button
|
||||
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white text-lg px-3 py-2 rounded-lg"
|
||||
@click="startScanning"
|
||||
>
|
||||
Scan QR Code
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -234,7 +258,6 @@ export default class ContactQRScanShow extends Vue {
|
||||
|
||||
// Add property to track if we're on desktop
|
||||
private isDesktop = false;
|
||||
private isFrontCamera = false;
|
||||
|
||||
async created() {
|
||||
try {
|
||||
@@ -483,8 +506,7 @@ export default class ContactQRScanShow extends Vue {
|
||||
}
|
||||
|
||||
const contactInfo = decodedJwt.payload.own;
|
||||
const did = contactInfo.did || decodedJwt.payload.iss;
|
||||
if (!did) {
|
||||
if (!contactInfo.did) {
|
||||
logger.warn("Invalid contact info - missing DID");
|
||||
this.$notify({
|
||||
group: "alert",
|
||||
@@ -497,7 +519,7 @@ export default class ContactQRScanShow extends Vue {
|
||||
|
||||
// Create contact object
|
||||
const contact = {
|
||||
did: did,
|
||||
did: contactInfo.did,
|
||||
name: contactInfo.name || "",
|
||||
email: contactInfo.email || "",
|
||||
phone: contactInfo.phone || "",
|
||||
@@ -701,6 +723,14 @@ export default class ContactQRScanShow extends Vue {
|
||||
document.addEventListener("resume", this.handleAppResume);
|
||||
// Start scanning automatically when view is loaded
|
||||
this.startScanning();
|
||||
|
||||
// Apply mirroring after a short delay to ensure video element is ready
|
||||
setTimeout(() => {
|
||||
const videoElement = document.querySelector('.qr-scanner video') as HTMLVideoElement;
|
||||
if (videoElement) {
|
||||
videoElement.style.transform = 'scaleX(-1)';
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
beforeDestroy() {
|
||||
@@ -847,8 +877,6 @@ export default class ContactQRScanShow extends Vue {
|
||||
onCameraOn(): void {
|
||||
this.cameraState = "active";
|
||||
this.isInitializing = false;
|
||||
this.isFrontCamera = this.preferredCamera === "user";
|
||||
this.applyCameraMirroring();
|
||||
}
|
||||
|
||||
onCameraOff(): void {
|
||||
@@ -894,8 +922,6 @@ export default class ContactQRScanShow extends Vue {
|
||||
toggleCamera(): void {
|
||||
this.preferredCamera =
|
||||
this.preferredCamera === "user" ? "environment" : "user";
|
||||
this.isFrontCamera = this.preferredCamera === "user";
|
||||
this.applyCameraMirroring();
|
||||
}
|
||||
|
||||
private handleError(error: unknown): void {
|
||||
@@ -917,21 +943,17 @@ export default class ContactQRScanShow extends Vue {
|
||||
// Add method to detect desktop browser
|
||||
private detectDesktopBrowser(): boolean {
|
||||
const userAgent = navigator.userAgent.toLowerCase();
|
||||
return !/android|webos|iphone|ipad|ipod|blackberry|iemobile|opera mini/i.test(
|
||||
userAgent,
|
||||
);
|
||||
return !/android|webos|iphone|ipad|ipod|blackberry|iemobile|opera mini/i.test(userAgent);
|
||||
}
|
||||
|
||||
// Add method to apply camera mirroring
|
||||
private applyCameraMirroring(): void {
|
||||
const videoElement = document.querySelector(
|
||||
".qr-scanner video",
|
||||
) as HTMLVideoElement;
|
||||
if (videoElement) {
|
||||
// Mirror if it's desktop or front camera on mobile
|
||||
const shouldMirror = this.isDesktop || (this.isFrontCamera && !this.isDesktop);
|
||||
videoElement.style.transform = shouldMirror ? "scaleX(-1)" : "none";
|
||||
// Update the computed property for camera mirroring
|
||||
get shouldMirrorCamera(): boolean {
|
||||
// On desktop, always mirror the webcam
|
||||
if (this.isDesktop) {
|
||||
return true;
|
||||
}
|
||||
// On mobile, mirror only for front-facing camera
|
||||
return this.preferredCamera === "user";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -946,9 +968,8 @@ export default class ContactQRScanShow extends Vue {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Remove the default mirroring from CSS since we're handling it in JavaScript */
|
||||
:deep(.qr-scanner video) {
|
||||
transform: none;
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
/* Ensure the canvas for QR detection is not mirrored */
|
||||
|
||||
@@ -54,12 +54,17 @@
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
class="flex items-center bg-gradient-to-b from-green-400 to-green-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-1 mr-1 rounded-md"
|
||||
class="flex items-center bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-1 mr-1 rounded-md"
|
||||
>
|
||||
<font-awesome
|
||||
icon="chair"
|
||||
class="fa-fw text-2xl"
|
||||
@click="this.$router.push({ name: 'onboard-meeting-list' })"
|
||||
@click="
|
||||
warning(
|
||||
'You must get registered before you can initiate an onboarding meeting.',
|
||||
'Not Registered',
|
||||
)
|
||||
"
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -93,7 +93,7 @@
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<ImageMethodDialog ref="imageDialog" default-camera-mode="environment" />
|
||||
<ImageMethodDialog ref="imageDialog" />
|
||||
|
||||
<div class="mt-4 flex justify-between gap-2">
|
||||
<!-- First Column for Giver -->
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<ImageMethodDialog ref="imageDialog" default-camera-mode="environment" />
|
||||
<ImageMethodDialog ref="imageDialog" />
|
||||
|
||||
<input
|
||||
v-model="agentDid"
|
||||
|
||||
@@ -34,13 +34,9 @@
|
||||
</div>
|
||||
<div v-else-if="hitError">
|
||||
<span class="text-xl">Error Creating Identity</span>
|
||||
<font-awesome
|
||||
icon="exclamation-triangle"
|
||||
class="fa-fw text-red-500 ml-2"
|
||||
></font-awesome>
|
||||
<font-awesome icon="exclamation-triangle" class="fa-fw text-red-500 ml-2"></font-awesome>
|
||||
<p class="text-sm text-gray-500">
|
||||
Try fully restarting the app. If that doesn't work, back up all data
|
||||
(identities and other data) and reinstall the app.
|
||||
Try fully restarting the app. If that doesn't work, back up all data (identities and other data) and reinstall the app.
|
||||
</p>
|
||||
</div>
|
||||
<div v-else>
|
||||
@@ -89,7 +85,7 @@ export default class NewIdentifierView extends Vue {
|
||||
.catch((error) => {
|
||||
this.loading = false;
|
||||
this.hitError = true;
|
||||
console.error("Failed to generate identity:", error);
|
||||
console.error('Failed to generate identity:', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,16 +28,6 @@
|
||||
|
||||
<!-- Members List -->
|
||||
<MembersList v-else :password="password" @error="handleError" />
|
||||
|
||||
<!-- Project Link Section -->
|
||||
<div v-if="projectLink" class="mt-8 p-4 border rounded-lg bg-white shadow">
|
||||
<router-link
|
||||
:to="'/project/' + encodeURIComponent(projectLink)"
|
||||
class="text-blue-600 hover:text-blue-800 transition-colors duration-200"
|
||||
>
|
||||
Go To Project Page
|
||||
</router-link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<UserNameDialog
|
||||
@@ -79,7 +69,6 @@ export default class OnboardMeetingMembersView extends Vue {
|
||||
firstName = "";
|
||||
isRegistered = false;
|
||||
isLoading = true;
|
||||
projectLink = "";
|
||||
$route!: RouteLocationNormalizedLoaded;
|
||||
$router!: Router;
|
||||
|
||||
@@ -96,12 +85,10 @@ export default class OnboardMeetingMembersView extends Vue {
|
||||
async created() {
|
||||
if (!this.groupId) {
|
||||
this.errorMessage = "The group info is missing. Go back and try again.";
|
||||
this.isLoading = false;
|
||||
return;
|
||||
}
|
||||
if (!this.password) {
|
||||
this.errorMessage = "The password is missing. Go back and try again.";
|
||||
this.isLoading = false;
|
||||
return;
|
||||
}
|
||||
const settings = await retrieveSettingsForActiveAccount();
|
||||
@@ -142,15 +129,6 @@ export default class OnboardMeetingMembersView extends Vue {
|
||||
// updateMemberInMeeting sets isLoading to false
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch the meeting details to get the project link
|
||||
const meetingResponse = await this.axios.get(
|
||||
`${this.apiServer}/api/partner/groupOnboard/${this.groupId}`,
|
||||
{ headers }
|
||||
);
|
||||
if (meetingResponse.data?.data?.projectLink) {
|
||||
this.projectLink = meetingResponse.data.data.projectLink;
|
||||
}
|
||||
} catch (error) {
|
||||
this.errorMessage =
|
||||
serverMessageForUser(error) ||
|
||||
|
||||
@@ -49,13 +49,12 @@
|
||||
|
||||
<div v-if="currentMeeting.password" class="mt-4">
|
||||
<p class="text-gray-600">
|
||||
Share the password with the members. You can also send them the
|
||||
"shortcut page for members" link below.
|
||||
Share the password with the people you want to onboard.
|
||||
</p>
|
||||
</div>
|
||||
<div v-else class="text-red-600">
|
||||
You must reenter your password. Edit this meeting, or delete it and
|
||||
create a new meeting.
|
||||
Your copy of the password is not saved. Edit the meeting, or delete it
|
||||
and create a new meeting.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -93,7 +92,7 @@
|
||||
v-if="
|
||||
!isLoading &&
|
||||
isInEditOrCreateMode() &&
|
||||
newOrUpdatedMeetingInputs != null /* duplicate check is for typechecks */
|
||||
newOrUpdatedMeeting != null /* duplicate check is for typechecks */
|
||||
"
|
||||
class="mt-8"
|
||||
>
|
||||
@@ -116,7 +115,7 @@
|
||||
>
|
||||
<input
|
||||
id="meetingName"
|
||||
v-model="newOrUpdatedMeetingInputs.name"
|
||||
v-model="newOrUpdatedMeeting.name"
|
||||
type="text"
|
||||
required
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none"
|
||||
@@ -132,7 +131,7 @@
|
||||
>
|
||||
<input
|
||||
id="expirationTime"
|
||||
v-model="newOrUpdatedMeetingInputs.expiresAt"
|
||||
v-model="newOrUpdatedMeeting.expiresAt"
|
||||
type="datetime-local"
|
||||
required
|
||||
:min="minDateTime"
|
||||
@@ -146,7 +145,7 @@
|
||||
>
|
||||
<input
|
||||
id="password"
|
||||
v-model="newOrUpdatedMeetingInputs.password"
|
||||
v-model="newOrUpdatedMeeting.password"
|
||||
type="text"
|
||||
required
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none"
|
||||
@@ -160,7 +159,7 @@
|
||||
>
|
||||
<input
|
||||
id="userName"
|
||||
v-model="newOrUpdatedMeetingInputs.userFullName"
|
||||
v-model="newOrUpdatedMeeting.userFullName"
|
||||
type="text"
|
||||
required
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none"
|
||||
@@ -168,19 +167,6 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="projectLink" class="block text-sm font-medium text-gray-700"
|
||||
>Project Link</label
|
||||
>
|
||||
<input
|
||||
id="projectLink"
|
||||
v-model="newOrUpdatedMeetingInputs.projectLink"
|
||||
type="text"
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none"
|
||||
placeholder="Project ID"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full bg-gradient-to-b from-green-400 to-green-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md hover:from-green-500 hover:to-green-800"
|
||||
@@ -215,25 +201,15 @@
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-2xl">Meeting Members</h2>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center gap-2 cursor-pointer text-blue-600"
|
||||
@click="copyMembersLinkToClipboard"
|
||||
title="Click to copy link for members"
|
||||
<router-link
|
||||
v-if="!!currentMeeting.password"
|
||||
:to="onboardMeetingMembersLink()"
|
||||
class="inline-block text-blue-600"
|
||||
target="_blank"
|
||||
>
|
||||
<span>
|
||||
• Page for Members
|
||||
<font-awesome icon="link" />
|
||||
</span>
|
||||
<router-link
|
||||
v-if="!!currentMeeting.password"
|
||||
:to="onboardMeetingMembersLink()"
|
||||
class="inline-block text-blue-600 ml-4"
|
||||
target="_blank"
|
||||
@click.stop
|
||||
>
|
||||
<font-awesome icon="external-link" />
|
||||
</router-link>
|
||||
</div>
|
||||
• Open shortcut page for members
|
||||
<font-awesome icon="external-link" />
|
||||
</router-link>
|
||||
|
||||
<MembersList
|
||||
:password="currentMeeting.password || ''"
|
||||
@@ -243,21 +219,6 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="currentMeeting?.projectLink"
|
||||
class="mt-8 p-4 border rounded-lg bg-white shadow"
|
||||
>
|
||||
<!-- Project Link Section -->
|
||||
<div>
|
||||
<router-link
|
||||
:to="'/project/' + encodeURIComponent(currentMeeting.projectLink)"
|
||||
class="text-blue-600 hover:text-blue-800 transition-colors duration-200"
|
||||
>
|
||||
Go To Project Page
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="isLoading">
|
||||
<div class="flex justify-center items-center h-full">
|
||||
<font-awesome icon="spinner" class="fa-spin-pulse" />
|
||||
@@ -268,8 +229,6 @@
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { useClipboard } from "@vueuse/core";
|
||||
|
||||
import QuickNav from "../components/QuickNav.vue";
|
||||
import TopMessage from "../components/TopMessage.vue";
|
||||
import MembersList from "../components/MembersList.vue";
|
||||
@@ -281,22 +240,19 @@ import {
|
||||
} from "../libs/endorserServer";
|
||||
import { encryptMessage } from "../libs/crypto";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
interface ServerMeeting {
|
||||
groupId: number; // from the server
|
||||
name: string; // to & from the server
|
||||
expiresAt: string; // to & from the server
|
||||
name: string; // from the server
|
||||
expiresAt: string; // from the server
|
||||
userFullName?: string; // from the user's session
|
||||
password?: string; // from the user's session
|
||||
projectLink?: string; // to & from the server
|
||||
}
|
||||
|
||||
interface MeetingSetupInputs {
|
||||
interface MeetingSetupInfo {
|
||||
name: string;
|
||||
expiresAt: string;
|
||||
userFullName: string;
|
||||
password: string;
|
||||
projectLink: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
@@ -313,7 +269,7 @@ export default class OnboardMeetingView extends Vue {
|
||||
) => void;
|
||||
|
||||
currentMeeting: ServerMeeting | null = null;
|
||||
newOrUpdatedMeetingInputs: MeetingSetupInputs | null = null;
|
||||
newOrUpdatedMeeting: MeetingSetupInfo | null = null;
|
||||
activeDid = "";
|
||||
apiServer = "";
|
||||
isDeleting = false;
|
||||
@@ -339,11 +295,11 @@ export default class OnboardMeetingView extends Vue {
|
||||
}
|
||||
|
||||
isInCreateMode(): boolean {
|
||||
return this.newOrUpdatedMeetingInputs != null && this.currentMeeting == null;
|
||||
return this.newOrUpdatedMeeting != null && this.currentMeeting == null;
|
||||
}
|
||||
|
||||
isInEditOrCreateMode(): boolean {
|
||||
return this.newOrUpdatedMeetingInputs != null;
|
||||
return this.newOrUpdatedMeeting != null;
|
||||
}
|
||||
|
||||
getDefaultExpirationTime(): string {
|
||||
@@ -368,14 +324,13 @@ export default class OnboardMeetingView extends Vue {
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
blankMeeting(): MeetingSetupInputs {
|
||||
blankMeeting(): MeetingSetupInfo {
|
||||
return {
|
||||
// no groupId yet
|
||||
name: "",
|
||||
expiresAt: this.getDefaultExpirationTime(),
|
||||
userFullName: this.fullName,
|
||||
password: (this.currentMeeting?.password as string) || "",
|
||||
projectLink: (this.currentMeeting?.projectLink as string) || "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -387,20 +342,19 @@ export default class OnboardMeetingView extends Vue {
|
||||
{ headers },
|
||||
);
|
||||
|
||||
const queryPassword = this.$route.query["password"] as string;
|
||||
if (response?.data?.data) {
|
||||
this.currentMeeting = {
|
||||
...response.data.data,
|
||||
userFullName: this.fullName,
|
||||
password: this.currentMeeting?.password || queryPassword || "",
|
||||
password: this.currentMeeting?.password || "",
|
||||
};
|
||||
} else {
|
||||
// no meeting found
|
||||
this.newOrUpdatedMeetingInputs = this.blankMeeting();
|
||||
this.newOrUpdatedMeeting = this.blankMeeting();
|
||||
}
|
||||
} catch (error) {
|
||||
// no meeting found
|
||||
this.newOrUpdatedMeetingInputs = this.blankMeeting();
|
||||
this.newOrUpdatedMeeting = this.blankMeeting();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -408,14 +362,14 @@ export default class OnboardMeetingView extends Vue {
|
||||
this.isLoading = true;
|
||||
|
||||
try {
|
||||
if (!this.newOrUpdatedMeetingInputs) {
|
||||
if (!this.newOrUpdatedMeeting) {
|
||||
throw Error(
|
||||
"There was no meeting data to create. We should never get here.",
|
||||
);
|
||||
}
|
||||
|
||||
// Convert local time to UTC for comparison and server submission
|
||||
const localExpiresAt = new Date(this.newOrUpdatedMeetingInputs.expiresAt);
|
||||
const localExpiresAt = new Date(this.newOrUpdatedMeeting.expiresAt);
|
||||
const now = new Date();
|
||||
if (localExpiresAt <= now) {
|
||||
this.$notify(
|
||||
@@ -429,7 +383,7 @@ export default class OnboardMeetingView extends Vue {
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!this.newOrUpdatedMeetingInputs.userFullName) {
|
||||
if (!this.newOrUpdatedMeeting.userFullName) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
@@ -441,7 +395,7 @@ export default class OnboardMeetingView extends Vue {
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!this.newOrUpdatedMeetingInputs.password) {
|
||||
if (!this.newOrUpdatedMeeting.password) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
@@ -454,36 +408,35 @@ export default class OnboardMeetingView extends Vue {
|
||||
return;
|
||||
}
|
||||
|
||||
// create content with user's name & DID encrypted with password
|
||||
// create content with user's name and DID encrypted with password
|
||||
const content = {
|
||||
name: this.newOrUpdatedMeetingInputs.userFullName,
|
||||
name: this.newOrUpdatedMeeting.userFullName,
|
||||
did: this.activeDid,
|
||||
isRegistered: this.isRegistered,
|
||||
};
|
||||
const encryptedContent = await encryptMessage(
|
||||
JSON.stringify(content),
|
||||
this.newOrUpdatedMeetingInputs.password,
|
||||
this.newOrUpdatedMeeting.password,
|
||||
);
|
||||
|
||||
const headers = await getHeaders(this.activeDid);
|
||||
const response = await this.axios.post(
|
||||
this.apiServer + "/api/partner/groupOnboard",
|
||||
{
|
||||
name: this.newOrUpdatedMeetingInputs.name,
|
||||
name: this.newOrUpdatedMeeting.name,
|
||||
expiresAt: localExpiresAt.toISOString(),
|
||||
content: encryptedContent,
|
||||
projectLink: this.newOrUpdatedMeetingInputs.projectLink,
|
||||
},
|
||||
{ headers },
|
||||
);
|
||||
|
||||
if (response.data && response.data.success) {
|
||||
this.currentMeeting = {
|
||||
...this.newOrUpdatedMeetingInputs,
|
||||
...this.newOrUpdatedMeeting,
|
||||
groupId: response.data.success.groupId,
|
||||
};
|
||||
|
||||
this.newOrUpdatedMeetingInputs = null;
|
||||
this.newOrUpdatedMeeting = null;
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
@@ -549,7 +502,7 @@ export default class OnboardMeetingView extends Vue {
|
||||
});
|
||||
|
||||
this.currentMeeting = null;
|
||||
this.newOrUpdatedMeetingInputs = this.blankMeeting();
|
||||
this.newOrUpdatedMeeting = this.blankMeeting();
|
||||
this.showDeleteConfirm = false;
|
||||
|
||||
this.$notify(
|
||||
@@ -581,12 +534,11 @@ export default class OnboardMeetingView extends Vue {
|
||||
// Populate form with existing meeting data
|
||||
if (this.currentMeeting) {
|
||||
const localExpiresAt = new Date(this.currentMeeting.expiresAt);
|
||||
this.newOrUpdatedMeetingInputs = {
|
||||
this.newOrUpdatedMeeting = {
|
||||
name: this.currentMeeting.name,
|
||||
expiresAt: this.formatDateForInput(localExpiresAt),
|
||||
userFullName: this.currentMeeting.userFullName || "",
|
||||
password: this.currentMeeting.password || "",
|
||||
projectLink: this.currentMeeting.projectLink || "",
|
||||
};
|
||||
} else {
|
||||
logger.error(
|
||||
@@ -597,18 +549,18 @@ export default class OnboardMeetingView extends Vue {
|
||||
|
||||
cancelEditing() {
|
||||
// Reset form data
|
||||
this.newOrUpdatedMeetingInputs = null;
|
||||
this.newOrUpdatedMeeting = null;
|
||||
}
|
||||
|
||||
async updateMeeting() {
|
||||
this.isLoading = true;
|
||||
if (!this.newOrUpdatedMeetingInputs) {
|
||||
if (!this.newOrUpdatedMeeting) {
|
||||
throw Error("There was no meeting data to update.");
|
||||
}
|
||||
|
||||
try {
|
||||
// Convert local time to UTC for comparison and server submission
|
||||
const localExpiresAt = new Date(this.newOrUpdatedMeetingInputs.expiresAt);
|
||||
const localExpiresAt = new Date(this.newOrUpdatedMeeting.expiresAt);
|
||||
const now = new Date();
|
||||
if (localExpiresAt <= now) {
|
||||
this.$notify(
|
||||
@@ -622,7 +574,7 @@ export default class OnboardMeetingView extends Vue {
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!this.newOrUpdatedMeetingInputs.userFullName) {
|
||||
if (!this.newOrUpdatedMeeting.userFullName) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
@@ -634,7 +586,7 @@ export default class OnboardMeetingView extends Vue {
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!this.newOrUpdatedMeetingInputs.password) {
|
||||
if (!this.newOrUpdatedMeeting.password) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
@@ -646,15 +598,15 @@ export default class OnboardMeetingView extends Vue {
|
||||
);
|
||||
return;
|
||||
}
|
||||
// create content with user's name & DID encrypted with password
|
||||
// create content with user's name and DID encrypted with password
|
||||
const content = {
|
||||
name: this.newOrUpdatedMeetingInputs.userFullName,
|
||||
name: this.newOrUpdatedMeeting.userFullName,
|
||||
did: this.activeDid,
|
||||
isRegistered: this.isRegistered,
|
||||
};
|
||||
const encryptedContent = await encryptMessage(
|
||||
JSON.stringify(content),
|
||||
this.newOrUpdatedMeetingInputs.password,
|
||||
this.newOrUpdatedMeeting.password,
|
||||
);
|
||||
|
||||
const headers = await getHeaders(this.activeDid);
|
||||
@@ -662,10 +614,9 @@ export default class OnboardMeetingView extends Vue {
|
||||
this.apiServer + "/api/partner/groupOnboard",
|
||||
{
|
||||
// the groupId is in the currentMeeting but it's not necessary while users only have one meeting
|
||||
name: this.newOrUpdatedMeetingInputs.name,
|
||||
name: this.newOrUpdatedMeeting.name,
|
||||
expiresAt: localExpiresAt.toISOString(),
|
||||
content: encryptedContent,
|
||||
projectLink: this.newOrUpdatedMeetingInputs.projectLink,
|
||||
},
|
||||
{ headers },
|
||||
);
|
||||
@@ -673,17 +624,10 @@ export default class OnboardMeetingView extends Vue {
|
||||
if (response.data && response.data.success) {
|
||||
// Update the current meeting with only the necessary fields
|
||||
this.currentMeeting = {
|
||||
...this.newOrUpdatedMeetingInputs,
|
||||
...this.newOrUpdatedMeeting,
|
||||
groupId: (this.currentMeeting?.groupId as number) || -1,
|
||||
};
|
||||
this.newOrUpdatedMeetingInputs = null;
|
||||
|
||||
if (this.currentMeeting?.password) {
|
||||
this.$router.push({
|
||||
name: "onboard-meeting-setup",
|
||||
query: { password: this.currentMeeting?.password },
|
||||
});
|
||||
}
|
||||
this.newOrUpdatedMeeting = null;
|
||||
} else {
|
||||
throw { response: response };
|
||||
}
|
||||
@@ -729,21 +673,5 @@ export default class OnboardMeetingView extends Vue {
|
||||
5000,
|
||||
);
|
||||
}
|
||||
|
||||
copyMembersLinkToClipboard() {
|
||||
useClipboard()
|
||||
.copy(this.onboardMeetingMembersLink())
|
||||
.then(() => {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "info",
|
||||
title: "Copied",
|
||||
text: "The member link is copied to the clipboard.",
|
||||
},
|
||||
5000,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -88,8 +88,6 @@ import { test, expect } from '@playwright/test';
|
||||
import { importUser, createUniqueStringsArray, createRandomNumbersArray } from './testUtils';
|
||||
|
||||
test('Record 9 new gifts', async ({ page }) => {
|
||||
test.slow(); // Set timeout longer
|
||||
|
||||
const giftCount = 9;
|
||||
const standardTitle = 'Gift ';
|
||||
const finalTitles = [];
|
||||
@@ -129,6 +127,6 @@ test('Record 9 new gifts', async ({ page }) => {
|
||||
await expect(page.locator('ul#listLatestActivity li')
|
||||
.filter({ hasText: finalTitles[i] })
|
||||
.first())
|
||||
.toBeVisible({ timeout: 3000 });
|
||||
.toBeVisible({ timeout: 10000 });
|
||||
}
|
||||
});
|
||||