workspaces to dashboards, imap email sync, ffmpeg tool, tts fix, file browser refresh, automation sidebar reorder

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-02 20:00:45 +00:00
co-authored by Claude Opus 4.6
parent bd362dc586
commit 927267e041
98 changed files with 1176 additions and 802 deletions
+102
View File
@@ -0,0 +1,102 @@
import { mkdir, readdir, rm, cp } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import { readdirSync } from 'node:fs';
import { createRouter } from '@@/create-router';
import { getDirs, migrateDashboardsDir, migrateFromState, migrateHomepageToScreens, readAllDashboardsState, resolveKey, writeJsonFile } from './utils';
const TEMPLATES_DIR = resolve(import.meta.dir, '../../../../seed/project-templates');
export const dashboardsRouter = createRouter();
// GET /dashboards
dashboardsRouter.get('/', async (ctx) => {
const email = ctx.get('user').email;
await migrateDashboardsDir(email);
const dirs = getDirs(email);
const dirFile = Bun.file(join(dirs.dashDir, 'index.json'));
if (!(await dirFile.exists())) {
await migrateFromState(email, dirs);
}
await migrateHomepageToScreens(dirs, email);
const state = await readAllDashboardsState(dirs);
return ctx.json(state);
});
// PATCH /dashboards
dashboardsRouter.patch('/', async (ctx) => {
const email = ctx.get('user').email;
const body = ctx.get('body') as Record<string, unknown>;
const dirs = getDirs(email);
await mkdir(dirs.dashDir, { recursive: true });
for (const [key, value] of Object.entries(body)) {
// Handle proj-meta-{slug}: create/update/delete project
const projMetaMatch = key.match(/^proj-meta-(.+)$/);
if (projMetaMatch) {
const slug = projMetaMatch[1]!;
const projectDir = join(dirs.projDir, slug);
if (value === null) {
await rm(projectDir, { recursive: true, force: true });
continue;
}
const officerdevDir = join(projectDir, '.officerdev');
const metaFile = join(officerdevDir, 'meta.json');
const isNew = !(await Bun.file(metaFile).exists());
await mkdir(officerdevDir, { recursive: true });
await writeJsonFile(metaFile, value);
if (isNew) {
const meta = value as Record<string, unknown>;
if (meta.projectType === 'app') {
const templateDir = join(TEMPLATES_DIR, 'simple-app-template');
const entries = readdirSync(templateDir);
for (const entry of entries) {
if (entry === '.git' || entry === '.officerdev') continue;
await cp(join(templateDir, entry), join(projectDir, entry), { recursive: true });
}
const pkgPath = join(projectDir, 'package.json');
const pkg = await Bun.file(pkgPath).json().catch(() => null);
if (pkg) {
pkg.name = slug;
await Bun.write(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
}
const install = Bun.spawn(['bun', 'install'], { cwd: projectDir, stdout: 'ignore', stderr: 'ignore' });
await install.exited;
}
const gitInit = Bun.spawn(['git', 'init', projectDir], { stdout: 'ignore', stderr: 'ignore' });
await gitInit.exited;
}
continue;
}
const mapping = resolveKey(dirs, key);
if (!mapping) continue;
if (value === null) {
try {
await rm(mapping.file, { force: true });
if (mapping.dir) {
const remaining = await readdir(mapping.dir);
if (remaining.length === 0) await rm(mapping.dir, { recursive: true, force: true });
}
} catch {
// ignore
}
continue;
}
if (mapping.dir) await mkdir(mapping.dir, { recursive: true });
await writeJsonFile(mapping.file, value);
}
const state = await readAllDashboardsState(dirs);
return ctx.json(state);
});
+3
View File
@@ -0,0 +1,3 @@
export * from './dashboards';
export * from './types';
export * from './utils';
+3
View File
@@ -0,0 +1,3 @@
export type KeyMapping = { file: string; dir?: string };
export type ResolveDirs = { dashDir: string; screensDir: string; projDir: string };
+263
View File
@@ -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),
};
}