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:
AI Assistant
2026-02-28 04:38:57 +02:00
parent 99fbdddb68
commit 11b35c750f
8 changed files with 1054 additions and 29 deletions
+14 -11
View File
@@ -272,19 +272,22 @@
5. **Responsabil (Assignee)** — New field with contact autocomplete + quick-create. ERP-ready with separate assigneeContactId. Shown in registry table as "Resp." column. 5. **Responsabil (Assignee)** — New field with contact autocomplete + quick-create. ERP-ready with separate assigneeContactId. Shown in registry table as "Resp." column.
6. **Threads & Branching**`threadParentId` field links entries as reply-to. Thread search with direction badges. ThreadView component shows parent, current entry, siblings (branches), and child replies as indented tree. Thread icon in table. Click to navigate between threaded entries. 6. **Threads & Branching**`threadParentId` field links entries as reply-to. Thread search with direction badges. ThreadView component shows parent, current entry, siblings (branches), and child replies as indented tree. Thread icon in table. Click to navigate between threaded entries.
### 3.03 `[BUSINESS]` Registratura — Termene Legale (Flux Nou) ### 3.03 `[BUSINESS]` Registratura — Termene Legale (Flux Nou) (99fbddd)
**Cerințe noi:** **Implementat:**
- **Declanșare Termen:** Termenul legal pentru ieșiri curge DOAR de la data înregistrării la destinatar, nu de la data trimiterii interne. - **Declanșare Termen:** Câmpuri `recipientRegNumber` + `recipientRegDate` pe RegistryEntry — termenul legal curge de la data înregistrării la destinatar
- **Câmpuri Noi:** "Număr înregistrare destinatar" și "Data înregistrare destinatar". - **Sistem de Alerte:** Alert banners (amber/red) în Dashboard + tab Termene legale pentru ieșiri fără date destinatar și documente expirând
- **Sistem de Alerte:** Notificări/Atenționări clare (în Dashboard și în modul) pentru ieșirile cu termen legal care NU au încă completate datele de la destinatar. - **Categorii Termene:** CU deja pe prima categorie; adăugat `prelungire-cu` (15 zile calendaristice, acord tacit aplicabil)
- **Categorii Termene:** Mutarea "Certificat de Urbanism" (CU) pe prima pagină/categorie principală (nu sub Avize). Adăugare subcategorie: "Cerere de prelungire CU". - **Acord Tacit:** Deja implementat în Phase 2 — funcționează automat
- **Acord Tacit:** Modificare automată a statusului pentru documentele fără răspuns în termenul legal (acord tacit). - **Istoric Modificări (Audit Log):** `DeadlineAuditEntry` interface, audit log pe fiecare `TrackedDeadline` (created/resolved), expandabil pe deadline card
- **Generare Raport/Declarație (Integrare Word Templates):** Generarea unui mini-log/raport combinat cu o "Declarație pe proprie răspundere" a proiectantului. Se va folosi un șablon din modulul _Word Templates_, populat automat cu datele din Registratură (data trimiterii, lipsa răspunsului), permițând editarea ulterioară în Word. - **Valabilitate Documente:** `expiryDate` + `expiryAlertDays` cu countdown live color-coded (red=expirat, amber=aproape)
- **Istoric Modificări Termene (Audit Log):** Un mini-log de audit vizibil pe fiecare termen legal (cine a modificat data, când a fost adăugat numărul de la destinatar). _Notă: Necesită implementarea autentificării (Phase 6 - Authentik) pentru a asocia acțiunile cu utilizatori reali din Active Directory._ - **Pregătire Web Scraping:** Câmpuri `externalStatusUrl` + `externalTrackingId` pe RegistryEntry
- **Valabilitate Documente (Expirare CU/AC):** Adăugarea unui sistem de urmărire a valabilității pentru documente emise (ex: Certificat de Urbanism, Autorizație de Construire). Sistemul trebuie să permită setarea unei date de expirare și să genereze alerte/reamintiri cu X zile înainte de expirare (pentru a iniția procedurile de prelungire). - **Dashboard Stats:** 6 carduri (adăugat "Lipsă nr. dest." + "Expiră curând")
- **Pregătire Web Scraping (Wishlist):** Adăugarea unui câmp opțional "URL Verificare Status" (ex: link către portalul primăriei) și "ID Urmărire Extern". Arhitectura trebuie să permită pe viitor rularea unui job de fundal (ex: via N8N sau un worker intern) care să facă scraping/API call pe acel URL și să actualizeze automat statusul în ArchiTools.
**Neimplementat (necesită integrare complexă):**
-**Generare Raport/Declarație:** Necesită integrare cross-module Registratura ↔ Word Templates
### 3.04 ✅ `[ARCHITECTURE]` Autentificare & Identitate (2026-02-27) ### 3.04 ✅ `[ARCHITECTURE]` Autentificare & Identitate (2026-02-27)
+5
View File
@@ -31,5 +31,10 @@ services:
- AUTHENTIK_ISSUER=https://auth.beletage.ro/application/o/architools/ - AUTHENTIK_ISSUER=https://auth.beletage.ro/application/o/architools/
# Vault encryption # Vault encryption
- ENCRYPTION_SECRET=ArchiTools-Vault-2025!SecureKey@AES256 - ENCRYPTION_SECRET=ArchiTools-Vault-2025!SecureKey@AES256
# ManicTime Tags.txt sync (SMB mount path)
- MANICTIME_TAGS_PATH=/mnt/manictime/Tags.txt
volumes:
# SMB share for ManicTime Tags.txt (mount on host: //time/tags → /mnt/manictime)
- /mnt/manictime:/mnt/manictime
labels: labels:
- "com.centurylinklabs.watchtower.enable=true" - "com.centurylinklabs.watchtower.enable=true"
+230
View File
@@ -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 },
);
}
}
@@ -0,0 +1,356 @@
"use client";
import { useState, useCallback } from "react";
import {
RefreshCw,
Download,
Upload,
ArrowLeftRight,
CheckCircle2,
XCircle,
AlertTriangle,
Clock,
FileText,
} from "lucide-react";
import { Button } from "@/shared/components/ui/button";
import { Badge } from "@/shared/components/ui/badge";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/shared/components/ui/dialog";
interface SyncStatus {
filePath: string;
lastModified: string;
totalLines: number;
groups: number;
syncPlan: {
addToArchiTools: number;
addToManicTime: number;
unchanged: number;
conflicts: string[];
};
archiToolsCount: number;
}
interface SyncResultData {
success: boolean;
direction: string;
pulled: number;
pushed: boolean;
totalTags: number;
warnings: string[];
timestamp: string;
}
export function ManicTimeSyncPanel({
onSyncComplete,
}: {
onSyncComplete: () => void;
}) {
const [status, setStatus] = useState<SyncStatus | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [syncing, setSyncing] = useState(false);
const [syncResult, setSyncResult] = useState<SyncResultData | null>(null);
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const [pendingDirection, setPendingDirection] = useState<string>("");
const checkStatus = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch("/api/manictime");
if (!res.ok) {
const data = (await res.json()) as { message?: string };
setError(data.message ?? `Eroare HTTP ${res.status}`);
setStatus(null);
return;
}
const data = (await res.json()) as SyncStatus;
setStatus(data);
} catch (err) {
setError(
`Eroare de conexiune: ${err instanceof Error ? err.message : "necunoscută"}`,
);
} finally {
setLoading(false);
}
}, []);
const executeSync = useCallback(
async (direction: string) => {
setSyncing(true);
setSyncResult(null);
setError(null);
try {
const res = await fetch("/api/manictime", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ direction }),
});
if (!res.ok) {
const data = (await res.json()) as { message?: string };
setError(data.message ?? `Eroare HTTP ${res.status}`);
return;
}
const data = (await res.json()) as SyncResultData;
setSyncResult(data);
onSyncComplete();
// Refresh status
await checkStatus();
} catch (err) {
setError(
`Eroare de conexiune: ${err instanceof Error ? err.message : "necunoscută"}`,
);
} finally {
setSyncing(false);
setShowConfirmDialog(false);
}
},
[checkStatus, onSyncComplete],
);
const handleSync = (direction: string) => {
setPendingDirection(direction);
setShowConfirmDialog(true);
};
return (
<>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<FileText className="h-4 w-4" />
Sincronizare ManicTime
{status && (
<Badge variant="outline" className="ml-auto text-xs">
<Clock className="mr-1 h-3 w-3" />
{new Date(status.lastModified).toLocaleString("ro-RO")}
</Badge>
)}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Check status button */}
<div className="flex flex-wrap items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={checkStatus}
disabled={loading}
>
<RefreshCw
className={`mr-1.5 h-3.5 w-3.5 ${loading ? "animate-spin" : ""}`}
/>
{loading
? "Se verifică..."
: status
? "Reîmprospătează"
: "Verifică conexiunea"}
</Button>
{status && (
<>
<Button
variant="outline"
size="sm"
onClick={() => handleSync("pull")}
disabled={syncing}
>
<Download className="mr-1.5 h-3.5 w-3.5" />
Import din ManicTime
{status.syncPlan.addToArchiTools > 0 && (
<Badge variant="secondary" className="ml-1.5">
+{status.syncPlan.addToArchiTools}
</Badge>
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleSync("push")}
disabled={syncing}
>
<Upload className="mr-1.5 h-3.5 w-3.5" />
Export în ManicTime
{status.syncPlan.addToManicTime > 0 && (
<Badge variant="secondary" className="ml-1.5">
+{status.syncPlan.addToManicTime}
</Badge>
)}
</Button>
<Button
size="sm"
onClick={() => handleSync("both")}
disabled={syncing}
>
<ArrowLeftRight className="mr-1.5 h-3.5 w-3.5" />
{syncing ? "Se sincronizează..." : "Sincronizare completă"}
</Button>
</>
)}
</div>
{/* Error */}
{error && (
<div className="flex items-start gap-2 rounded-md border border-destructive/50 bg-destructive/5 p-3">
<XCircle className="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
<p className="text-sm text-destructive">{error}</p>
</div>
)}
{/* Status info */}
{status && (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div className="rounded-md border p-2.5 text-center">
<p className="text-xs text-muted-foreground">
Etichete ManicTime
</p>
<p className="text-lg font-bold">{status.totalLines}</p>
</div>
<div className="rounded-md border p-2.5 text-center">
<p className="text-xs text-muted-foreground">
Etichete ArchiTools
</p>
<p className="text-lg font-bold">{status.archiToolsCount}</p>
</div>
<div className="rounded-md border p-2.5 text-center">
<p className="text-xs text-muted-foreground">
Noi din ManicTime
</p>
<p className="text-lg font-bold text-blue-600">
{status.syncPlan.addToArchiTools}
</p>
</div>
<div className="rounded-md border p-2.5 text-center">
<p className="text-xs text-muted-foreground">
Noi din ArchiTools
</p>
<p className="text-lg font-bold text-violet-600">
{status.syncPlan.addToManicTime}
</p>
</div>
</div>
)}
{/* Sync result */}
{syncResult && (
<div className="flex items-start gap-2 rounded-md border border-green-200 bg-green-50 p-3 dark:border-green-800 dark:bg-green-950/30">
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-green-600" />
<div className="space-y-1">
<p className="text-sm font-medium text-green-800 dark:text-green-200">
Sincronizare completă
</p>
<p className="text-xs text-green-700 dark:text-green-300">
{syncResult.pulled > 0 &&
`${syncResult.pulled} etichete importate din ManicTime. `}
{syncResult.pushed && "Fișierul Tags.txt a fost actualizat. "}
Total: {syncResult.totalTags} etichete.
</p>
{syncResult.warnings.length > 0 && (
<div className="mt-1 space-y-0.5">
{syncResult.warnings.map((w, i) => (
<p
key={i}
className="flex items-center gap-1 text-xs text-amber-700 dark:text-amber-300"
>
<AlertTriangle className="h-3 w-3" /> {w}
</p>
))}
</div>
)}
</div>
</div>
)}
{/* Hint when no check performed */}
{!status && !error && !loading && (
<p className="text-xs text-muted-foreground">
Apasă &ldquo;Verifică conexiunea&rdquo; pentru a vedea starea
fișierului ManicTime Tags.txt. Fișierul este citit de pe share-ul
SMB montat la{" "}
<code className="rounded bg-muted px-1 text-[11px]">
/mnt/manictime/Tags.txt
</code>
. Configurați calea prin variabila{" "}
<code className="rounded bg-muted px-1 text-[11px]">
MANICTIME_TAGS_PATH
</code>
.
</p>
)}
</CardContent>
</Card>
{/* Confirmation dialog */}
<Dialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{pendingDirection === "pull" && "Import din ManicTime"}
{pendingDirection === "push" && "Export în ManicTime"}
{pendingDirection === "both" && "Sincronizare completă"}
</DialogTitle>
</DialogHeader>
<div className="space-y-3 py-2">
{pendingDirection === "pull" && (
<p className="text-sm text-muted-foreground">
Se vor importa{" "}
<strong>
{status?.syncPlan.addToArchiTools ?? 0} etichete noi
</strong>{" "}
din fișierul ManicTime în ArchiTools. Etichetele existente nu
vor fi modificate.
</p>
)}
{pendingDirection === "push" && (
<p className="text-sm text-muted-foreground">
Se va rescrie fișierul Tags.txt cu toate etichetele din
ArchiTools. Un backup al versiunii curente va fi creat automat.
ManicTime va primi{" "}
<strong>
{status?.syncPlan.addToManicTime ?? 0} etichete noi
</strong>
.
</p>
)}
{pendingDirection === "both" && (
<p className="text-sm text-muted-foreground">
Se vor importa mai întâi etichetele noi din ManicTime (
<strong>{status?.syncPlan.addToArchiTools ?? 0}</strong>), apoi
se va actualiza fișierul Tags.txt cu toate etichetele din
ArchiTools. Backup automat inclus.
</p>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowConfirmDialog(false)}
>
Anulează
</Button>
<Button
onClick={() => executeSync(pendingDirection)}
disabled={syncing}
>
{syncing ? "Se sincronizează..." : "Confirmă"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
@@ -44,6 +44,7 @@ import { TAG_CATEGORY_ORDER, TAG_CATEGORY_LABELS } from "@/core/tagging/types";
import type { CompanyId } from "@/core/auth/types"; import type { CompanyId } from "@/core/auth/types";
import { cn } from "@/shared/lib/utils"; import { cn } from "@/shared/lib/utils";
import { getManicTimeSeedTags } from "../services/seed-data"; import { getManicTimeSeedTags } from "../services/seed-data";
import { ManicTimeSyncPanel } from "./manictime-sync-panel";
const SCOPE_LABELS: Record<TagScope, string> = { const SCOPE_LABELS: Record<TagScope, string> = {
global: "Global", global: "Global",
@@ -74,8 +75,15 @@ const TAG_COLORS = [
]; ];
export function TagManagerModule() { export function TagManagerModule() {
const { tags, loading, createTag, updateTag, deleteTag, importTags } = const {
useTags(); tags,
loading,
createTag,
updateTag,
deleteTag,
importTags,
refresh,
} = useTags();
// ── Create form state ── // ── Create form state ──
const [newLabel, setNewLabel] = useState(""); const [newLabel, setNewLabel] = useState("");
@@ -271,6 +279,9 @@ export function TagManagerModule() {
))} ))}
</div> </div>
{/* ManicTime Sync Panel */}
<ManicTimeSyncPanel onSyncComplete={refresh} />
{/* Seed import banner */} {/* Seed import banner */}
{tags.length === 0 && !loading && ( {tags.length === 0 && !loading && (
<Card className="border-dashed border-2"> <Card className="border-dashed border-2">
+13 -12
View File
@@ -1,17 +1,18 @@
import type { ModuleConfig } from '@/core/module-registry/types'; import type { ModuleConfig } from "@/core/module-registry/types";
export const tagManagerConfig: ModuleConfig = { export const tagManagerConfig: ModuleConfig = {
id: 'tag-manager', id: "tag-manager",
name: 'Manager Etichete', name: "Manager Etichete",
description: 'Administrare centralizată a etichetelor și categoriilor din platformă', description:
icon: 'tags', "Administrare centralizată a etichetelor, sincronizare ManicTime, validare ierarhie",
route: '/tag-manager', icon: "tags",
category: 'tools', route: "/tag-manager",
featureFlag: 'module.tag-manager', category: "tools",
visibility: 'all', featureFlag: "module.tag-manager",
version: '0.1.0', visibility: "all",
version: "0.2.0",
dependencies: [], dependencies: [],
storageNamespace: 'tag-manager', storageNamespace: "tag-manager",
navOrder: 40, navOrder: 40,
tags: ['etichete', 'categorii', 'organizare'], tags: ["etichete", "categorii", "organizare", "manictime", "sincronizare"],
}; };
+5 -4
View File
@@ -1,4 +1,5 @@
export { tagManagerConfig } from './config'; export { tagManagerConfig } from "./config";
export { TagManagerModule } from './components/tag-manager-module'; export { TagManagerModule } from "./components/tag-manager-module";
export type { Tag, TagCategory, TagScope } from './types'; export { ManicTimeSyncPanel } from "./components/manictime-sync-panel";
export { TAG_CATEGORY_ORDER, TAG_CATEGORY_LABELS } from './types'; export type { Tag, TagCategory, TagScope } from "./types";
export { TAG_CATEGORY_ORDER, TAG_CATEGORY_LABELS } from "./types";
@@ -0,0 +1,418 @@
/**
* ManicTime Tags.txt Parser & Serializer
*
* The Tags.txt format is a plain-text file with one tag per line.
* Blank lines separate groups (categories). The parser maps groups
* to ArchiTools tag categories based on pattern recognition:
*
* - Lines matching `\d+ .+` or `L\d+ .+` → project tags
* - Known phase names (CU, Schita, DTAC, etc.) → phase tags
* - Known activity names (Ofertare, Documentare, etc.) → activity tags
* - Other → custom tags
*/
import type { Tag, TagCategory } from "@/core/tagging/types";
import type { CompanyId } from "@/core/auth/types";
// ── Known phase labels (case-insensitive match) ──
const KNOWN_PHASES = new Set([
"cu",
"schita",
"avize",
"pud",
"ao",
"puz",
"pug",
"dtad",
"dtac",
"pt",
"detalii de executie",
"studii de fundamentare",
"regulament",
"parte desenata",
"parte scrisa",
"consultanta client",
"macheta",
"consultanta receptie",
"redactare",
"depunere",
"ridicare",
"verificare proiect",
"vizita santier",
]);
// ── Known activity labels ──
const KNOWN_ACTIVITIES = new Set([
"ofertare",
"configurari",
"organizare initiala",
"pregatire portofoliu",
"website",
"documentare",
"design grafic",
"design interior",
"design exterior",
"releveu",
"reclama",
"master matdr",
"pauza de masa",
"timp personal",
"concediu",
"compensare overtime",
]);
// ── Company identifiers that appear as group headers ──
const COMPANY_HEADERS = new Set([
"beletage",
"urban switch",
"studii de teren",
]);
// ── Project line pattern: optional letter prefix + digits + space + name ──
const PROJECT_LINE_RE = /^(\w?\d+)\s+(.+)$/;
// Special pattern for "176 - 2025 - ReAC Ansamblu rezi Bibescu" style
const PROJECT_LINE_EXTENDED_RE = /^(\d+)\s+-\s+.+$/;
export interface ManicTimeTag {
line: string;
category: TagCategory | "header" | "unknown";
projectCode?: string;
label: string;
}
export interface ParsedManicTimeFile {
groups: string[][];
tags: ManicTimeTag[];
rawContent: string;
}
export interface SyncResult {
addedToArchiTools: ManicTimeTag[];
addedToManicTime: Tag[];
unchanged: number;
conflicts: string[];
}
/**
* Parse a ManicTime Tags.txt file into structured data.
*/
export function parseManicTimeFile(content: string): ParsedManicTimeFile {
const lines = content.split(/\r?\n/);
const groups: string[][] = [];
let currentGroup: string[] = [];
for (const line of lines) {
if (line.trim() === "") {
if (currentGroup.length > 0) {
groups.push(currentGroup);
currentGroup = [];
}
} else {
currentGroup.push(line.trim());
}
}
if (currentGroup.length > 0) {
groups.push(currentGroup);
}
const tags: ManicTimeTag[] = [];
for (const group of groups) {
for (const line of group) {
tags.push(classifyLine(line));
}
}
return { groups, tags, rawContent: content };
}
/**
* Classify a single line from Tags.txt.
*/
function classifyLine(line: string): ManicTimeTag {
const trimmed = line.trim();
const lower = trimmed.toLowerCase();
// Company header
if (COMPANY_HEADERS.has(lower)) {
return { line: trimmed, category: "header", label: trimmed };
}
// Project line (e.g. "000 Farmacie", "L089 PUZ TUSA-BOJAN", "176 - 2025 - ReAC...")
const projectMatch = trimmed.match(PROJECT_LINE_RE);
if (projectMatch?.[1] && projectMatch[2]) {
const num = projectMatch[1];
const label = projectMatch[2].trim();
const padded = num.replace(/^[A-Z]/i, "").padStart(3, "0");
const prefix = num.match(/^[A-Z]/i) ? `B${num.charAt(0)}` : "B";
return {
line: trimmed,
category: "project",
projectCode: `${prefix}-${padded}`,
label,
};
}
if (PROJECT_LINE_EXTENDED_RE.test(trimmed)) {
return {
line: trimmed,
category: "project",
projectCode: `B-${trimmed.split(/\s/)[0]?.padStart(3, "0") ?? "000"}`,
label: trimmed,
};
}
// Phase
if (KNOWN_PHASES.has(lower)) {
return { line: trimmed, category: "phase", label: trimmed };
}
// Activity
if (KNOWN_ACTIVITIES.has(lower)) {
return { line: trimmed, category: "activity", label: trimmed };
}
// Unknown / custom
return { line: trimmed, category: "unknown", label: trimmed };
}
/**
* Convert ArchiTools tags back to ManicTime Tags.txt format.
* Groups: activities, projects (by company), phases, custom.
*/
export function serializeToManicTime(tags: Tag[]): string {
const lines: string[] = [];
// ── Group 1: General activities (personal, leave) ──
const personalActivities = ["Pauza de masa", "Timp personal"];
const leaveActivities = ["Concediu", "Compensare overtime"];
const personalFound = tags.filter((t) =>
personalActivities.some((a) => t.label.toLowerCase() === a.toLowerCase()),
);
if (personalFound.length > 0) {
for (const t of personalFound) lines.push(t.label);
lines.push("");
}
const leaveFound = tags.filter((t) =>
leaveActivities.some((a) => t.label.toLowerCase() === a.toLowerCase()),
);
if (leaveFound.length > 0) {
for (const t of leaveFound) lines.push(t.label);
lines.push("");
}
// ── Group 2: Company activities ──
const companyActivities = tags.filter(
(t) =>
t.category === "activity" &&
!personalActivities.some(
(a) => t.label.toLowerCase() === a.toLowerCase(),
) &&
!leaveActivities.some((a) => t.label.toLowerCase() === a.toLowerCase()),
);
if (companyActivities.length > 0) {
lines.push("Beletage");
for (const t of companyActivities) lines.push(t.label);
lines.push("");
}
// ── Group 3: Projects by company ──
const companies: CompanyId[] = [
"beletage",
"urban-switch",
"studii-de-teren",
];
for (const company of companies) {
const companyProjects = tags
.filter((t) => t.category === "project" && t.companyId === company)
.sort((a, b) => (a.projectCode ?? "").localeCompare(b.projectCode ?? ""));
if (companyProjects.length > 0) {
for (const t of companyProjects) {
const code = t.projectCode ?? "";
// Extract numeric part from project code like "B-000" → "000"
const numMatch = code.match(/\d+$/);
const num = numMatch?.[0] ?? "000";
lines.push(`${num} ${t.label}`);
}
lines.push("");
}
}
// ── Group 4: Phases ──
const phases = tags.filter((t) => t.category === "phase");
if (phases.length > 0) {
for (const t of phases) lines.push(t.label);
lines.push("");
}
// ── Group 5: Document types ──
const docTypes = tags.filter((t) => t.category === "document-type");
if (docTypes.length > 0) {
for (const t of docTypes) lines.push(t.label);
lines.push("");
}
// ── Group 6: Custom ──
const custom = tags.filter((t) => t.category === "custom");
if (custom.length > 0) {
for (const t of custom) lines.push(t.label);
lines.push("");
}
return lines.join("\n").trimEnd() + "\n";
}
/**
* Compare ManicTime file content with ArchiTools tags and produce a sync plan.
*/
export function computeSyncPlan(
manicTimeTags: ManicTimeTag[],
archiToolsTags: Tag[],
): SyncResult {
const result: SyncResult = {
addedToArchiTools: [],
addedToManicTime: [],
unchanged: 0,
conflicts: [],
};
// Build a normalized-label set from ArchiTools
const archiLabels = new Set(
archiToolsTags.map((t) => t.label.toLowerCase().trim()),
);
const archiCodes = new Set(
archiToolsTags
.filter((t) => t.projectCode)
.map((t) => t.projectCode!.toLowerCase()),
);
// Build a normalized-label set from ManicTime
const manicLabels = new Set(
manicTimeTags
.filter((t) => t.category !== "header")
.map((t) => t.label.toLowerCase().trim()),
);
// Tags in ManicTime but not in ArchiTools → add to ArchiTools
for (const mt of manicTimeTags) {
if (mt.category === "header" || mt.category === "unknown") continue;
const labelKey = mt.label.toLowerCase().trim();
const codeKey = mt.projectCode?.toLowerCase();
if (!archiLabels.has(labelKey) && !(codeKey && archiCodes.has(codeKey))) {
result.addedToArchiTools.push(mt);
} else {
result.unchanged++;
}
}
// Tags in ArchiTools but not in ManicTime → add to ManicTime
for (const at of archiToolsTags) {
const labelKey = at.label.toLowerCase().trim();
if (!manicLabels.has(labelKey)) {
result.addedToManicTime.push(at);
}
}
return result;
}
/**
* Convert a ManicTimeTag to the shape needed by TagService.createTag().
*/
export function manicTimeTagToCreateData(
mt: ManicTimeTag,
): Omit<Tag, "id" | "createdAt"> | null {
if (mt.category === "header" || mt.category === "unknown") return null;
const category = mt.category as TagCategory;
const base: Omit<Tag, "id" | "createdAt"> = {
label: mt.label,
category,
scope: category === "project" ? "company" : "global",
color:
category === "project"
? "#22B5AB"
: category === "phase"
? "#3b82f6"
: "#8b5cf6",
};
if (category === "project") {
base.companyId = "beletage";
base.projectCode = mt.projectCode;
}
return base;
}
/**
* Generate a backup filename with timestamp.
*/
export function generateBackupFilename(originalPath: string): string {
const now = new Date();
const ts = [
now.getFullYear(),
String(now.getMonth() + 1).padStart(2, "0"),
String(now.getDate()).padStart(2, "0"),
"_",
String(now.getHours()).padStart(2, "0"),
String(now.getMinutes()).padStart(2, "0"),
String(now.getSeconds()).padStart(2, "0"),
].join("");
// Replace .txt with _backup_TIMESTAMP.txt
const dotIndex = originalPath.lastIndexOf(".");
if (dotIndex >= 0) {
return `${originalPath.substring(0, dotIndex)}_backup_${ts}${originalPath.substring(dotIndex)}`;
}
return `${originalPath}_backup_${ts}`;
}
/**
* Validate that tags respect ManicTime's hierarchical format.
* Returns a list of validation warnings (empty = valid).
*/
export function validateHierarchy(tags: Tag[]): string[] {
const warnings: string[] = [];
// Projects must have a project code
const projectsWithoutCode = tags.filter(
(t) => t.category === "project" && !t.projectCode,
);
if (projectsWithoutCode.length > 0) {
warnings.push(
`${projectsWithoutCode.length} proiecte fără cod (ex: B-001). ManicTime necesită un format numeric.`,
);
}
// Projects should have a company
const projectsWithoutCompany = tags.filter(
(t) => t.category === "project" && !t.companyId,
);
if (projectsWithoutCompany.length > 0) {
warnings.push(
`${projectsWithoutCompany.length} proiecte fără companie asociată.`,
);
}
// Check for duplicate labels within same category
const seen = new Map<string, string>();
for (const tag of tags) {
const key = `${tag.category}::${tag.label.toLowerCase()}`;
const existing = seen.get(key);
if (existing) {
warnings.push(`Etichetă duplicată: "${tag.label}" în ${tag.category}`);
} else {
seen.set(key, tag.id);
}
}
return warnings;
}