|
|
|
@@ -0,0 +1,263 @@
|
|
|
|
|
import { mkdir, readdir, rename, rm } from 'node:fs/promises';
|
|
|
|
|
import { join } from 'node:path';
|
|
|
|
|
import { getUserDashboardsDir, getUserHomepageDashboardDir, getUserStateFile, getUserProjectsDir, DATA_PATH } from '@@/data-path';
|
|
|
|
|
import type { KeyMapping, ResolveDirs } from './types';
|
|
|
|
|
|
|
|
|
|
const RESERVED_DIRS = new Set(['screens']);
|
|
|
|
|
|
|
|
|
|
export function resolveKey(dirs: ResolveDirs, key: string): KeyMapping | null {
|
|
|
|
|
if (key === 'workspaces') return { file: join(dirs.dashDir, 'index.json') };
|
|
|
|
|
if (key === 'ws-terminals-default') return { file: join(dirs.dashDir, 'default-terminals.json') };
|
|
|
|
|
if (key === 'ws-host-terminals-default') return { file: join(dirs.dashDir, 'default-host-terminals.json') };
|
|
|
|
|
|
|
|
|
|
// screens/{name} → screens/{name}/layout.json
|
|
|
|
|
const screensMatch = key.match(/^screens\/(.+)$/);
|
|
|
|
|
if (screensMatch) {
|
|
|
|
|
const name = screensMatch[1]!;
|
|
|
|
|
const dir = join(dirs.screensDir, name);
|
|
|
|
|
return { file: join(dir, 'layout.json'), dir };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const layoutMatch = key.match(/^ws-layout-(.+)$/);
|
|
|
|
|
if (layoutMatch) {
|
|
|
|
|
const id = layoutMatch[1]!;
|
|
|
|
|
const dir = join(dirs.dashDir, id);
|
|
|
|
|
return { file: join(dir, 'layout.json'), dir };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const terminalsMatch = key.match(/^ws-terminals-(.+)$/);
|
|
|
|
|
if (terminalsMatch) {
|
|
|
|
|
const id = terminalsMatch[1]!;
|
|
|
|
|
const dir = join(dirs.dashDir, id);
|
|
|
|
|
return { file: join(dir, 'terminals.json'), dir };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const hostTerminalsMatch = key.match(/^ws-host-terminals-(.+)$/);
|
|
|
|
|
if (hostTerminalsMatch) {
|
|
|
|
|
const id = hostTerminalsMatch[1]!;
|
|
|
|
|
const dir = join(dirs.dashDir, id);
|
|
|
|
|
return { file: join(dir, 'host-terminals.json'), dir };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Project keys — stored in {projDir}/{slug}/.officerdev/
|
|
|
|
|
const projMetaMatch = key.match(/^proj-meta-(.+)$/);
|
|
|
|
|
if (projMetaMatch) {
|
|
|
|
|
const slug = projMetaMatch[1]!;
|
|
|
|
|
const dir = join(dirs.projDir, slug, '.officerdev');
|
|
|
|
|
return { file: join(dir, 'meta.json'), dir };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const projLayoutMatch = key.match(/^proj-layout-(.+)$/);
|
|
|
|
|
if (projLayoutMatch) {
|
|
|
|
|
const slug = projLayoutMatch[1]!;
|
|
|
|
|
const dir = join(dirs.projDir, slug, '.officerdev');
|
|
|
|
|
return { file: join(dir, 'layout.json'), dir };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const projTerminalsMatch = key.match(/^proj-terminals-(.+)$/);
|
|
|
|
|
if (projTerminalsMatch) {
|
|
|
|
|
const slug = projTerminalsMatch[1]!;
|
|
|
|
|
const dir = join(dirs.projDir, slug, '.officerdev');
|
|
|
|
|
return { file: join(dir, 'terminals.json'), dir };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const projHostTerminalsMatch = key.match(/^proj-host-terminals-(.+)$/);
|
|
|
|
|
if (projHostTerminalsMatch) {
|
|
|
|
|
const slug = projHostTerminalsMatch[1]!;
|
|
|
|
|
const dir = join(dirs.projDir, slug, '.officerdev');
|
|
|
|
|
return { file: join(dir, 'host-terminals.json'), dir };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function readJsonFile(path: string): Promise<unknown | null> {
|
|
|
|
|
try {
|
|
|
|
|
const file = Bun.file(path);
|
|
|
|
|
if (!(await file.exists())) return null;
|
|
|
|
|
return await file.json();
|
|
|
|
|
} catch {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function writeJsonFile(path: string, data: unknown) {
|
|
|
|
|
await Bun.write(path, JSON.stringify(data, null, 2));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function migrateFromState(email: string, dirs: ResolveDirs) {
|
|
|
|
|
const stateFile = getUserStateFile(email);
|
|
|
|
|
const file = Bun.file(stateFile);
|
|
|
|
|
if (!(await file.exists())) return;
|
|
|
|
|
|
|
|
|
|
let state: Record<string, unknown>;
|
|
|
|
|
try { state = (await file.json()) as Record<string, unknown>; } catch { return; }
|
|
|
|
|
const wsKeys = Object.keys(state).filter(
|
|
|
|
|
(k) => k === 'workspaces' || k.startsWith('ws-layout-') || k.startsWith('ws-terminals-') || k.startsWith('ws-host-terminals-'),
|
|
|
|
|
);
|
|
|
|
|
if (wsKeys.length === 0) return;
|
|
|
|
|
|
|
|
|
|
await mkdir(dirs.dashDir, { recursive: true });
|
|
|
|
|
|
|
|
|
|
for (const key of wsKeys) {
|
|
|
|
|
const migratedKey = key === 'ws-layout-workspaces' ? 'screens/homepage' : key;
|
|
|
|
|
const mapping = resolveKey(dirs, migratedKey);
|
|
|
|
|
if (!mapping) continue;
|
|
|
|
|
if (mapping.dir) await mkdir(mapping.dir, { recursive: true });
|
|
|
|
|
await writeJsonFile(mapping.file, state[key]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const cleaned = { ...state };
|
|
|
|
|
for (const key of wsKeys) delete cleaned[key];
|
|
|
|
|
await Bun.write(stateFile, JSON.stringify(cleaned, null, 2));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function migrateHomepageToScreens(dirs: ResolveDirs, email: string) {
|
|
|
|
|
const oldHomepageDir = getUserHomepageDashboardDir(email);
|
|
|
|
|
const layoutFile = join(oldHomepageDir, 'layout.json');
|
|
|
|
|
if (!(await Bun.file(layoutFile).exists())) return;
|
|
|
|
|
|
|
|
|
|
const targetDir = join(dirs.screensDir, 'homepage');
|
|
|
|
|
await mkdir(targetDir, { recursive: true });
|
|
|
|
|
|
|
|
|
|
for (const name of ['layout.json', 'terminals.json', 'host-terminals.json']) {
|
|
|
|
|
const src = join(oldHomepageDir, name);
|
|
|
|
|
if (await Bun.file(src).exists()) {
|
|
|
|
|
await rename(src, join(targetDir, name));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const remaining = await readdir(oldHomepageDir);
|
|
|
|
|
if (remaining.length === 0) await rm(oldHomepageDir, { recursive: true, force: true });
|
|
|
|
|
|
|
|
|
|
const oldWsHomepageDir = join(dirs.dashDir, 'ws-homepage');
|
|
|
|
|
const oldWsLayout = join(oldWsHomepageDir, 'layout.json');
|
|
|
|
|
if (!(await Bun.file(oldWsLayout).exists())) return;
|
|
|
|
|
|
|
|
|
|
for (const name of ['layout.json', 'terminals.json', 'host-terminals.json']) {
|
|
|
|
|
const src = join(oldWsHomepageDir, name);
|
|
|
|
|
const target = join(targetDir, name);
|
|
|
|
|
if (await Bun.file(src).exists() && !(await Bun.file(target).exists())) {
|
|
|
|
|
await rename(src, target);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const wsRemaining = await readdir(oldWsHomepageDir);
|
|
|
|
|
if (wsRemaining.length === 0) await rm(oldWsHomepageDir, { recursive: true, force: true });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Migrate on-disk directory from old 'workspaces' name to 'dashboards'
|
|
|
|
|
export async function migrateDashboardsDir(email: string) {
|
|
|
|
|
const oldDir = join(DATA_PATH, email, 'workspaces');
|
|
|
|
|
const newDir = getUserDashboardsDir(email);
|
|
|
|
|
try {
|
|
|
|
|
const oldExists = await Bun.file(join(oldDir, 'index.json')).exists() || await readdir(oldDir).then(() => true).catch(() => false);
|
|
|
|
|
if (oldExists) {
|
|
|
|
|
const newExists = await readdir(newDir).then(() => true).catch(() => false);
|
|
|
|
|
if (!newExists) {
|
|
|
|
|
await rename(oldDir, newDir);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore — old dir doesn't exist
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function readDashboardDir(dirPath: string, id: string, result: Record<string, unknown>) {
|
|
|
|
|
const layout = await readJsonFile(join(dirPath, 'layout.json'));
|
|
|
|
|
if (layout !== null) result[`ws-layout-${id}`] = layout;
|
|
|
|
|
|
|
|
|
|
const terminals = await readJsonFile(join(dirPath, 'terminals.json'));
|
|
|
|
|
if (terminals !== null) result[`ws-terminals-${id}`] = terminals;
|
|
|
|
|
|
|
|
|
|
const hostTerminals = await readJsonFile(join(dirPath, 'host-terminals.json'));
|
|
|
|
|
if (hostTerminals !== null) result[`ws-host-terminals-${id}`] = hostTerminals;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function readProjectDir(projectPath: string, slug: string, result: Record<string, unknown>): Promise<unknown | null> {
|
|
|
|
|
const officerdevDir = join(projectPath, '.officerdev');
|
|
|
|
|
const meta = await readJsonFile(join(officerdevDir, 'meta.json'));
|
|
|
|
|
if (!meta) return null;
|
|
|
|
|
|
|
|
|
|
const layout = await readJsonFile(join(officerdevDir, 'layout.json'));
|
|
|
|
|
if (layout !== null) result[`proj-layout-${slug}`] = layout;
|
|
|
|
|
|
|
|
|
|
const terminals = await readJsonFile(join(officerdevDir, 'terminals.json'));
|
|
|
|
|
if (terminals !== null) result[`proj-terminals-${slug}`] = terminals;
|
|
|
|
|
|
|
|
|
|
const hostTerminals = await readJsonFile(join(officerdevDir, 'host-terminals.json'));
|
|
|
|
|
if (hostTerminals !== null) result[`proj-host-terminals-${slug}`] = hostTerminals;
|
|
|
|
|
|
|
|
|
|
return { ...(meta as object), id: slug, cwd: `/Projects/${slug}` };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function readScreensDir(screensDir: string, result: Record<string, unknown>) {
|
|
|
|
|
let entries: import('node:fs').Dirent[] = [];
|
|
|
|
|
try {
|
|
|
|
|
entries = await readdir(screensDir, { withFileTypes: true });
|
|
|
|
|
} catch {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const entry of entries) {
|
|
|
|
|
if (!entry.isDirectory()) continue;
|
|
|
|
|
const layout = await readJsonFile(join(screensDir, entry.name, 'layout.json'));
|
|
|
|
|
if (layout !== null) result[`screens/${entry.name}`] = layout;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function readAllDashboardsState(dirs: ResolveDirs): Promise<Record<string, unknown>> {
|
|
|
|
|
const result: Record<string, unknown> = {};
|
|
|
|
|
|
|
|
|
|
const indexData = await readJsonFile(join(dirs.dashDir, 'index.json'));
|
|
|
|
|
if (indexData !== null) result['workspaces'] = indexData;
|
|
|
|
|
|
|
|
|
|
const defaultTerminals = await readJsonFile(join(dirs.dashDir, 'default-terminals.json'));
|
|
|
|
|
if (defaultTerminals !== null) result['ws-terminals-default'] = defaultTerminals;
|
|
|
|
|
|
|
|
|
|
const defaultHostTerminals = await readJsonFile(join(dirs.dashDir, 'default-host-terminals.json'));
|
|
|
|
|
if (defaultHostTerminals !== null) result['ws-host-terminals-default'] = defaultHostTerminals;
|
|
|
|
|
|
|
|
|
|
// Read screens
|
|
|
|
|
await readScreensDir(dirs.screensDir, result);
|
|
|
|
|
|
|
|
|
|
// Read per-dashboard subdirs
|
|
|
|
|
let entries: import('node:fs').Dirent[] = [];
|
|
|
|
|
try {
|
|
|
|
|
entries = await readdir(dirs.dashDir, { withFileTypes: true });
|
|
|
|
|
} catch {
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const entry of entries) {
|
|
|
|
|
if (!entry.isDirectory() || RESERVED_DIRS.has(entry.name)) continue;
|
|
|
|
|
await readDashboardDir(join(dirs.dashDir, entry.name), entry.name, result);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Read project data — scan directories containing .officerdev/meta.json
|
|
|
|
|
const projects: unknown[] = [];
|
|
|
|
|
let projEntries: import('node:fs').Dirent[] = [];
|
|
|
|
|
try {
|
|
|
|
|
projEntries = await readdir(dirs.projDir, { withFileTypes: true });
|
|
|
|
|
} catch {
|
|
|
|
|
result['projects'] = projects;
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const entry of projEntries) {
|
|
|
|
|
if (!entry.isDirectory()) continue;
|
|
|
|
|
const meta = await readProjectDir(join(dirs.projDir, entry.name), entry.name, result);
|
|
|
|
|
if (meta) projects.push(meta);
|
|
|
|
|
}
|
|
|
|
|
result['projects'] = projects;
|
|
|
|
|
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function getDirs(email: string): ResolveDirs {
|
|
|
|
|
return {
|
|
|
|
|
dashDir: getUserDashboardsDir(email),
|
|
|
|
|
screensDir: join(getUserDashboardsDir(email), 'screens'),
|
|
|
|
|
projDir: getUserProjectsDir(email),
|
|
|
|
|
};
|
|
|
|
|
}
|