feat: added conversation history feature
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { KGConversationStorage } from './KGConversationStorage';
|
||||
|
||||
class MockFileSystemWritableFileStream {
|
||||
public data = '';
|
||||
async write(content: string) { this.data = content; }
|
||||
async close() {}
|
||||
}
|
||||
|
||||
class MockFileSystemFileHandle {
|
||||
kind = 'file' as const;
|
||||
constructor(public name: string, private _content: string = '') {}
|
||||
async getFile() {
|
||||
return { text: () => Promise.resolve(this._content) };
|
||||
}
|
||||
async createWritable() {
|
||||
const stream = new MockFileSystemWritableFileStream();
|
||||
const origClose = stream.close.bind(stream);
|
||||
stream.close = async () => {
|
||||
this._content = stream.data;
|
||||
await origClose();
|
||||
};
|
||||
return stream;
|
||||
}
|
||||
}
|
||||
|
||||
class MockFileSystemDirectoryHandle {
|
||||
kind = 'directory' as const;
|
||||
private entries = new Map<string, MockFileSystemDirectoryHandle | MockFileSystemFileHandle>();
|
||||
|
||||
constructor(public name: string) {}
|
||||
|
||||
async getDirectoryHandle(name: string, options?: { create?: boolean }): Promise<MockFileSystemDirectoryHandle> {
|
||||
let entry = this.entries.get(name);
|
||||
if (!entry || entry.kind !== 'directory') {
|
||||
if (options?.create) {
|
||||
entry = new MockFileSystemDirectoryHandle(name);
|
||||
this.entries.set(name, entry);
|
||||
} else {
|
||||
throw new DOMException(`Directory "${name}" not found`, 'NotFoundError');
|
||||
}
|
||||
}
|
||||
return entry as MockFileSystemDirectoryHandle;
|
||||
}
|
||||
|
||||
async getFileHandle(name: string, options?: { create?: boolean }): Promise<MockFileSystemFileHandle> {
|
||||
let entry = this.entries.get(name);
|
||||
if (!entry || entry.kind !== 'file') {
|
||||
if (options?.create) {
|
||||
entry = new MockFileSystemFileHandle(name);
|
||||
this.entries.set(name, entry);
|
||||
} else {
|
||||
throw new DOMException(`File "${name}" not found`, 'NotFoundError');
|
||||
}
|
||||
}
|
||||
return entry as MockFileSystemFileHandle;
|
||||
}
|
||||
|
||||
async removeEntry(name: string): Promise<void> {
|
||||
this.entries.delete(name);
|
||||
}
|
||||
|
||||
async *values(): AsyncIterableIterator<MockFileSystemDirectoryHandle | MockFileSystemFileHandle> {
|
||||
for (const entry of this.entries.values()) {
|
||||
yield entry;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const mockRoot = new MockFileSystemDirectoryHandle('root');
|
||||
|
||||
vi.stubGlobal('navigator', {
|
||||
...navigator,
|
||||
storage: {
|
||||
getDirectory: vi.fn(() => Promise.resolve(mockRoot)),
|
||||
persist: vi.fn(() => Promise.resolve(true)),
|
||||
},
|
||||
});
|
||||
|
||||
describe('KGConversationStorage', () => {
|
||||
let storage: KGConversationStorage;
|
||||
|
||||
beforeEach(async () => {
|
||||
;(KGConversationStorage as unknown as { _instance: undefined })._instance = undefined;
|
||||
const entries = (mockRoot as unknown as { entries: Map<string, unknown> }).entries;
|
||||
entries.clear();
|
||||
|
||||
storage = KGConversationStorage.getInstance();
|
||||
await storage.initialize();
|
||||
});
|
||||
|
||||
it('saves, lists, and loads project-scoped conversations sorted by last turn time', async () => {
|
||||
await storage.saveConversation('Song A', {
|
||||
version: 1,
|
||||
conversationId: 'conv_1',
|
||||
continuationState: {
|
||||
messages: [{ id: 'm1', role: 'user', content: 'first', timestamp: 1 }],
|
||||
todos: [],
|
||||
},
|
||||
fullHistory: {
|
||||
messages: [{ id: 'm1', role: 'user', content: 'first', timestamp: 1 }],
|
||||
},
|
||||
displayTranscript: [{ id: 'display_1', role: 'user', content: 'first' }],
|
||||
}, {
|
||||
conversationId: 'conv_1',
|
||||
title: 'First',
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
lastTurnAt: 1,
|
||||
messageCount: 1,
|
||||
preview: 'first',
|
||||
});
|
||||
|
||||
await storage.saveConversation('Song A', {
|
||||
version: 1,
|
||||
conversationId: 'conv_2',
|
||||
continuationState: {
|
||||
messages: [{ id: 'm2', role: 'user', content: 'second', timestamp: 2 }],
|
||||
todos: [],
|
||||
},
|
||||
fullHistory: {
|
||||
messages: [{ id: 'm2', role: 'user', content: 'second', timestamp: 2 }],
|
||||
},
|
||||
displayTranscript: [{ id: 'display_2', role: 'user', content: 'second' }],
|
||||
}, {
|
||||
conversationId: 'conv_2',
|
||||
title: 'Second',
|
||||
createdAt: 2,
|
||||
updatedAt: 2,
|
||||
lastTurnAt: 2,
|
||||
messageCount: 1,
|
||||
preview: 'second',
|
||||
});
|
||||
|
||||
const listed = await storage.listConversations('Song A');
|
||||
expect(listed.map(item => item.conversationId)).toEqual(['conv_2', 'conv_1']);
|
||||
|
||||
const loaded = await storage.loadConversation('Song A', 'conv_1');
|
||||
expect(loaded?.document.conversationId).toBe('conv_1');
|
||||
expect(loaded?.document.displayTranscript).toEqual([
|
||||
{ id: 'display_1', role: 'user', content: 'first' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
import { OPFS_CONSTANTS } from '../../constants/coreConstants';
|
||||
import type { SavedConversationDocument, SavedConversationMeta } from '../../types/conversationTypes';
|
||||
|
||||
export class KGConversationStorage {
|
||||
private static _instance: KGConversationStorage;
|
||||
private rootDirHandle: FileSystemDirectoryHandle | null = null;
|
||||
private projectsDirHandle: FileSystemDirectoryHandle | null = null;
|
||||
private _initialized = false;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): KGConversationStorage {
|
||||
if (!KGConversationStorage._instance) {
|
||||
KGConversationStorage._instance = new KGConversationStorage();
|
||||
}
|
||||
return KGConversationStorage._instance;
|
||||
}
|
||||
|
||||
public async initialize(): Promise<void> {
|
||||
if (this._initialized) return;
|
||||
|
||||
if (!navigator.storage?.getDirectory) {
|
||||
throw new Error(
|
||||
'OPFS is unavailable. K.G.Studio requires a secure context (HTTPS or localhost). ' +
|
||||
'Access via https:// or use localhost/127.0.0.1 instead of an IP address.',
|
||||
);
|
||||
}
|
||||
|
||||
this.rootDirHandle = await navigator.storage.getDirectory();
|
||||
this.projectsDirHandle = await this.rootDirHandle.getDirectoryHandle(
|
||||
OPFS_CONSTANTS.ROOT_DIR,
|
||||
{ create: true },
|
||||
);
|
||||
this._initialized = true;
|
||||
}
|
||||
|
||||
public async saveConversation(
|
||||
projectName: string,
|
||||
document: SavedConversationDocument,
|
||||
meta: SavedConversationMeta,
|
||||
): Promise<void> {
|
||||
this.ensureInitialized();
|
||||
|
||||
const conversationDir = await this.getConversationDir(projectName, document.conversationId, true);
|
||||
await this.writeFile(
|
||||
conversationDir,
|
||||
OPFS_CONSTANTS.CONVERSATION_FILE,
|
||||
JSON.stringify(document, null, 2),
|
||||
);
|
||||
await this.writeFile(
|
||||
conversationDir,
|
||||
OPFS_CONSTANTS.METADATA_FILE,
|
||||
JSON.stringify(meta, null, 2),
|
||||
);
|
||||
}
|
||||
|
||||
public async loadConversation(
|
||||
projectName: string,
|
||||
conversationId: string,
|
||||
): Promise<{ document: SavedConversationDocument; meta: SavedConversationMeta } | null> {
|
||||
this.ensureInitialized();
|
||||
|
||||
try {
|
||||
const conversationDir = await this.getConversationDir(projectName, conversationId, false);
|
||||
const [documentRaw, metaRaw] = await Promise.all([
|
||||
this.readFile(conversationDir, OPFS_CONSTANTS.CONVERSATION_FILE),
|
||||
this.readFile(conversationDir, OPFS_CONSTANTS.METADATA_FILE),
|
||||
]);
|
||||
return {
|
||||
document: JSON.parse(documentRaw) as SavedConversationDocument,
|
||||
meta: JSON.parse(metaRaw) as SavedConversationMeta,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Error loading conversation "${conversationId}" for project "${projectName}":`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async listConversations(projectName: string): Promise<SavedConversationMeta[]> {
|
||||
this.ensureInitialized();
|
||||
|
||||
let conversationsDir: FileSystemDirectoryHandle;
|
||||
try {
|
||||
conversationsDir = await this.getConversationsDir(projectName, false);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const metas: SavedConversationMeta[] = [];
|
||||
for await (const entry of conversationsDir.values()) {
|
||||
if (entry.kind !== 'directory') {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const conversationDir = await conversationsDir.getDirectoryHandle(entry.name);
|
||||
const metaRaw = await this.readFile(conversationDir, OPFS_CONSTANTS.METADATA_FILE);
|
||||
metas.push(JSON.parse(metaRaw) as SavedConversationMeta);
|
||||
} catch {
|
||||
// Skip malformed entries.
|
||||
}
|
||||
}
|
||||
|
||||
return metas.sort((a, b) => b.lastTurnAt - a.lastTurnAt);
|
||||
}
|
||||
|
||||
public async deleteConversation(projectName: string, conversationId: string): Promise<void> {
|
||||
this.ensureInitialized();
|
||||
const conversationsDir = await this.getConversationsDir(projectName, false);
|
||||
await conversationsDir.removeEntry(conversationId, { recursive: true });
|
||||
}
|
||||
|
||||
private ensureInitialized(): void {
|
||||
if (!this._initialized || !this.projectsDirHandle) {
|
||||
throw new Error('KGConversationStorage not initialized. Call initialize() first.');
|
||||
}
|
||||
}
|
||||
|
||||
private async getConversationsDir(
|
||||
projectName: string,
|
||||
create: boolean,
|
||||
): Promise<FileSystemDirectoryHandle> {
|
||||
const projectDir = await this.projectsDirHandle!.getDirectoryHandle(projectName, { create });
|
||||
return projectDir.getDirectoryHandle(OPFS_CONSTANTS.CONVERSATIONS_DIR, { create });
|
||||
}
|
||||
|
||||
private async getConversationDir(
|
||||
projectName: string,
|
||||
conversationId: string,
|
||||
create: boolean,
|
||||
): Promise<FileSystemDirectoryHandle> {
|
||||
const conversationsDir = await this.getConversationsDir(projectName, create);
|
||||
return conversationsDir.getDirectoryHandle(conversationId, { create });
|
||||
}
|
||||
|
||||
private async writeFile(
|
||||
dirHandle: FileSystemDirectoryHandle,
|
||||
fileName: string,
|
||||
content: string,
|
||||
): Promise<void> {
|
||||
const fileHandle = await dirHandle.getFileHandle(fileName, { create: true });
|
||||
const writable = await fileHandle.createWritable();
|
||||
await writable.write(content);
|
||||
await writable.close();
|
||||
}
|
||||
|
||||
private async readFile(
|
||||
dirHandle: FileSystemDirectoryHandle,
|
||||
fileName: string,
|
||||
): Promise<string> {
|
||||
const fileHandle = await dirHandle.getFileHandle(fileName);
|
||||
const file = await fileHandle.getFile();
|
||||
return file.text();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { KGProjectStorage, DuplicateEntryError } from './KGProjectStorage';
|
||||
import { KGConversationStorage } from './KGConversationStorage';
|
||||
import { KGProject } from '../KGProject';
|
||||
import { GlobalTrackType } from '../global-track';
|
||||
import { KGKeySignatureRegion } from '../region/KGKeySignatureRegion';
|
||||
@@ -10,16 +11,23 @@ import { KGTrack } from '../track/KGTrack';
|
||||
// --- OPFS mock infrastructure ---
|
||||
|
||||
class MockFileSystemWritableFileStream {
|
||||
public data = '';
|
||||
async write(content: string) { this.data = content; }
|
||||
public data: string | ArrayBuffer = '';
|
||||
async write(content: string | ArrayBuffer) { this.data = content; }
|
||||
async close() {}
|
||||
}
|
||||
|
||||
class MockFileSystemFileHandle {
|
||||
kind = 'file' as const;
|
||||
constructor(public name: string, private _content: string = '') {}
|
||||
constructor(public name: string, private _content: string | ArrayBuffer = '') {}
|
||||
async getFile() {
|
||||
return { text: () => Promise.resolve(this._content) };
|
||||
return {
|
||||
text: () => Promise.resolve(typeof this._content === 'string' ? this._content : new TextDecoder().decode(this._content)),
|
||||
arrayBuffer: () => Promise.resolve(
|
||||
typeof this._content === 'string'
|
||||
? new TextEncoder().encode(this._content).buffer
|
||||
: this._content
|
||||
),
|
||||
};
|
||||
}
|
||||
async createWritable() {
|
||||
const stream = new MockFileSystemWritableFileStream();
|
||||
@@ -93,6 +101,7 @@ vi.stubGlobal('navigator', {
|
||||
|
||||
describe('KGProjectStorage', () => {
|
||||
let storage: KGProjectStorage;
|
||||
let conversationStorage: KGConversationStorage;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Reset singleton and mock filesystem
|
||||
@@ -103,6 +112,9 @@ describe('KGProjectStorage', () => {
|
||||
|
||||
storage = KGProjectStorage.getInstance();
|
||||
await storage.initialize();
|
||||
;(KGConversationStorage as unknown as { _instance: undefined })._instance = undefined;
|
||||
conversationStorage = KGConversationStorage.getInstance();
|
||||
await conversationStorage.initialize();
|
||||
});
|
||||
|
||||
function createTestProject(name = 'Test Project'): KGProject {
|
||||
@@ -337,4 +349,65 @@ describe('KGProjectStorage', () => {
|
||||
const loaded = await storage.load('New Name');
|
||||
expect(loaded!.getName()).toBe('New Name');
|
||||
});
|
||||
|
||||
it('copies conversation history when saving under a new project name', async () => {
|
||||
await storage.save('Source Song', createTestProject('Source Song'));
|
||||
await conversationStorage.saveConversation('Source Song', {
|
||||
version: 1,
|
||||
conversationId: 'conv_1',
|
||||
continuationState: {
|
||||
messages: [{ id: 'm1', role: 'user', content: 'hello', timestamp: 1 }],
|
||||
todos: [],
|
||||
},
|
||||
fullHistory: {
|
||||
messages: [{ id: 'm1', role: 'user', content: 'hello', timestamp: 1 }],
|
||||
},
|
||||
displayTranscript: [{ id: 'display_1', role: 'user', content: 'hello' }],
|
||||
}, {
|
||||
conversationId: 'conv_1',
|
||||
title: 'hello',
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
lastTurnAt: 1,
|
||||
messageCount: 1,
|
||||
preview: 'hello',
|
||||
});
|
||||
|
||||
await storage.saveAs('Source Song', 'Copied Song', createTestProject('Copied Song'));
|
||||
|
||||
const loadedConversation = await conversationStorage.loadConversation('Copied Song', 'conv_1');
|
||||
expect(loadedConversation?.document.conversationId).toBe('conv_1');
|
||||
});
|
||||
|
||||
it('includes conversation history in project bundle export and import', async () => {
|
||||
await storage.save('Bundle Song', createTestProject('Bundle Song'));
|
||||
await conversationStorage.saveConversation('Bundle Song', {
|
||||
version: 1,
|
||||
conversationId: 'conv_bundle',
|
||||
continuationState: {
|
||||
messages: [{ id: 'm1', role: 'user', content: 'bundle', timestamp: 1 }],
|
||||
todos: [],
|
||||
},
|
||||
fullHistory: {
|
||||
messages: [{ id: 'm1', role: 'user', content: 'bundle', timestamp: 1 }],
|
||||
},
|
||||
displayTranscript: [{ id: 'display_1', role: 'user', content: 'bundle' }],
|
||||
}, {
|
||||
conversationId: 'conv_bundle',
|
||||
title: 'bundle',
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
lastTurnAt: 1,
|
||||
messageCount: 1,
|
||||
preview: 'bundle',
|
||||
});
|
||||
|
||||
const bundle = await storage.exportAsZip('Bundle Song');
|
||||
const importedName = await storage.importFromZip(bundle);
|
||||
const loadedConversation = await conversationStorage.loadConversation(importedName, 'conv_bundle');
|
||||
|
||||
expect(loadedConversation?.document.displayTranscript).toEqual([
|
||||
{ id: 'display_1', role: 'user', content: 'bundle' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -232,8 +232,8 @@ export class KGProjectStorage {
|
||||
project.setName(targetName);
|
||||
await this.save(targetName, project, false);
|
||||
|
||||
// Copy media files
|
||||
await this.copyMediaFiles(sourceName, targetName);
|
||||
// Copy project-scoped auxiliary artifacts such as media and conversation history
|
||||
await this.copyProjectArtifacts(sourceName, targetName);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -346,8 +346,8 @@ export class KGProjectStorage {
|
||||
project.setName(newName);
|
||||
await this.save(newName, project, false);
|
||||
|
||||
// Copy media files from old to new location
|
||||
await this.copyMediaFiles(oldName, newName);
|
||||
// Copy project-scoped auxiliary artifacts such as media and conversation history
|
||||
await this.copyProjectArtifacts(oldName, newName);
|
||||
|
||||
// Delete old location
|
||||
await this.delete(oldName);
|
||||
@@ -366,7 +366,7 @@ export class KGProjectStorage {
|
||||
|
||||
// Migrate media files only if the old folder exists
|
||||
if (await this.exists(oldName)) {
|
||||
await this.copyMediaFiles(oldName, newName);
|
||||
await this.copyProjectArtifacts(oldName, newName);
|
||||
await this.delete(oldName);
|
||||
}
|
||||
}
|
||||
@@ -381,7 +381,7 @@ export class KGProjectStorage {
|
||||
await this.save(targetName, data, false);
|
||||
|
||||
if (await this.exists(sourceName)) {
|
||||
await this.copyMediaFiles(sourceName, targetName);
|
||||
await this.copyProjectArtifacts(sourceName, targetName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -414,7 +414,7 @@ export class KGProjectStorage {
|
||||
if (entry.kind === 'file') {
|
||||
const fileHandle = entry as FileSystemFileHandle;
|
||||
const file = await fileHandle.getFile();
|
||||
zip.file(entryPath, file.arrayBuffer());
|
||||
zip.file(entryPath, new Uint8Array(await this.readBlobLikeAsArrayBuffer(file)));
|
||||
} else {
|
||||
const subDir = entry as FileSystemDirectoryHandle;
|
||||
await this.addDirectoryToZip(zip, subDir, entryPath);
|
||||
@@ -551,40 +551,53 @@ export class KGProjectStorage {
|
||||
|
||||
// --- Media migration ---
|
||||
|
||||
/**
|
||||
* Copy all files from projects/<fromName>/media/ to projects/<toName>/media/.
|
||||
* If the source media directory doesn't exist, returns without error.
|
||||
*/
|
||||
private async copyMediaFiles(fromName: string, toName: string): Promise<void> {
|
||||
private async copyProjectArtifacts(fromName: string, toName: string): Promise<void> {
|
||||
try {
|
||||
const fromDir = await this.projectsDirHandle!.getDirectoryHandle(fromName);
|
||||
let fromMedia: FileSystemDirectoryHandle;
|
||||
try {
|
||||
fromMedia = await fromDir.getDirectoryHandle(OPFS_CONSTANTS.MEDIA_DIR);
|
||||
} catch {
|
||||
// No media directory in source — nothing to copy
|
||||
return;
|
||||
}
|
||||
|
||||
const toDir = await this.projectsDirHandle!.getDirectoryHandle(toName);
|
||||
const toMedia = await toDir.getDirectoryHandle(OPFS_CONSTANTS.MEDIA_DIR, { create: true });
|
||||
|
||||
for await (const entry of fromMedia.values()) {
|
||||
for await (const entry of fromDir.values()) {
|
||||
if (entry.name === OPFS_CONSTANTS.PROJECT_FILE || entry.name === OPFS_CONSTANTS.METADATA_FILE) {
|
||||
continue;
|
||||
}
|
||||
if (entry.kind === 'file') {
|
||||
const fileHandle = entry as FileSystemFileHandle;
|
||||
const file = await fileHandle.getFile();
|
||||
const newHandle = await toMedia.getFileHandle(entry.name, { create: true });
|
||||
const newHandle = await toDir.getFileHandle(entry.name, { create: true });
|
||||
const writable = await newHandle.createWritable();
|
||||
await writable.write(await file.arrayBuffer());
|
||||
await writable.close();
|
||||
} else {
|
||||
await this.copyDirectoryContents(
|
||||
entry as FileSystemDirectoryHandle,
|
||||
await toDir.getDirectoryHandle(entry.name, { create: true }),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error copying media files from "${fromName}" to "${toName}":`, error);
|
||||
console.error(`Error copying project artifacts from "${fromName}" to "${toName}":`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async copyDirectoryContents(
|
||||
fromDir: FileSystemDirectoryHandle,
|
||||
toDir: FileSystemDirectoryHandle,
|
||||
): Promise<void> {
|
||||
for await (const entry of fromDir.values()) {
|
||||
if (entry.kind === 'file') {
|
||||
const fileHandle = entry as FileSystemFileHandle;
|
||||
const file = await fileHandle.getFile();
|
||||
const newHandle = await toDir.getFileHandle(entry.name, { create: true });
|
||||
const writable = await newHandle.createWritable();
|
||||
await writable.write(await this.readBlobLikeAsArrayBuffer(file));
|
||||
await writable.close();
|
||||
} else {
|
||||
const newSubDir = await toDir.getDirectoryHandle(entry.name, { create: true });
|
||||
await this.copyDirectoryContents(entry as FileSystemDirectoryHandle, newSubDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- File I/O helpers ---
|
||||
|
||||
private async writeFile(
|
||||
@@ -618,4 +631,16 @@ export class KGProjectStorage {
|
||||
return { name, createdAt: 0, updatedAt: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
private async readBlobLikeAsArrayBuffer(
|
||||
file: { arrayBuffer?: () => Promise<ArrayBuffer>; text?: () => Promise<string> },
|
||||
): Promise<ArrayBuffer> {
|
||||
if (typeof file.arrayBuffer === 'function') {
|
||||
return file.arrayBuffer();
|
||||
}
|
||||
if (typeof file.text === 'function') {
|
||||
return new TextEncoder().encode(await file.text()).buffer;
|
||||
}
|
||||
throw new Error('Unsupported file object: expected arrayBuffer() or text().');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user