user-context migration
This commit is contained in:
@@ -0,0 +1,596 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
type SharePolicy = "collaborative" | "handoff";
|
||||
type ShareKind = "user" | "bucket" | "note";
|
||||
|
||||
type ShareContext = {
|
||||
id: string;
|
||||
key: string;
|
||||
displayName: string;
|
||||
kind: ShareKind;
|
||||
policy: SharePolicy;
|
||||
targetUserId?: string | null;
|
||||
deletedAt?: string | null;
|
||||
};
|
||||
|
||||
type NoteRecord = {
|
||||
id: string;
|
||||
title: string;
|
||||
key: string;
|
||||
tags: string[];
|
||||
user: string;
|
||||
tasks: string[];
|
||||
};
|
||||
|
||||
type TaskShareRef = {
|
||||
contextId: string;
|
||||
key: string;
|
||||
kind: ShareKind;
|
||||
policy?: SharePolicy;
|
||||
};
|
||||
|
||||
type NoteRef = {
|
||||
noteId: string;
|
||||
key: string;
|
||||
};
|
||||
|
||||
type ParsedTag =
|
||||
| { kind: "share"; raw: string; key: string; display: string }
|
||||
| { kind: "note"; raw: string; key: string; display: string }
|
||||
| { kind: "label"; raw: string; key: string; display: string };
|
||||
|
||||
type TaskRecord = {
|
||||
id: string;
|
||||
title: string;
|
||||
tags?: string[];
|
||||
labelTags?: string[];
|
||||
shareRefs?: TaskShareRef[];
|
||||
noteRefs?: NoteRef[];
|
||||
createdBy?: string | null;
|
||||
user?: string | string[] | null;
|
||||
};
|
||||
|
||||
type Summary = {
|
||||
touched: number;
|
||||
personalRefsAdded: number;
|
||||
personalRefsRemoved: number;
|
||||
ambiguousMultiPersonal: number;
|
||||
failures: number;
|
||||
};
|
||||
|
||||
const mode = (process.argv[2] || "report").toLowerCase();
|
||||
if (!["backup", "report", "migrate"].includes(mode)) {
|
||||
throw new Error("Usage: bun run scripts/context-ownership-migration.ts [backup|report|migrate]");
|
||||
}
|
||||
|
||||
const dataMode = (process.env.TASGRID_DATA_MODE || "prod").toLowerCase() === "dev" ? "dev" : "prod";
|
||||
const withDataSuffix = (base: string) => dataMode === "dev" ? `${base}_Dev` : base;
|
||||
const TASGRID_COLLECTION = withDataSuffix("TasGrid");
|
||||
const CONTEXTS_COLLECTION = withDataSuffix("TasGrid_Contexts");
|
||||
const NOTES_COLLECTION = withDataSuffix("TasGrid_Notes");
|
||||
const NOTE_HANDOFF_POLICY_TAG = "__note_policy_handoff__";
|
||||
|
||||
const POCKETBASE_URL = process.env.POCKETBASE_URL || "https://pocketbase.ccllc.pro";
|
||||
let authToken = "";
|
||||
|
||||
const normalizeWhitespace = (value: string) => value.trim().replace(/\s+/g, " ");
|
||||
const normalizeKey = (value: string) =>
|
||||
normalizeWhitespace(value)
|
||||
.toLowerCase()
|
||||
.replace(/^[@#]+/, "")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
|
||||
const buildShareTag = (displayName: string) => `@${normalizeWhitespace(displayName)}`;
|
||||
const buildNoteTag = (value: string) => `#${normalizeWhitespace(value)}`;
|
||||
const buildUserContextKey = (userId: string) => `user-${userId}`;
|
||||
const unwrapRelationId = (value: unknown): string | null => {
|
||||
if (typeof value === "string") return value || null;
|
||||
if (Array.isArray(value)) {
|
||||
const first = value[0];
|
||||
return typeof first === "string" ? first : null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const parseTag = (rawTag: string): ParsedTag => {
|
||||
const raw = normalizeWhitespace(rawTag);
|
||||
const prefix = raw[0];
|
||||
const display = prefix === "@" || prefix === "#" ? raw.slice(1).trim() : raw;
|
||||
const key = normalizeKey(display);
|
||||
|
||||
if (prefix === "@") return { kind: "share", raw, key, display };
|
||||
if (prefix === "#") return { kind: "note", raw, key, display };
|
||||
return { kind: "label", raw, key, display };
|
||||
};
|
||||
|
||||
const parseTags = (rawTags: string[] | undefined | null) =>
|
||||
(rawTags || []).map(parseTag).filter(tag => !!tag.display);
|
||||
|
||||
const dedupeShareRefs = (refs: TaskShareRef[]) => {
|
||||
const seen = new Set<string>();
|
||||
return refs.filter(ref => {
|
||||
const key = `${ref.kind}:${ref.contextId}:${ref.key}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
const dedupeNoteRefs = (refs: NoteRef[]) => {
|
||||
const seen = new Set<string>();
|
||||
return refs.filter(ref => {
|
||||
const key = `${ref.noteId}:${ref.key}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
const DEFAULT_SHARE_POLICY_BY_KIND: Record<ShareKind, SharePolicy> = {
|
||||
user: "collaborative",
|
||||
bucket: "handoff",
|
||||
note: "handoff"
|
||||
};
|
||||
|
||||
const getNoteSharePolicy = (note: Pick<NoteRecord, "tags"> | undefined): SharePolicy =>
|
||||
note?.tags?.includes(NOTE_HANDOFF_POLICY_TAG) ? "handoff" : "collaborative";
|
||||
|
||||
const relationId = (record: any, field: string) => unwrapRelationId(record?.[field]);
|
||||
|
||||
const createOutputDir = async () => {
|
||||
const outputDir = path.resolve(process.cwd(), "migration-output", `context-ownership-${new Date().toISOString().replace(/[:.]/g, "-")}`);
|
||||
await fs.mkdir(outputDir, { recursive: true });
|
||||
return outputDir;
|
||||
};
|
||||
|
||||
const tryAuthEndpoint = async (pathname: string, email: string, password: string) => {
|
||||
const response = await fetch(`${POCKETBASE_URL}${pathname}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ identity: email, password })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
ok: false as const,
|
||||
status: response.status,
|
||||
body: await response.text(),
|
||||
pathname
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true as const,
|
||||
payload: await response.json(),
|
||||
pathname
|
||||
};
|
||||
};
|
||||
|
||||
const auth = async () => {
|
||||
const email = process.env.PB_ADMIN_EMAIL || process.env.PB_EMAIL;
|
||||
const password = process.env.PB_ADMIN_PASSWORD || process.env.PB_PASSWORD;
|
||||
if (!email || !password) {
|
||||
throw new Error("Set PB_ADMIN_EMAIL/PB_ADMIN_PASSWORD (or PB_EMAIL/PB_PASSWORD) before running this script.");
|
||||
}
|
||||
|
||||
const authAttempts = [
|
||||
await tryAuthEndpoint("/api/collections/_superusers/auth-with-password", email, password),
|
||||
await tryAuthEndpoint("/api/admins/auth-with-password", email, password)
|
||||
];
|
||||
|
||||
const success = authAttempts.find(attempt => attempt.ok);
|
||||
if (!success) {
|
||||
const details = authAttempts
|
||||
.map(attempt => `${attempt.pathname}: ${attempt.status} ${attempt.body}`)
|
||||
.join(" | ");
|
||||
throw new Error(`PocketBase superuser/admin auth failed. ${details}`);
|
||||
}
|
||||
|
||||
const payload = success.payload;
|
||||
authToken = payload.token;
|
||||
};
|
||||
|
||||
const request = async (pathname: string, init: RequestInit = {}) => {
|
||||
const response = await fetch(`${POCKETBASE_URL}${pathname}`, {
|
||||
...init,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(authToken ? { Authorization: authToken } : {}),
|
||||
...(init.headers || {})
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`${response.status} ${response.statusText}: ${await response.text()}`);
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return response.json();
|
||||
};
|
||||
|
||||
const getFullList = async (collection: string, query: Record<string, string> = {}) => {
|
||||
const items: any[] = [];
|
||||
let page = 1;
|
||||
|
||||
while (true) {
|
||||
const search = new URLSearchParams({ perPage: "500", page: String(page), ...query });
|
||||
const payload = await request(`/api/collections/${collection}/records?${search.toString()}`);
|
||||
items.push(...(payload.items || []));
|
||||
if (!payload.totalPages || page >= payload.totalPages) {
|
||||
break;
|
||||
}
|
||||
page += 1;
|
||||
}
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
const createRecord = (collection: string, body: Record<string, unknown>) =>
|
||||
request(`/api/collections/${collection}/records`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
const updateRecord = (collection: string, id: string, body: Record<string, unknown>) =>
|
||||
request(`/api/collections/${collection}/records/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
const syncUserContexts = async (users: any[], contexts: ShareContext[]) => {
|
||||
const nextContexts = [...contexts];
|
||||
for (const user of users) {
|
||||
const displayName = user.name || user.email;
|
||||
const existing = nextContexts.find(context =>
|
||||
context.kind === "user" &&
|
||||
context.targetUserId === user.id &&
|
||||
!context.deletedAt
|
||||
);
|
||||
if (!displayName || existing) continue;
|
||||
|
||||
const created = await createRecord(CONTEXTS_COLLECTION, {
|
||||
key: buildUserContextKey(user.id),
|
||||
displayName,
|
||||
kind: "user",
|
||||
policy: "collaborative",
|
||||
targetUserId: user.id
|
||||
});
|
||||
|
||||
nextContexts.push({
|
||||
id: created.id,
|
||||
key: created.key,
|
||||
displayName: created.displayName,
|
||||
kind: created.kind,
|
||||
policy: "collaborative",
|
||||
targetUserId: relationId(created, "targetUserId"),
|
||||
deletedAt: created.deletedAt || null
|
||||
});
|
||||
}
|
||||
return nextContexts;
|
||||
};
|
||||
|
||||
const buildNoteLinkMap = (notes: NoteRecord[]) => {
|
||||
const links = new Map<string, NoteRef[]>();
|
||||
for (const note of notes) {
|
||||
for (const taskId of note.tasks || []) {
|
||||
const existing = links.get(taskId) || [];
|
||||
existing.push({ noteId: note.id, key: note.key });
|
||||
links.set(taskId, existing);
|
||||
}
|
||||
}
|
||||
return links;
|
||||
};
|
||||
|
||||
const main = async () => {
|
||||
await auth();
|
||||
|
||||
const [users, rawContexts, rawNotes, rawTasks] = await Promise.all([
|
||||
getFullList("users", { fields: "id,name,email" }),
|
||||
getFullList(CONTEXTS_COLLECTION),
|
||||
getFullList(NOTES_COLLECTION),
|
||||
getFullList(TASGRID_COLLECTION)
|
||||
]);
|
||||
|
||||
const contexts = mode === "migrate"
|
||||
? await syncUserContexts(users, rawContexts.map((record: any) => ({
|
||||
id: record.id,
|
||||
key: record.key,
|
||||
displayName: record.displayName,
|
||||
kind: record.kind || "bucket",
|
||||
policy: (record.policy || (record.kind === "user" ? "collaborative" : "handoff")) as SharePolicy,
|
||||
targetUserId: relationId(record, "targetUserId"),
|
||||
deletedAt: record.deletedAt || null
|
||||
})))
|
||||
: rawContexts.map((record: any) => ({
|
||||
id: record.id,
|
||||
key: record.key,
|
||||
displayName: record.displayName,
|
||||
kind: record.kind || "bucket",
|
||||
policy: (record.policy || (record.kind === "user" ? "collaborative" : "handoff")) as SharePolicy,
|
||||
targetUserId: relationId(record, "targetUserId"),
|
||||
deletedAt: record.deletedAt || null
|
||||
}));
|
||||
|
||||
const notes = rawNotes.map((record: any) => ({
|
||||
id: record.id,
|
||||
title: record.title,
|
||||
key: record.key || normalizeKey(record.title || record.id),
|
||||
tags: Array.isArray(record.tags) ? record.tags.filter(Boolean) : [],
|
||||
user: relationId(record, "user") || "",
|
||||
tasks: Array.isArray(record.tasks) ? record.tasks.filter(Boolean) : []
|
||||
})) satisfies NoteRecord[];
|
||||
|
||||
const contextById = new Map(contexts.map(context => [context.id, context]));
|
||||
const ownerContextByUserId = new Map(
|
||||
contexts
|
||||
.filter(context => context.kind === "user" && !!context.targetUserId && !context.deletedAt)
|
||||
.map(context => [context.targetUserId as string, context] as const)
|
||||
);
|
||||
const noteById = new Map(notes.map(note => [note.id, note]));
|
||||
const noteByKey = new Map(notes.map(note => [note.key, note]));
|
||||
const noteLinksByTaskId = buildNoteLinkMap(notes);
|
||||
|
||||
const getContextByRef = (ref: TaskShareRef) => {
|
||||
if (ref.kind === "note") {
|
||||
const note = noteById.get(ref.contextId) || noteByKey.get(ref.key);
|
||||
return note
|
||||
? {
|
||||
id: note.id,
|
||||
key: note.key,
|
||||
displayName: note.key || note.title,
|
||||
kind: "note" as const,
|
||||
policy: getNoteSharePolicy(note),
|
||||
targetUserId: note.user
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
return contextById.get(ref.contextId)
|
||||
|| contexts.find(context => !context.deletedAt && context.key === ref.key && context.kind === ref.kind)
|
||||
|| contexts.find(context => !context.deletedAt && normalizeKey(context.displayName) === ref.key)
|
||||
|| null;
|
||||
};
|
||||
|
||||
const getTaskSharePolicy = (ref: TaskShareRef): SharePolicy => {
|
||||
if (ref.kind === "note") {
|
||||
if (ref.policy) return ref.policy;
|
||||
const note = noteById.get(ref.contextId) || noteByKey.get(ref.key);
|
||||
return getNoteSharePolicy(note);
|
||||
}
|
||||
const context = getContextByRef(ref);
|
||||
if (ref.policy) return ref.policy;
|
||||
return context?.policy || DEFAULT_SHARE_POLICY_BY_KIND[ref.kind];
|
||||
};
|
||||
|
||||
const getPersonalContextUserIdFromRef = (ref: TaskShareRef) => {
|
||||
if (ref.kind !== "user") return null;
|
||||
const context = getContextByRef(ref);
|
||||
if (context?.targetUserId) return context.targetUserId;
|
||||
if (ref.key.startsWith("user-")) return ref.key.replace(/^user-/, "") || null;
|
||||
return null;
|
||||
};
|
||||
|
||||
const getTaskPersonalRefs = (refs: TaskShareRef[]) =>
|
||||
refs.filter(ref => ref.kind === "user" && !!getPersonalContextUserIdFromRef(ref));
|
||||
|
||||
const resolveMirroredOwnerId = (currentOwnerId: string | null, refs: TaskShareRef[], preferredOwnerId?: string | null) => {
|
||||
const personalUserIds = [...new Set(
|
||||
refs
|
||||
.map(ref => getPersonalContextUserIdFromRef(ref))
|
||||
.filter((value): value is string => !!value)
|
||||
)];
|
||||
|
||||
if (personalUserIds.length === 1) return personalUserIds[0];
|
||||
if (personalUserIds.length > 1 && preferredOwnerId && personalUserIds.includes(preferredOwnerId)) return preferredOwnerId;
|
||||
return currentOwnerId || null;
|
||||
};
|
||||
|
||||
const buildTaskTags = (labelTags: string[], shareRefs: TaskShareRef[], noteRefs: NoteRef[], favoriteTag?: string) => {
|
||||
const tags = [
|
||||
...labelTags,
|
||||
...shareRefs.map(ref => {
|
||||
const context = getContextByRef(ref);
|
||||
if (ref.kind === "note") return buildNoteTag(context?.key || ref.key);
|
||||
if (context?.displayName) return buildShareTag(context.displayName);
|
||||
if (ref.kind === "user" && ref.key.startsWith("user-")) return null;
|
||||
return buildShareTag(ref.key);
|
||||
}),
|
||||
...noteRefs.map(ref => {
|
||||
const note = noteById.get(ref.noteId) || noteByKey.get(ref.key);
|
||||
return buildNoteTag(note?.key || ref.key);
|
||||
})
|
||||
];
|
||||
|
||||
if (favoriteTag && !tags.includes(favoriteTag)) tags.push(favoriteTag);
|
||||
return [...new Set(tags.filter((value): value is string => !!value))];
|
||||
};
|
||||
|
||||
const summary: Summary = {
|
||||
touched: 0,
|
||||
personalRefsAdded: 0,
|
||||
personalRefsRemoved: 0,
|
||||
ambiguousMultiPersonal: 0,
|
||||
failures: 0
|
||||
};
|
||||
|
||||
const reportRows: any[] = [];
|
||||
|
||||
for (const record of rawTasks as TaskRecord[]) {
|
||||
if ((record.tags || []).includes("__template__")) continue;
|
||||
|
||||
try {
|
||||
const legacyTags = record.tags || [];
|
||||
const parsedTags = parseTags(legacyTags).filter(tag => tag.raw !== "__template__");
|
||||
const shareRefs = Array.isArray(record.shareRefs)
|
||||
? record.shareRefs.map(ref => ({
|
||||
contextId: ref.contextId,
|
||||
key: ref.key,
|
||||
kind: ref.kind,
|
||||
policy: ref.policy
|
||||
}))
|
||||
: parsedTags
|
||||
.filter((tag): tag is Extract<(typeof parsedTags)[number], { kind: "share" | "note" }> => tag.kind === "share" || tag.kind === "note")
|
||||
.map(tag => {
|
||||
if (tag.kind === "note") {
|
||||
const note = noteByKey.get(tag.key);
|
||||
return {
|
||||
contextId: note?.id || tag.key,
|
||||
key: tag.key,
|
||||
kind: "note" as const
|
||||
};
|
||||
}
|
||||
|
||||
const context = contexts.find(existing =>
|
||||
!existing.deletedAt && (
|
||||
existing.key === tag.key ||
|
||||
normalizeKey(existing.displayName) === tag.key ||
|
||||
buildShareTag(existing.displayName).toLowerCase() === tag.raw.toLowerCase()
|
||||
)
|
||||
);
|
||||
return {
|
||||
contextId: context?.id || tag.key,
|
||||
key: tag.key,
|
||||
kind: (context?.kind || "bucket") as "user" | "bucket",
|
||||
policy: context?.policy
|
||||
};
|
||||
});
|
||||
|
||||
const noteRefs = dedupeNoteRefs([
|
||||
...(Array.isArray(record.noteRefs)
|
||||
? record.noteRefs.map(ref => ({ noteId: ref.noteId, key: ref.key }))
|
||||
: parsedTags
|
||||
.filter(tag => tag.kind === "note")
|
||||
.map(tag => {
|
||||
const note = noteByKey.get(tag.key);
|
||||
return {
|
||||
noteId: note?.id || tag.key,
|
||||
key: tag.key
|
||||
};
|
||||
})),
|
||||
...(noteLinksByTaskId.get(record.id) || [])
|
||||
]);
|
||||
|
||||
let nextShareRefs = dedupeShareRefs([
|
||||
...shareRefs,
|
||||
...noteRefs.map(ref => ({
|
||||
contextId: ref.noteId,
|
||||
key: ref.key,
|
||||
kind: "note" as const,
|
||||
policy: shareRefs.find(existing => existing.kind === "note" && (existing.contextId === ref.noteId || existing.key === ref.key))?.policy
|
||||
}))
|
||||
]);
|
||||
|
||||
const ownerId = unwrapRelationId(record.user);
|
||||
const ownerContext = ownerId ? ownerContextByUserId.get(ownerId) : undefined;
|
||||
const shouldRemoveOwnerPersonalRef = !!ownerId &&
|
||||
getTaskPersonalRefs(nextShareRefs).length === 0 &&
|
||||
nextShareRefs.some(ref => ref.kind !== "user" && getTaskSharePolicy(ref) === "handoff");
|
||||
|
||||
if (ownerContext) {
|
||||
const matchesOwnerContext = (ref: TaskShareRef) =>
|
||||
ref.kind === "user" && (
|
||||
ref.contextId === ownerContext.id ||
|
||||
ref.key === ownerContext.key ||
|
||||
normalizeKey(ownerContext.displayName) === ref.key
|
||||
);
|
||||
|
||||
if (shouldRemoveOwnerPersonalRef) {
|
||||
const before = nextShareRefs.length;
|
||||
nextShareRefs = nextShareRefs.filter(ref => !matchesOwnerContext(ref));
|
||||
if (before !== nextShareRefs.length) {
|
||||
summary.personalRefsRemoved += before - nextShareRefs.length;
|
||||
}
|
||||
} else if (!nextShareRefs.some(matchesOwnerContext)) {
|
||||
nextShareRefs = [
|
||||
{
|
||||
contextId: ownerContext.id,
|
||||
key: ownerContext.key,
|
||||
kind: "user" as const,
|
||||
policy: ownerContext.policy
|
||||
},
|
||||
...nextShareRefs
|
||||
];
|
||||
summary.personalRefsAdded += 1;
|
||||
}
|
||||
}
|
||||
|
||||
nextShareRefs = dedupeShareRefs(nextShareRefs);
|
||||
|
||||
const labelTags = Array.isArray(record.labelTags)
|
||||
? record.labelTags
|
||||
: parsedTags.filter(tag => tag.kind === "label").map(tag => tag.display);
|
||||
const favoriteTag = legacyTags.find(tag => tag.endsWith("_favorite__"));
|
||||
const nextTags = buildTaskTags(labelTags, nextShareRefs, noteRefs, favoriteTag);
|
||||
const nextOwnerId = resolveMirroredOwnerId(ownerId, nextShareRefs, ownerId);
|
||||
const personalUserIds = [...new Set(nextShareRefs.map(ref => getPersonalContextUserIdFromRef(ref)).filter((value): value is string => !!value))];
|
||||
if (personalUserIds.length > 1) {
|
||||
summary.ambiguousMultiPersonal += 1;
|
||||
}
|
||||
|
||||
const changed =
|
||||
JSON.stringify(nextShareRefs) !== JSON.stringify(record.shareRefs || []) ||
|
||||
JSON.stringify(noteRefs) !== JSON.stringify(record.noteRefs || []) ||
|
||||
JSON.stringify(nextTags) !== JSON.stringify(record.tags || []) ||
|
||||
nextOwnerId !== ownerId;
|
||||
|
||||
if (changed) {
|
||||
summary.touched += 1;
|
||||
reportRows.push({
|
||||
id: record.id,
|
||||
title: record.title,
|
||||
ownerIdBefore: ownerId,
|
||||
ownerIdAfter: nextOwnerId,
|
||||
personalUserIds
|
||||
});
|
||||
|
||||
if (mode === "migrate") {
|
||||
await updateRecord(TASGRID_COLLECTION, record.id, {
|
||||
user: nextOwnerId,
|
||||
tags: nextTags,
|
||||
labelTags,
|
||||
shareRefs: nextShareRefs,
|
||||
noteRefs
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
summary.failures += 1;
|
||||
reportRows.push({
|
||||
id: record.id,
|
||||
title: record.title,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === "backup") {
|
||||
const outputDir = await createOutputDir();
|
||||
await Promise.all([
|
||||
fs.writeFile(path.join(outputDir, "users.json"), JSON.stringify(users, null, 2)),
|
||||
fs.writeFile(path.join(outputDir, "contexts.json"), JSON.stringify(contexts, null, 2)),
|
||||
fs.writeFile(path.join(outputDir, "notes.json"), JSON.stringify(rawNotes, null, 2)),
|
||||
fs.writeFile(path.join(outputDir, "tasks.json"), JSON.stringify(rawTasks, null, 2))
|
||||
]);
|
||||
console.log(JSON.stringify({ mode, dataMode, outputDir, counts: { users: users.length, contexts: contexts.length, notes: rawNotes.length, tasks: rawTasks.length } }, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === "report") {
|
||||
const outputDir = await createOutputDir();
|
||||
await fs.writeFile(path.join(outputDir, "context-ownership-report.json"), JSON.stringify({ summary, tasks: reportRows }, null, 2));
|
||||
console.log(JSON.stringify({ mode, dataMode, outputDir, summary }, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({ mode, dataMode, summary }, null, 2));
|
||||
};
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user