3.13 Tag Manager ManicTime sync bidirectional sync, backup, hierarchy validation
- ManicTime parser service: parse/serialize Tags.txt format, classify lines into project/phase/activity - API route /api/manictime: GET (read + sync plan), POST (pull/push/both with backup versioning) - ManicTimeSyncPanel component: connection check, stats grid, import/export/full sync with confirmation dialog - Integrated into Tag Manager module with live sync status - Docker: MANICTIME_TAGS_PATH env var, SMB volume mount /mnt/manictime - Hierarchy validation: project codes, company association, duplicate detection - Version bump to 0.2.0
This commit is contained in:
@@ -0,0 +1,230 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
import {
|
||||
parseManicTimeFile,
|
||||
serializeToManicTime,
|
||||
computeSyncPlan,
|
||||
generateBackupFilename,
|
||||
validateHierarchy,
|
||||
manicTimeTagToCreateData,
|
||||
} from "@/modules/tag-manager/services/manictime-service";
|
||||
import type { Tag } from "@/core/tagging/types";
|
||||
import { prisma } from "@/core/storage/prisma";import type { Prisma } from '@prisma/client';
|
||||
const NAMESPACE = "tags";
|
||||
|
||||
/**
|
||||
* Get the ManicTime Tags.txt file path from environment.
|
||||
* Default: /mnt/manictime/Tags.txt (Docker SMB mount)
|
||||
*/
|
||||
function getTagsFilePath(): string {
|
||||
return process.env.MANICTIME_TAGS_PATH ?? "/mnt/manictime/Tags.txt";
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all tags from the database (same namespace as TagService).
|
||||
*/
|
||||
async function loadArchiToolsTags(): Promise<Tag[]> {
|
||||
const records = await prisma.keyValueStore.findMany({
|
||||
where: { namespace: NAMESPACE },
|
||||
});
|
||||
return records.map((r) => r.value as unknown as Tag);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/manictime
|
||||
* Read and parse the ManicTime Tags.txt file.
|
||||
* Returns parsed tags + sync plan against ArchiTools.
|
||||
*/
|
||||
export async function GET() {
|
||||
const filePath = getTagsFilePath();
|
||||
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "file_not_found",
|
||||
message: `Fișierul Tags.txt nu a fost găsit la: ${filePath}. Verificați montarea SMB și variabila MANICTIME_TAGS_PATH.`,
|
||||
filePath,
|
||||
},
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(filePath, "utf-8");
|
||||
const parsed = parseManicTimeFile(content);
|
||||
const archiToolsTags = await loadArchiToolsTags();
|
||||
const syncPlan = computeSyncPlan(parsed.tags, archiToolsTags);
|
||||
|
||||
return NextResponse.json({
|
||||
filePath,
|
||||
lastModified: (await fs.stat(filePath)).mtime.toISOString(),
|
||||
totalLines: parsed.tags.length,
|
||||
groups: parsed.groups.length,
|
||||
syncPlan: {
|
||||
addToArchiTools: syncPlan.addedToArchiTools.length,
|
||||
addToManicTime: syncPlan.addedToManicTime.length,
|
||||
unchanged: syncPlan.unchanged,
|
||||
conflicts: syncPlan.conflicts,
|
||||
},
|
||||
manicTimeTags: parsed.tags,
|
||||
archiToolsCount: archiToolsTags.length,
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "read_error",
|
||||
message: `Eroare la citirea fișierului: ${error instanceof Error ? error.message : "necunoscută"}`,
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/manictime
|
||||
* Perform bidirectional sync.
|
||||
*
|
||||
* Body: { direction: 'pull' | 'push' | 'both' }
|
||||
* - pull: Import new tags from ManicTime → ArchiTools
|
||||
* - push: Export ArchiTools tags → ManicTime Tags.txt (with backup)
|
||||
* - both: Pull then push
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
const filePath = getTagsFilePath();
|
||||
|
||||
let body: { direction?: string } = {};
|
||||
try {
|
||||
body = (await request.json()) as { direction?: string };
|
||||
} catch {
|
||||
return NextResponse.json({ error: "invalid_json" }, { status: 400 });
|
||||
}
|
||||
|
||||
const direction = body.direction ?? "both";
|
||||
if (!["pull", "push", "both"].includes(direction)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "invalid_direction",
|
||||
message: "Direction must be pull, push, or both",
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const archiToolsTags = await loadArchiToolsTags();
|
||||
let pulled = 0;
|
||||
let pushed = false;
|
||||
|
||||
// ── Pull: ManicTime → ArchiTools ──
|
||||
if (direction === "pull" || direction === "both") {
|
||||
let content: string;
|
||||
try {
|
||||
content = await fs.readFile(filePath, "utf-8");
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "file_not_found",
|
||||
message: `Fișierul Tags.txt nu a fost găsit la: ${filePath}`,
|
||||
},
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = parseManicTimeFile(content);
|
||||
const syncPlan = computeSyncPlan(parsed.tags, archiToolsTags);
|
||||
|
||||
// Import new tags into ArchiTools DB
|
||||
const existingKeys = new Set(
|
||||
archiToolsTags.map((t) => `${t.category}::${t.label.toLowerCase()}`),
|
||||
);
|
||||
|
||||
for (const mt of syncPlan.addedToArchiTools) {
|
||||
const data = manicTimeTagToCreateData(mt);
|
||||
if (!data) continue;
|
||||
|
||||
const key = `${data.category}::${data.label.toLowerCase()}`;
|
||||
if (existingKeys.has(key)) continue;
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const tag: Tag = {
|
||||
...data,
|
||||
id,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const tagJson = JSON.parse(JSON.stringify(tag)) as Prisma.InputJsonValue;
|
||||
|
||||
await prisma.keyValueStore.upsert({
|
||||
where: { namespace_key: { namespace: NAMESPACE, key: id } },
|
||||
update: { value: tagJson },
|
||||
create: {
|
||||
namespace: NAMESPACE,
|
||||
key: id,
|
||||
value: tagJson,
|
||||
},
|
||||
});
|
||||
|
||||
existingKeys.add(key);
|
||||
pulled++;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Push: ArchiTools → ManicTime ──
|
||||
if (direction === "push" || direction === "both") {
|
||||
// Re-load to include any newly pulled tags
|
||||
const allTags = await loadArchiToolsTags();
|
||||
|
||||
// Validate hierarchy before writing
|
||||
const warnings = validateHierarchy(allTags);
|
||||
|
||||
// Create backup if file exists
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
const backupPath = generateBackupFilename(filePath);
|
||||
await fs.copyFile(filePath, backupPath);
|
||||
} catch {
|
||||
// File doesn't exist yet, no backup needed
|
||||
}
|
||||
|
||||
// Ensure directory exists
|
||||
const dir = path.dirname(filePath);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
|
||||
// Write new content
|
||||
const newContent = serializeToManicTime(allTags);
|
||||
await fs.writeFile(filePath, newContent, "utf-8");
|
||||
pushed = true;
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
direction,
|
||||
pulled,
|
||||
pushed,
|
||||
totalTags: allTags.length,
|
||||
warnings,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
direction,
|
||||
pulled,
|
||||
pushed,
|
||||
totalTags: archiToolsTags.length + pulled,
|
||||
warnings: [],
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "sync_error",
|
||||
message: `Eroare la sincronizare: ${error instanceof Error ? error.message : "necunoscută"}`,
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user