implement basic app structure, add mockup GUI

This commit is contained in:
2025-12-22 16:10:28 +01:00
parent 9068fed848
commit adca7b7a2c
16 changed files with 1233 additions and 0 deletions

3
.env.example Normal file
View File

@@ -0,0 +1,3 @@
# LLM API Configuration (OpenAI-compatible endpoint)
LLM_API_KEY=your_llm_api_key_here
LLM_API_BASE=http://localhost:11434/v1

2
.gitignore vendored
View File

@@ -168,3 +168,5 @@ cython_debug/
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
uv.lock
app_database/

View File

@@ -14,6 +14,13 @@ Designed for operation in forensic-grade, air-gapped environments, the system en
---
## GUI Draft
![Image](resources/gui-mockup.png)
***Work in progress***
*This is a mockup how the GUI would look, drafted with Claude.*
## SYSTEM ARCHITECTURE & DESIGN PHILOSOPHY
The design of Factum-Notes prioritizes integrity, low-latency input, and deployment flexibility.

37
pyproject.toml Normal file
View File

@@ -0,0 +1,37 @@
[project]
name = "factum"
version = "0.0.0"
description = "Forensic note-taking application which utilizes PGP signatures on timestamped notes to ensure maximum integrity."
authors = [
{name = "Mario Stöckl", email = "mstoeck3@hs-mittweida.de"}
]
readme = "README.md"
requires-python = "==3.13.*"
license = {text = "BSD-3-Clause"}
dependencies = [
"PySide6~=6.8.0",
"logging>=0.4.9.6",
"openai~=2.8.1",
"python-dotenv~=1.2.1",
]
[project.optional-dependencies]
dev = [
"pytest",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/factum"]
[tool.uv.workspace]
members = [
"factum",
]
[project.scripts]
factum = "factum.main:main"

147
requirements.txt Normal file
View File

@@ -0,0 +1,147 @@
# This file was autogenerated by uv via the following command:
# uv export --format requirements-txt
-e .
annotated-types==0.7.0 \
--hash=sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53 \
--hash=sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89
# via pydantic
anyio==4.12.0 \
--hash=sha256:73c693b567b0c55130c104d0b43a9baf3aa6a31fc6110116509f27bf75e21ec0 \
--hash=sha256:dad2376a628f98eeca4881fc56cd06affd18f659b17a747d3ff0307ced94b1bb
# via
# httpx
# openai
certifi==2025.11.12 \
--hash=sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b \
--hash=sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316
# via
# httpcore
# httpx
colorama==0.4.6 ; sys_platform == 'win32' \
--hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \
--hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6
# via tqdm
distro==1.9.0 \
--hash=sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed \
--hash=sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2
# via openai
h11==0.16.0 \
--hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 \
--hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86
# via httpcore
httpcore==1.0.9 \
--hash=sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55 \
--hash=sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8
# via httpx
httpx==0.28.1 \
--hash=sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc \
--hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad
# via openai
idna==3.11 \
--hash=sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea \
--hash=sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902
# via
# anyio
# httpx
jiter==0.12.0 \
--hash=sha256:048485c654b838140b007390b8182ba9774621103bd4d77c9c3f6f117474ba45 \
--hash=sha256:2a5e90604620f94bf62264e7c2c038704d38217b7465b863896c6d7c902b06c7 \
--hash=sha256:351d54f2b09a41600ffea43d081522d792e81dcfb915f6d2d242744c1cc48beb \
--hash=sha256:37f27a32ce36364d2fa4f7fdc507279db604d27d239ea2e044c8f148410defe1 \
--hash=sha256:4747de73d6b8c78f2e253a2787930f4fffc68da7fa319739f57437f95963c4de \
--hash=sha256:4e017c417b1ebda911bd13b1e40612704b1f5420e30695112efdbed8a4b389ed \
--hash=sha256:53839b35a38f56b8be26a7851a48b89bc47e5d88e900929df10ed93b95fea3d6 \
--hash=sha256:635e737fbb7315bef0037c19b88b799143d2d7d3507e61a76751025226b3ac87 \
--hash=sha256:64dfcd7d5c168b38d3f9f8bba7fc639edb3418abcc74f22fdbe6b8938293f30b \
--hash=sha256:6cc49d5130a14b732e0612bc76ae8db3b49898732223ef8b7599aa8d9810683e \
--hash=sha256:88ef757017e78d2860f96250f9393b7b577b06a956ad102c29c8237554380db3 \
--hash=sha256:89b0bfb8b2bf2351fba36bb211ef8bfceba73ef58e7f0c68fb67b5a2795ca2f9 \
--hash=sha256:94f669548e55c91ab47fef8bddd9c954dab1938644e715ea49d7e117015110a4 \
--hash=sha256:bbc0944aa3d4b4773e348cda635252824a78f4ba44328e042ef1ff3f6080d1cf \
--hash=sha256:c97b92c54fe6110138c872add030a1f99aea2401ddcdaa21edf74705a646dd60 \
--hash=sha256:da25c62d4ee1ffbacb97fac6dfe4dcd6759ebdc9015991e92a6eae5816287f44 \
--hash=sha256:e25012eb0c456fcc13354255d0338cd5397cce26c77b2832b3c4e2e255ea5d9a \
--hash=sha256:ed53b3d6acbcb0fd0b90f20c7cb3b24c357fe82a3518934d4edfa8c6898e498c \
--hash=sha256:f5aa5427a629a824a543672778c9ce0c5e556550d1569bb6ea28a85015287626
# via openai
logging==0.4.9.6 \
--hash=sha256:26f6b50773f085042d301085bd1bf5d9f3735704db9f37c1ce6d8b85c38f2417
# via factum
openai==2.8.1 \
--hash=sha256:c6c3b5a04994734386e8dad3c00a393f56d3b68a27cd2e8acae91a59e4122463 \
--hash=sha256:cb1b79eef6e809f6da326a7ef6038719e35aa944c42d081807bfa1be8060f15f
# via factum
pydantic==2.12.5 \
--hash=sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49 \
--hash=sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d
# via openai
pydantic-core==2.41.5 \
--hash=sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740 \
--hash=sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33 \
--hash=sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e \
--hash=sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0 \
--hash=sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34 \
--hash=sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd \
--hash=sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586 \
--hash=sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36 \
--hash=sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e \
--hash=sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11 \
--hash=sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858 \
--hash=sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9 \
--hash=sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2 \
--hash=sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d \
--hash=sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e
# via pydantic
pyside6==6.8.3 \
--hash=sha256:31f390c961b54067ae41360e5ea3b340ce0e0e5feadea2236c28226d3b37edcc \
--hash=sha256:5bf5153cab9484629315f57c56a9ad4b7d075b4dd275f828f7549abf712c590b \
--hash=sha256:722dc0061d8ef6dbb8c0b99864f21e83a5b49ece1ecb2d0b890840d969e1e461 \
--hash=sha256:8e53e2357bfbdee1fa86c48312bf637460a2c26d49e7af0b3fae2e179ccc7052
# via factum
pyside6-addons==6.8.3 \
--hash=sha256:67548f6db11f4e1b7e4b6efd9c3fc2e8d275188a7b2feac388961128572a6955 \
--hash=sha256:6983d3b01fad53637bad5360930d5923509c744cc39704f9c1190eb9934e33da \
--hash=sha256:7949a844a40ee10998eb2734e2c06c4c7182dfcd4c21cc4108a6b96655ebe59f \
--hash=sha256:ea46649e40b9e6ab11a0da2da054d3914bff5607a5882885e9c3bc2eef200036
# via pyside6
pyside6-essentials==6.8.3 \
--hash=sha256:3c0fae5550aff69f2166f46476c36e0ef56ce73d84829eac4559770b0c034b07 \
--hash=sha256:aa56c135db924ecfaf50088baf32f737d28027419ca5fee67c0c7141b29184e3 \
--hash=sha256:b4f4823f870b5bed477d6f7b6a3041839b859f70abfd703cf53208c73c2fe4cd \
--hash=sha256:fd57fa0c886ef99b3844173322c0023ec77cc946a0c9a0cdfbc2ac5c511053c1
# via
# pyside6
# pyside6-addons
python-dotenv==1.2.1 \
--hash=sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6 \
--hash=sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61
# via factum
shiboken6==6.8.3 \
--hash=sha256:295a003466ca2cccf6660e2f2ceb5e6cef4af192a48a196a32d46b6f0c9ec5cb \
--hash=sha256:2b1a41348102952d2a5fbf3630bddd4d44112e18058b5e4cf505e51f2812429d \
--hash=sha256:483efc7dd53c69147b8a8ade71f7619c79ffc683efcb1dc4f4cb6c40bb23d29b \
--hash=sha256:bca3a94513ce9242f7d4bbdca902072a1631888e0aa3a8711a52cc5dbe93588f
# via
# pyside6
# pyside6-addons
# pyside6-essentials
sniffio==1.3.1 \
--hash=sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2 \
--hash=sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc
# via openai
tqdm==4.67.1 \
--hash=sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2 \
--hash=sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2
# via openai
typing-extensions==4.15.0 \
--hash=sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466 \
--hash=sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548
# via
# openai
# pydantic
# pydantic-core
# typing-inspection
typing-inspection==0.4.2 \
--hash=sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7 \
--hash=sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464
# via pydantic

844
resources/gui-mockup.jsx Normal file
View File

@@ -0,0 +1,844 @@
import React, { useState } from 'react';
import { ChevronRight, ChevronDown, Pin, X, Hash, User, Check, Send, Filter, Trash2 } from 'lucide-react';
export default function FactumNotesMockup() {
const [selectedCase, setSelectedCase] = useState('CASE-2025-001');
const [selectedEvidence, setSelectedEvidence] = useState('HDD-001');
const [activeCase, setActiveCase] = useState('CASE-2025-001');
const [activeEvidence, setActiveEvidence] = useState('HDD-001');
const [expandedCases, setExpandedCases] = useState({ 'CASE-2025-001': true });
const [noteInput, setNoteInput] = useState('');
const [showGoals, setShowGoals] = useState(true);
const [showIocs, setShowIocs] = useState(true);
const [llmActive, setLlmActive] = useState(false);
const [llmInput, setLlmInput] = useState('');
const [llmMessages, setLlmMessages] = useState([
{ role: 'assistant', content: 'Ready to assist with analysis. Ask me about timeline correlation, IoC enrichment, or next investigation steps.' }
]);
const [filterTag, setFilterTag] = useState(null);
// Mock data
const cases = {
'CASE-2025-001': {
name: 'Financial Fraud Investigation',
evidences: ['HDD-001', 'USB-STICK-001', 'MEMORY-DUMP-001']
},
'CASE-2024-089': {
name: 'Corporate Espionage',
evidences: ['LAPTOP-001', 'PHONE-BACKUP-001']
}
};
const [notes, setNotes] = useState([
{
id: 1,
caseId: 'CASE-2025-001',
evidenceId: 'HDD-001',
timestamp: '2025-12-22 14:32:15 UTC',
content: 'Initial analysis of disk image reveals potential data exfiltration. Found suspicious PowerShell script at C:\\Users\\john.doe\\AppData\\Local\\Temp\\export.ps1. Script contains Base64-encoded payload attempting to connect to 192.168.45.123:4444 and download file from https://malicious-domain.com/payload.exe. MD5 hash of script: 5d41402abc4b2a76b9719d911017c592',
tags: ['initial-analysis', 'powershell', 'network-activity'],
signatures: ['Alice Johnson', 'Bob Smith'],
iocs: ['192.168.45.123:4444', 'https://malicious-domain.com/payload.exe', '5d41402abc4b2a76b9719d911017c592'],
signed: true,
pinned: false
},
{
id: 2,
caseId: 'CASE-2025-001',
evidenceId: 'HDD-001',
timestamp: '2025-12-22 15:45:02 UTC',
content: 'Registry analysis shows persistence mechanism established via Run key. Malware creates scheduled task named "SystemUpdate" executing C:\\Windows\\Temp\\svchost.exe every 30 minutes. SHA256: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855',
tags: ['persistence', 'registry', 'scheduled-task'],
signatures: ['Alice Johnson'],
iocs: ['C:\\Windows\\Temp\\svchost.exe', 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'],
signed: true,
pinned: false
},
{
id: 3,
caseId: 'CASE-2025-001',
evidenceId: 'HDD-001',
timestamp: '2025-12-22 16:20:38 UTC',
content: 'Browser history extraction complete. User visited cryptocurrency exchange wallet at 1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa multiple times between Dec 15-20. Timeline correlates with unauthorized transfers.',
tags: ['timeline', 'browser-history', 'cryptocurrency'],
signatures: ['Alice Johnson'],
iocs: ['1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa'],
signed: true,
pinned: true
},
{
id: 4,
caseId: 'CASE-2025-001',
evidenceId: 'USB-STICK-001',
timestamp: '2025-12-22 17:10:22 UTC',
content: 'USB device analysis reveals autorun.inf configured to execute malicious payload. Device contains encrypted archive with password hint referencing internal project codename.',
tags: ['usb-analysis', 'autorun', 'encryption'],
signatures: ['Bob Smith'],
iocs: [],
signed: true,
pinned: false
},
{
id: 5,
caseId: 'CASE-2024-089',
evidenceId: 'LAPTOP-001',
timestamp: '2025-12-21 10:15:00 UTC',
content: 'Corporate laptop shows evidence of unauthorized remote access tool installation. RustDesk client configured with external server 203.0.113.50:21116',
tags: ['remote-access', 'corporate', 'initial-analysis'],
signatures: ['Alice Johnson'],
iocs: ['203.0.113.50:21116'],
signed: true,
pinned: false
},
{
id: 6,
caseId: 'CASE-2025-001',
evidenceId: null, // Case-level note
timestamp: '2025-12-22 13:00:00 UTC',
content: 'Case opened: Financial fraud investigation involving potential cryptocurrency theft. Initial evidence suggests internal employee involvement based on access patterns.',
tags: ['case-overview', 'fraud', 'cryptocurrency'],
signatures: ['Alice Johnson'],
iocs: [],
signed: true,
pinned: false
},
{
id: 7,
caseId: 'CASE-2025-001',
evidenceId: null, // Case-level note
timestamp: '2025-12-22 18:00:00 UTC',
content: 'Daily summary: Evidence from multiple devices shows coordinated exfiltration attempt. Recommend expanding investigation to include network logs and employee workstation forensics.',
tags: ['summary', 'coordination'],
signatures: ['Alice Johnson'],
iocs: [],
signed: true,
pinned: false
}
]);
const [showNestedNotes, setShowNestedNotes] = useState(true);
const [pinnedNotes, setPinnedNotes] = useState([
{
id: 'pin-1',
content: 'CRITICAL: Check all PowerShell execution logs between Dec 15-20',
timestamp: '2025-12-22 13:00:00 UTC'
}
]);
const investigativeGoals = [
'Identify initial infection vector and timeline',
'Map complete network of compromised systems',
'Determine data exfiltration extent and destination',
'Establish attribution indicators'
];
const toggleCase = (caseId) => {
setExpandedCases(prev => ({ ...prev, [caseId]: !prev[caseId] }));
};
const extractIocs = (text) => {
const iocs = [];
// IPv4 with port
const ipv4Pattern = /\b(?:\d{1,3}\.){3}\d{1,3}(?::\d+)?\b/g;
const ipMatches = text.match(ipv4Pattern);
if (ipMatches) iocs.push(...ipMatches);
// URLs
const urlPattern = /https?:\/\/[^\s]+/g;
const urlMatches = text.match(urlPattern);
if (urlMatches) iocs.push(...urlMatches);
// MD5
const md5Pattern = /\b[a-f0-9]{32}\b/gi;
const md5Matches = text.match(md5Pattern);
if (md5Matches) iocs.push(...md5Matches);
// SHA256
const sha256Pattern = /\b[a-f0-9]{64}\b/gi;
const sha256Matches = text.match(sha256Pattern);
if (sha256Matches) iocs.push(...sha256Matches);
// Bitcoin addresses (simplified)
const btcPattern = /\b[13][a-km-zA-HJ-NP-Z1-9]{25,34}\b/g;
const btcMatches = text.match(btcPattern);
if (btcMatches) iocs.push(...btcMatches);
// File paths
const pathPattern = /[A-Z]:\\(?:[^\s\\]+\\)*[^\s\\]+\.[a-z]{2,4}/gi;
const pathMatches = text.match(pathPattern);
if (pathMatches) iocs.push(...pathMatches);
return [...new Set(iocs)];
};
const extractTags = (text) => {
const tagPattern = /#([a-z0-9-]+)/gi;
const matches = text.match(tagPattern);
return matches ? [...new Set(matches.map(t => t.substring(1).toLowerCase()))] : [];
};
const addNote = () => {
if (!noteInput.trim()) return;
const now = new Date();
const timestamp = now.toISOString().replace('T', ' ').substring(0, 19) + ' UTC';
const iocs = extractIocs(noteInput);
const tags = extractTags(noteInput);
const newNote = {
id: Math.max(...notes.map(n => n.id), 0) + 1,
caseId: selectedCase,
evidenceId: selectedEvidence,
timestamp,
content: noteInput,
tags,
signatures: ['Alice Johnson'],
iocs,
signed: true,
pinned: false
};
setNotes([...notes, newNote]);
setNoteInput('');
};
const togglePin = (noteId) => {
setNotes(notes.map(note =>
note.id === noteId ? { ...note, pinned: !note.pinned } : note
));
};
const deleteNote = (noteId) => {
setNotes(notes.filter(n => n.id !== noteId));
};
const sendLlmMessage = () => {
if (!llmInput.trim()) return;
const userMsg = { role: 'user', content: llmInput };
setLlmMessages([...llmMessages, userMsg]);
// Simulate AI response
setTimeout(() => {
let response = '';
const input = llmInput.toLowerCase();
if (input.includes('timeline') || input.includes('chronology')) {
response = 'Based on the timestamps, the attack sequence appears to be: 1) Initial compromise via PowerShell script (14:32), 2) Persistence establishment through registry modification (15:45), 3) Data exfiltration to cryptocurrency wallet (16:20). Consider examining system logs between these timestamps for additional pivot points.';
} else if (input.includes('ioc') || input.includes('indicator')) {
response = 'I\'ve identified 6 unique IoCs across your notes. The IP address 192.168.45.123:4444 appears to be a C2 server. I recommend: 1) Checking if this IP appears in other evidence items, 2) Searching for the malicious domain in DNS logs, 3) Validating the file hashes against VirusTotal.';
} else if (input.includes('next') || input.includes('recommend')) {
response = 'Recommended next steps: 1) Analyze the PowerShell execution history for the timeframe Dec 15-20, 2) Extract and decrypt the USB archive if possible, 3) Cross-reference the cryptocurrency wallet address with blockchain explorers for transaction analysis, 4) Check network logs for communication with 192.168.45.123.';
} else {
response = 'I can help analyze your notes, correlate timelines, enrich IoCs, or suggest investigation steps. What specific aspect would you like assistance with?';
}
const aiMsg = { role: 'assistant', content: response };
setLlmMessages(prev => [...prev, aiMsg]);
}, 500);
setLlmInput('');
};
const handleKeyDown = (e, callback) => {
if (e.key === 'Enter' && e.ctrlKey) {
e.preventDefault();
callback();
}
};
// Filter notes based on selection and tag filter
const filteredNotes = notes.filter(note => {
const matchesCase = note.caseId === selectedCase;
const matchesTag = !filterTag || note.tags.includes(filterTag);
if (!matchesCase || !matchesTag) return false;
// If viewing a specific evidence, only show notes from that evidence
if (selectedEvidence) {
return note.evidenceId === selectedEvidence;
}
// If viewing case-level (no evidence selected)
// Show case-level notes always
if (!note.evidenceId) {
return true;
}
// Show evidence notes only if nested toggle is on
return showNestedNotes;
}).sort((a, b) => {
// Sort chronologically by timestamp
return new Date(a.timestamp) - new Date(b.timestamp);
});
// Get all unique tags from filtered notes
const allTags = [...new Set(filteredNotes.flatMap(note => note.tags))];
// Get extracted IoCs from filtered notes and organize by type
const iocsByType = filteredNotes.reduce((acc, note) => {
note.iocs.forEach(ioc => {
let type = 'Unknown';
if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/.test(ioc)) type = 'IPv4';
else if (/^https?:\/\//.test(ioc)) type = 'URL';
else if (/^[a-f0-9]{32}$/i.test(ioc)) type = 'MD5';
else if (/^[a-f0-9]{64}$/i.test(ioc)) type = 'SHA256';
else if (/^[13][a-km-zA-HJ-NP-Z1-9]{25,34}$/.test(ioc)) type = 'Bitcoin';
else if (/^[A-Z]:\\/.test(ioc)) type = 'File Path';
if (!acc[type]) {
acc[type] = [];
}
if (!acc[type].find(item => item === ioc)) {
acc[type].push(ioc);
}
});
return acc;
}, {});
// Get pinned notes from the currently selected case (not filtered by evidence)
const displayPinnedNotes = notes.filter(n => n.pinned && n.caseId === selectedCase);
// State for IoC tree expansion
const [expandedIocTypes, setExpandedIocTypes] = useState({});
const toggleIocType = (type) => {
setExpandedIocTypes(prev => ({ ...prev, [type]: !prev[type] }));
};
const highlightIocs = (text, iocs) => {
if (!iocs || iocs.length === 0) return text;
let result = text;
const parts = [];
let lastIndex = 0;
// Sort IOCs by their position in text
const positions = [];
iocs.forEach(ioc => {
const index = result.indexOf(ioc);
if (index !== -1) {
positions.push({ ioc, index });
}
});
positions.sort((a, b) => a.index - b.index);
positions.forEach(({ ioc, index }) => {
if (index >= lastIndex) {
parts.push(result.substring(lastIndex, index));
parts.push(<span key={index} className="bg-amber-100 text-amber-900 px-1 rounded font-mono text-sm border border-amber-300">{ioc}</span>);
lastIndex = index + ioc.length;
}
});
parts.push(result.substring(lastIndex));
return parts;
};
return (
<div className="h-screen flex flex-col bg-stone-50 text-stone-800 font-mono">
{/* Menu Bar */}
<div className="bg-amber-900 border-b border-amber-800 px-4 py-2 flex items-center space-x-6">
<span className="text-amber-50 font-bold text-lg">FACTUM-NOTES</span>
<button className="text-amber-100 hover:text-amber-50 transition-colors">File</button>
<button className="text-amber-100 hover:text-amber-50 transition-colors">View</button>
<button className="text-amber-100 hover:text-amber-50 transition-colors">Settings</button>
<button className="text-amber-100 hover:text-amber-50 transition-colors">Help</button>
</div>
{/* Main Content Area */}
<div className="flex-1 flex overflow-hidden">
{/* Left Sidebar - Case/Evidence Tree */}
<div className="w-72 bg-stone-100 border-r border-stone-300 flex flex-col">
<div className="p-3 border-b border-stone-300">
<h3 className="text-xs uppercase tracking-wider text-stone-600 mb-1">Case Structure</h3>
<p className="text-xs text-stone-500 flex items-center">
<span className="inline-block w-2 h-2 rounded-full bg-green-600 mr-1"></span> = Active for CLI notes
</p>
</div>
<div className="flex-1 overflow-y-auto p-2">
{Object.entries(cases).map(([caseId, caseData]) => (
<div key={caseId} className="mb-2">
<div
className={`flex items-center space-x-2 px-2 py-2 rounded cursor-pointer transition-colors ${
selectedCase === caseId && !selectedEvidence ? 'bg-amber-100 text-amber-900 border border-amber-300' : 'hover:bg-stone-200'
}`}
onClick={() => {
setSelectedCase(caseId);
setSelectedEvidence(null);
toggleCase(caseId);
}}
>
{expandedCases[caseId] ?
<ChevronDown className="w-4 h-4" /> :
<ChevronRight className="w-4 h-4" />
}
<span
className={`inline-block w-2 h-2 rounded-full cursor-pointer transition-colors ${
activeCase === caseId && !activeEvidence ? 'bg-green-600' : 'bg-stone-400 hover:bg-green-400'
}`}
onClick={(e) => {
e.stopPropagation();
setActiveCase(caseId);
setActiveEvidence(null);
}}
title={activeCase === caseId && !activeEvidence ? "Active for CLI notes" : "Set as active for CLI notes"}
/>
<span className="text-sm font-semibold flex-1">{caseId}</span>
</div>
{expandedCases[caseId] && (
<div className="ml-6 mt-1 space-y-1">
<div className="text-xs text-stone-600 mb-2">{caseData.name}</div>
{caseData.evidences.map(evidence => (
<div
key={evidence}
className={`px-2 py-1.5 rounded text-sm cursor-pointer transition-colors flex items-center ${
selectedEvidence === evidence ? 'bg-amber-50 text-amber-900 border-l-2 border-amber-700' : 'hover:bg-stone-200'
}`}
onClick={() => {
setSelectedCase(caseId);
setSelectedEvidence(evidence);
}}
>
<span
className={`inline-block w-2 h-2 rounded-full mr-2 cursor-pointer transition-colors ${
activeCase === caseId && activeEvidence === evidence ? 'bg-green-600' : 'bg-stone-400 hover:bg-green-400'
}`}
onClick={(e) => {
e.stopPropagation();
setActiveCase(caseId);
setActiveEvidence(evidence);
}}
title={activeCase === caseId && activeEvidence === evidence ? "Active for CLI notes" : "Set as active for CLI notes"}
/>
<span className="flex-1">{evidence}</span>
</div>
))}
</div>
)}
</div>
))}
</div>
<div className="p-3 border-t border-stone-300 space-y-2">
<button className="w-full px-3 py-2 bg-amber-700 hover:bg-amber-800 text-amber-50 rounded text-sm transition-colors">
+ New Case
</button>
<button className="w-full px-3 py-2 bg-red-100 hover:bg-red-200 text-red-800 border border-red-300 rounded text-sm transition-colors">
Delete Case
</button>
</div>
</div>
{/* Central Content Area */}
<div className="flex-1 flex flex-col">
{/* Filter Bar */}
<div className="border-b border-stone-300 px-4 py-2 bg-stone-100 flex items-center justify-between">
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<Filter className="w-4 h-4 text-stone-600" />
<span className="text-xs text-stone-600">Filter by tag:</span>
<select
value={filterTag || ''}
onChange={(e) => setFilterTag(e.target.value || null)}
className="bg-white border border-stone-300 rounded px-2 py-1 text-xs text-stone-800 focus:outline-none focus:border-amber-700 focus:ring-1 focus:ring-amber-700"
>
<option value="">All tags</option>
{allTags.map(tag => (
<option key={tag} value={tag}>#{tag}</option>
))}
</select>
{filterTag && (
<button
onClick={() => setFilterTag(null)}
className="text-xs text-red-700 hover:text-red-800 transition-colors"
>
Clear filter
</button>
)}
</div>
{!selectedEvidence && (
<label className="flex items-center space-x-2 cursor-pointer">
<input
type="checkbox"
checked={showNestedNotes}
onChange={(e) => setShowNestedNotes(e.target.checked)}
className="w-3 h-3 text-amber-700 focus:ring-amber-700 rounded"
/>
<span className="text-xs text-stone-700">Show nested evidence notes</span>
</label>
)}
</div>
<span className="text-xs text-stone-600">{filteredNotes.length} note{filteredNotes.length !== 1 ? 's' : ''}</span>
</div>
{/* Notes Display */}
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{filteredNotes.length === 0 ? (
<div className="flex items-center justify-center h-full text-stone-500 text-sm">
No notes for this selection
</div>
) : (
filteredNotes.map((note, idx) => {
// Determine if this is a nested note (evidence note when viewing case-level)
const isNested = !selectedEvidence && note.evidenceId;
// For tree structure, check if next note is also nested
const nextNote = filteredNotes[idx + 1];
const isLastNested = isNested && (!nextNote || !nextNote.evidenceId);
const treeSymbol = isNested ? (isLastNested ? '└──' : '├──') : '';
return (
<div key={note.id} className={`bg-white border border-stone-300 rounded-lg p-4 hover:border-amber-400 hover:shadow-sm transition-all ${isNested ? 'ml-12' : ''}`}>
{/* Timestamp and Evidence Badge */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center space-x-2 flex-1">
{isNested && (
<>
<span className="text-stone-500 font-mono text-xs flex-shrink-0">{treeSymbol}</span>
<span className="text-xs font-semibold text-stone-700 bg-amber-100 px-2 py-1 rounded border border-amber-300 flex-shrink-0">
{note.evidenceId}
</span>
</>
)}
<span className="text-amber-800 text-sm font-bold">{note.timestamp}</span>
</div>
<div className="flex items-center space-x-2">
<Pin
className={`w-4 h-4 cursor-pointer transition-colors ${
note.pinned ? 'text-amber-700 fill-amber-700' : 'text-stone-400 hover:text-amber-700'
}`}
onClick={() => togglePin(note.id)}
/>
<Trash2
className="w-4 h-4 text-stone-400 hover:text-red-700 cursor-pointer transition-colors"
onClick={() => {
if (window.confirm('Delete this note?')) {
deleteNote(note.id);
}
}}
/>
</div>
</div>
{/* Content */}
<div className="text-sm text-stone-700 leading-relaxed mb-3">
{highlightIocs(note.content, note.iocs)}
</div>
{/* Tags */}
{note.tags && note.tags.length > 0 && (
<div className="flex flex-wrap gap-2 mb-3">
{note.tags.map(tag => (
<span
key={tag}
className="px-2 py-1 bg-blue-50 text-blue-800 rounded text-xs border border-blue-200 cursor-pointer hover:bg-blue-100 transition-colors"
onClick={() => setFilterTag(tag)}
>
#{tag}
</span>
))}
</div>
)}
{/* Signatures */}
<div className="flex items-center space-x-2 pt-2 border-t border-stone-200">
<Check className="w-3 h-3 text-green-700" />
<span className="text-xs text-stone-600">Signed by:</span>
{note.signatures.map((signer, idx) => (
<span
key={idx}
className="group relative"
>
<User className="w-4 h-4 text-green-700 cursor-help" />
<span className="absolute bottom-full left-1/2 -translate-x-1/2 mb-1 px-2 py-1 bg-stone-800 text-amber-50 text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none z-10">
{signer}
</span>
</span>
))}
</div>
</div>
);
})
)}
</div>
{/* Input Area */}
<div className="border-t border-stone-300 p-4 bg-stone-100">
<textarea
value={noteInput}
onChange={(e) => setNoteInput(e.target.value)}
onKeyDown={(e) => handleKeyDown(e, addNote)}
placeholder="Enter forensic note... Use #tags for categorization. IoCs will be auto-extracted. (Ctrl+Enter to submit)"
className="w-full h-24 bg-white border border-stone-300 rounded px-3 py-2 text-sm text-stone-800 placeholder-stone-500 focus:outline-none focus:border-amber-700 focus:ring-1 focus:ring-amber-700 resize-none"
/>
<div className="flex items-center justify-between mt-2">
<span className="text-xs text-stone-600">IoCs will be auto-extracted | GPG signing enabled</span>
<button
onClick={addNote}
className="px-4 py-1.5 bg-amber-700 hover:bg-amber-800 text-amber-50 rounded text-sm transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
disabled={!noteInput.trim()}
>
Add Note
</button>
</div>
</div>
</div>
{/* Right Sidebar - Information Panel */}
<div className="w-80 bg-stone-100 border-l border-stone-300 flex flex-col overflow-y-auto">
{/* Investigative Goals - Always at top */}
<div className="border-b border-stone-300">
<div
className="p-3 bg-stone-200 flex items-center justify-between cursor-pointer hover:bg-stone-250 transition-colors"
onClick={() => setShowGoals(!showGoals)}
>
<h3 className="text-xs uppercase tracking-wider text-stone-700">Investigative Goals</h3>
<div className="flex items-center space-x-2">
<button
onClick={(e) => {
e.stopPropagation();
alert('Edit goals dialog would open here');
}}
className="p-1 hover:bg-stone-300 rounded transition-colors"
title="Edit Goals"
>
<svg className="w-3 h-3 text-stone-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
{showGoals ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
</div>
</div>
{showGoals && (
<div className="p-3">
<ol className="space-y-2 text-xs text-stone-700">
{investigativeGoals.map((goal, idx) => (
<li key={idx} className="flex items-start">
<span className="text-amber-800 mr-2 font-semibold">{idx + 1}.</span>
<span>{goal}</span>
</li>
))}
</ol>
</div>
)}
</div>
{/* Pinned Notes */}
{displayPinnedNotes.length > 0 && (
<div className="border-b border-stone-300">
<div className="p-3 bg-stone-200">
<h3 className="text-xs uppercase tracking-wider text-stone-700 flex items-center">
<Pin className="w-3 h-3 mr-2" />
Pinned Notes
</h3>
</div>
<div className="p-3 space-y-2 max-h-64 overflow-y-auto" style={{minHeight: '80px'}}>
{displayPinnedNotes.map(note => (
<div key={note.id} className="bg-green-50 border border-green-300 rounded p-3">
<div className="flex items-start justify-between mb-2">
<div className="flex-1">
<span className="text-xs text-green-800">{note.timestamp}</span>
<div className="text-xs text-stone-600 mt-1">
{note.caseId}{note.evidenceId ? ` > ${note.evidenceId}` : ''}
</div>
</div>
<X
className="w-3 h-3 text-stone-500 hover:text-red-700 cursor-pointer transition-colors flex-shrink-0"
onClick={() => togglePin(note.id)}
/>
</div>
<p className="text-xs text-stone-800 line-clamp-3">{note.content}</p>
{note.tags && note.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{note.tags.map(tag => (
<span key={tag} className="px-1.5 py-0.5 bg-blue-100 text-blue-800 rounded text-xs border border-blue-200">
#{tag}
</span>
))}
</div>
)}
</div>
))}
</div>
{/* Resize Handle */}
<div className="h-1 bg-stone-300 hover:bg-amber-600 cursor-ns-resize transition-colors" title="Drag to resize"></div>
</div>
)}
{/* Extracted IOCs */}
<div className="border-b border-stone-300">
<div
className="p-3 bg-stone-200 flex items-center justify-between cursor-pointer hover:bg-stone-250 transition-colors"
onClick={() => setShowIocs(!showIocs)}
>
<h3 className="text-xs uppercase tracking-wider text-stone-700 flex items-center">
<Hash className="w-3 h-3 mr-2" />
Extracted IOCs ({Object.values(iocsByType).flat().length})
</h3>
{showIocs ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
</div>
{showIocs && (
<div className="p-3 space-y-1 max-h-64 overflow-y-auto" style={{minHeight: '100px'}}>
{Object.keys(iocsByType).length === 0 ? (
<p className="text-xs text-stone-500">No IoCs extracted yet</p>
) : (
Object.entries(iocsByType).map(([type, iocs]) => (
<div key={type}>
<div
className="flex items-center space-x-1 cursor-pointer hover:bg-stone-100 py-1 px-2 rounded transition-colors"
onClick={() => toggleIocType(type)}
>
<span className="text-stone-700 font-mono text-xs">
{expandedIocTypes[type] ? '▼' : '▶'}
</span>
<span className="text-xs font-semibold text-amber-800">
{type} ({iocs.length})
</span>
</div>
{expandedIocTypes[type] && (
<div className="ml-4 space-y-1">
{iocs.map((ioc, idx) => (
<div
key={idx}
className="flex items-start space-x-1 py-1 px-2 hover:bg-amber-50 rounded cursor-pointer transition-colors"
onClick={() => alert(`Filter notes containing: ${ioc}`)}
title="Click to filter notes containing this IoC"
>
<span className="text-stone-500 font-mono text-xs flex-shrink-0">
{idx === iocs.length - 1 ? '└──' : '├──'}
</span>
<span className="text-xs text-stone-700 font-mono break-all">{ioc}</span>
</div>
))}
</div>
)}
</div>
))
)}
</div>
)}
{/* Resize Handle */}
<div className="h-1 bg-stone-300 hover:bg-amber-600 cursor-ns-resize transition-colors" title="Drag to resize"></div>
</div>
{/* LLM Interface */}
<div className="flex-1 flex flex-col" style={{minHeight: '200px'}}>
<div className="p-3 bg-stone-200 flex items-center justify-between">
<h3 className="text-xs uppercase tracking-wider text-stone-700">AI Assistant</h3>
<button
onClick={() => setLlmActive(!llmActive)}
className={`px-2 py-1 rounded text-xs transition-colors ${
llmActive ? 'bg-green-100 text-green-800 border border-green-300' : 'bg-stone-300 text-stone-600'
}`}
>
{llmActive ? 'ACTIVE' : 'INACTIVE'}
</button>
</div>
{llmActive && (
<div className="flex-1 flex flex-col p-3 min-h-0">
{/* Quick Action Buttons */}
<div className="mb-3 space-y-2">
<button
onClick={() => {
const assessMsg = { role: 'user', content: '[Assess Investigation] Compare current notes with investigative goals' };
setLlmMessages([...llmMessages, assessMsg]);
setTimeout(() => {
const response = { role: 'assistant', content: 'Based on your notes, you\'ve made progress on goals 1 and 3. Goal 2 (mapping compromised systems) needs attention - consider analyzing network logs from the malicious IP 192.168.45.123. Goal 4 (attribution) is still pending - the cryptocurrency wallet and PowerShell techniques could be researched for similar campaigns.' };
setLlmMessages(prev => [...prev, response]);
}, 500);
}}
className="w-full px-3 py-2 bg-blue-100 hover:bg-blue-200 text-blue-800 border border-blue-300 rounded text-xs transition-colors text-left flex items-center"
>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
</svg>
Assess Investigation
</button>
<button
onClick={() => {
const suggestMsg = { role: 'user', content: '[Suggest Goals] Recommend investigation directions' };
setLlmMessages([...llmMessages, suggestMsg]);
setTimeout(() => {
const response = { role: 'assistant', content: 'Based on your findings, I suggest:\n1. Timeline all PowerShell executions and correlate with network connections\n2. Analyze the encrypted USB archive - password hint may lead to internal knowledge\n3. Search for similar Bitcoin wallet in other evidence items\n4. Research the hash 5d41402abc4b2a76b9719d911017c592 against threat intelligence databases\n\nWould you like me to add these to your investigative goals?' };
setLlmMessages(prev => [...prev, response]);
}, 500);
}}
className="w-full px-3 py-2 bg-green-100 hover:bg-green-200 text-green-800 border border-green-300 rounded text-xs transition-colors text-left flex items-center"
>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
Suggest Goals
</button>
</div>
{/* Conversation History */}
<div className="flex-1 bg-white border border-stone-300 rounded p-3 mb-2 overflow-y-auto text-xs space-y-2">
{llmMessages.map((msg, idx) => (
<div key={idx} className={`${msg.role === 'user' ? 'text-blue-800' : 'text-stone-700'}`}>
<span className="font-semibold">{msg.role === 'user' ? '👤 You' : '🤖 AI'}:</span> {msg.content}
</div>
))}
</div>
{/* General Query Input */}
<div className="flex space-x-2">
<input
type="text"
value={llmInput}
onChange={(e) => setLlmInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
sendLlmMessage();
}
}}
placeholder="Ask the AI..."
className="flex-1 bg-white border border-stone-300 rounded px-2 py-1.5 text-xs text-stone-800 placeholder-stone-500 focus:outline-none focus:border-amber-700 focus:ring-1 focus:ring-amber-700"
/>
<button
onClick={sendLlmMessage}
disabled={!llmInput.trim()}
className="px-3 py-1.5 bg-amber-700 hover:bg-amber-800 text-amber-50 rounded text-xs transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<Send className="w-3 h-3" />
</button>
</div>
</div>
)}
</div>
</div>
</div>
{/* Status Bar */}
<div className="bg-amber-900 border-t border-amber-800 px-4 py-2 flex items-center justify-between text-xs">
<div className="flex items-center space-x-6">
<span className="text-amber-100">
Viewing: <span className="text-amber-50 font-semibold">{selectedCase}</span>
{selectedEvidence && <span className="text-amber-50 font-semibold"> / {selectedEvidence}</span>}
</span>
<span className="text-amber-100 flex items-center">
<span className="inline-block w-2 h-2 rounded-full bg-green-400 mr-2"></span>
CLI Active: <span className="text-amber-50 font-semibold ml-1">{activeCase}</span>
{activeEvidence && <span className="text-amber-50 font-semibold"> / {activeEvidence}</span>}
</span>
</div>
<div className="flex items-center space-x-4">
<span className="text-amber-100">
GPG Key: <span className="text-amber-200">alice.johnson@forensics.lab (4096R/A1B2C3D4)</span>
</span>
<span className="flex items-center text-green-200">
<Check className="w-3 h-3 mr-1" />
Signing Enabled
</span>
</div>
</div>
</div>
);
}

BIN
resources/gui-mockup.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

12
src/factum/__init__.py Normal file
View File

@@ -0,0 +1,12 @@
#
# Copyright (c) 2025, mstoeck3
# All rights reserved.
#
# This source code is licensed under the BSD-3-Clause license found in the
# LICENSE file in the root directory of this source tree.
#
"""factum-notes: """
__version__ = "0.0.0"

20
src/factum/config.py Normal file
View File

@@ -0,0 +1,20 @@
import logging
from dotenv import dotenv_values
class Config:
def __init__(self):
env = dotenv_values(".env")
self.log_level_str = (env.get("LOG_LEVEL") or "DEBUG").upper()
self.level_map = {
"DEBUG": logging.DEBUG,
"INFO": logging.INFO,
"WARNING": logging.WARNING,
"ERROR": logging.ERROR
}
@property
def log_level(self):
return self.level_map.get(self.log_level_str, logging.DEBUG)
settings = Config()

23
src/factum/main.py Normal file
View File

@@ -0,0 +1,23 @@
import logging
from factum.config import settings
from factum.services.db_service import DBService
from factum.ui.main_window import FactumWindow
from PySide6.QtWidgets import QApplication
class FactumApp:
def __init__(self, db_service: DBService):
self.app = QApplication([])
self.main_window = FactumWindow(db_service)
def run(self):
self.main_window.show()
self.app.exec()
logging.info("Factum application is running.")
def main():
logging.basicConfig(level=settings.log_level)
logging.debug(f"Starting Factum with level: {settings.log_level_str}")
db_service = DBService()
app = FactumApp(db_service)
app.run()

View File

View File

@@ -0,0 +1,27 @@
import sqlite3
import logging
import os
from dotenv import dotenv_values
logger = logging.getLogger(__name__)
DB_DIR = "app_database"
DB_FILE = os.path.join(DB_DIR, "factum.db")
class DBService:
def __init__(self):
logging.debug("Initializing DBService")
self.conn = self.connect_db()
def connect_db(self):
logging.debug(f"Trying to connect to database at {DB_FILE}")
if not os.path.exists(DB_DIR):
logging.debug(f"Database directory {DB_DIR} does not exist, creating...")
os.makedirs(DB_DIR, exist_ok=True)
try:
conn = sqlite3.connect(DB_FILE)
logging.info("Database connection established.")
return conn
except sqlite3.Error as e:
logging.error(f"Database connection failed: {e}")
raise

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,45 @@
from PySide6.QtWidgets import QMainWindow, QApplication, QWidget, QVBoxLayout, QLabel, QPushButton, QLineEdit, QMenu, QHBoxLayout
from PySide6.QtCore import Qt, Signal
from factum import config
import factum.ui.stylesheet as ss
class FactumWindow(QMainWindow):
def __init__(self, db_service):
super().__init__()
self.db_service = db_service
self.config = config.settings
self.create_ui()
self.connect_ui_elements()
def create_ui(self):
self.setWindowTitle("Factum")
self.setGeometry(100, 100, 800, 600)
self.setStyleSheet(ss.main_window_style)
central_widget = QWidget()
self.setCentralWidget(central_widget)
self.main_layout = QHBoxLayout()
self.case_tree_layout = QVBoxLayout()
self.note_area_layout = QVBoxLayout()
self.info_widget_layout = QVBoxLayout()
self.main_layout.addLayout(self.case_tree_layout)
self.main_layout.addLayout(self.note_area_layout)
self.main_layout.addLayout(self.info_widget_layout)
central_widget.setLayout(self.main_layout)
self.create_menu_bar()
def connect_ui_elements(self):
pass
def create_menu_bar(self):
menu_bar = self.menuBar()
menu_bar.setStyleSheet(ss.toolbar_style)
settings_menu = QMenu("&Settings", self)
menu_bar.addMenu(settings_menu)
about_menu = QMenu("&About", self)
menu_bar.addMenu(about_menu)

View File

@@ -0,0 +1,65 @@
main_window_style = """
QWidget {
background-color: #f0f0f0;
font-family: Arial, sans-serif;
font-size: 14px;
color: #333;
}
"""
button_style = """
QPushButton {
padding: 8px 16px;
font-size: 16px;
border-radius: 12px;
background-color: #808080;
color: white;
border: 1px solid #6e6e6e;
}
QPushButton:hover {
background-color: #6e6e6e;
}
QPushButton:pressed {
background-color: #5e5e5e;
}
"""
input_style = """
QLineEdit {
border: 1px solid #ddd;
border-radius: 24px;
padding: 10px 20px;
font-size: 16px;
}
QLineEdit:focus {
border: 1px solid #aaa;
outline: none;
}
"""
toolbar_style = """
QMenuBar::item {
padding: 6px 12px;
border-radius: 4px;
}
QMenuBar::item:selected {
background-color: #e8e8e8;
}
QMenu {
border: 2px solid #c0c0c0;
border-radius: 6px;
padding: 6px 4px;
}
QMenu::item {
padding: 8px 24px 8px 12px;
border-radius: 4px;
margin: 2px;
}
QMenu::item:selected {
background-color: #e8e8e8;
}
"""
status_widget_style = """
"""