Compare commits

..

26 Commits

Author SHA1 Message Date
Claude
56b0b6651e add line counter tool and update README with code stats
https://claude.ai/code/session_01Gt1RfK8m2hxciLMMMYG8xg
2026-03-27 13:07:29 +00:00
f627a5ac6e enabling kafka 2026-03-26 09:59:31 -04:00
a5e7d97213 adding full qwen3 2026-03-24 16:20:21 -04:00
1419deb3c6 setting up brain nix serve 2026-03-24 15:04:48 -04:00
1f06692696 adding zstd to firefix settings 2026-03-24 12:53:44 -04:00
8f8177f36e adding zstd compression to fastapi 2026-03-24 12:53:44 -04:00
8534edc285 added git key binds 2026-03-24 12:45:51 -04:00
73b28a855b fixed missed renames 2026-03-24 12:45:51 -04:00
0c0810a06b added cycle status 2026-03-24 12:45:51 -04:00
239bef975a adding availability status to HA 2026-03-24 12:45:51 -04:00
2577b791f7 removing antigravity 2026-03-24 12:41:28 -04:00
b4d9562591 fixed treefmt 2026-03-22 19:07:23 -04:00
66f972ac2b removing react 2026-03-22 19:07:23 -04:00
aca756f479 down grading transmision 2026-03-22 14:30:13 -04:00
7f59f7f7ac fixing brain gps data 2026-03-22 14:30:13 -04:00
github-actions[bot]
70864c620f flake.lock: Update
Flake lock file updates:

• Updated input 'firefox-addons':
    'gitlab:rycee/nur-expressions/07e1616?dir=pkgs/firefox-addons' (2026-03-06)
  → 'gitlab:rycee/nur-expressions/81e28f4?dir=pkgs/firefox-addons' (2026-03-20)
• Updated input 'home-manager':
    'github:nix-community/home-manager/daa2c22' (2026-03-06)
  → 'github:nix-community/home-manager/9670de2' (2026-03-20)
• Updated input 'nixos-hardware':
    'github:nixos/nixos-hardware/41c6b42' (2026-02-24)
  → 'github:nixos/nixos-hardware/2d4b471' (2026-03-20)
• Updated input 'nixpkgs':
    'github:nixos/nixpkgs/80bdc1e' (2026-03-04)
  → 'github:nixos/nixpkgs/b40629e' (2026-03-18)
• Updated input 'nixpkgs-master':
    'github:nixos/nixpkgs/af5157a' (2026-03-07)
  → 'github:nixos/nixpkgs/8620c0b' (2026-03-21)
• Updated input 'sops-nix':
    'github:Mic92/sops-nix/1d9b98a' (2026-03-02)
  → 'github:Mic92/sops-nix/29b6519' (2026-03-19)
2026-03-21 08:29:18 -04:00
dependabot[bot]
304f1c8433 Bump flatted from 3.3.3 to 3.4.2 in /frontend
Bumps [flatted](https://github.com/WebReflection/flatted) from 3.3.3 to 3.4.2.
- [Commits](https://github.com/WebReflection/flatted/compare/v3.3.3...v3.4.2)

---
updated-dependencies:
- dependency-name: flatted
  dependency-version: 3.4.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-21 08:24:11 -04:00
1b5a036061 httpx conversion 2026-03-18 19:38:14 -04:00
42330ec186 changed to sa.Enum 2026-03-18 19:29:59 -04:00
3f4373d1f6 fixed tests and treeftm 2026-03-18 19:29:59 -04:00
cc73dfc467 updated mats ssh key 2026-03-18 19:29:59 -04:00
976c3f9d3e move signal bot to its own DB 2026-03-18 19:29:59 -04:00
2661127426 updated _format_location to use van van_last_known_longitude and van_last_known_latitude 2026-03-18 19:29:59 -04:00
1b3e6725ea added sync_roles 2026-03-18 19:29:59 -04:00
7d2fbaea43 added bot class and rbac style auth with dynamic help msg base on roles 2026-03-18 19:29:59 -04:00
a19b1c7e60 Add Signal location command backed by Home Assistant 2026-03-18 19:29:59 -04:00
87 changed files with 2861 additions and 6936 deletions

View File

@@ -1 +1,26 @@
# dotfiles # dotfiles
<!-- LINE-COUNT-START -->
This repo has **20,055** lines of technical debt.
| File Type | Lines | Percentage |
|-----------|------:|-----------:|
| .py | 11,441 | 57.0% |
| .nix | 4,471 | 22.3% |
| .yaml | 1,121 | 5.6% |
| .html | 1,009 | 5.0% |
| .json | 555 | 2.8% |
| .yml | 479 | 2.4% |
| .toml | 290 | 1.4% |
| .css | 212 | 1.1% |
| .gitignore | 199 | 1.0% |
| .md | 75 | 0.4% |
| .cfg | 73 | 0.4% |
| .sh | 48 | 0.2% |
| .mako | 36 | 0.2% |
| .LICENSE | 21 | 0.1% |
| .conf | 17 | 0.1% |
| .Gemfile | 4 | 0.0% |
| .svg | 3 | 0.0% |
| .new | 1 | 0.0% |
<!-- LINE-COUNT-END -->

View File

@@ -0,0 +1,6 @@
{
nix.settings = {
trusted-substituters = [ "http://192.168.95.35:5000" ];
substituters = [ "http://192.168.95.35:5000/?priority=1&want-mass-query=true" ];
};
}

36
flake.lock generated
View File

@@ -8,11 +8,11 @@
}, },
"locked": { "locked": {
"dir": "pkgs/firefox-addons", "dir": "pkgs/firefox-addons",
"lastModified": 1772824881, "lastModified": 1773979456,
"narHash": "sha256-NqX+JCA8hRV3GoYrsqnHB2IWKte1eQ8NK2WVbJkORcw=", "narHash": "sha256-9kBMJ5IvxqNlkkj/swmE8uK1Sc7TL/LIRUI958m7uBM=",
"owner": "rycee", "owner": "rycee",
"repo": "nur-expressions", "repo": "nur-expressions",
"rev": "07e1616c9b13fe4794dad4bcc33cd7088c554465", "rev": "81e28f47ac18d9e89513929c77e711e657b64851",
"type": "gitlab" "type": "gitlab"
}, },
"original": { "original": {
@@ -29,11 +29,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1772807318, "lastModified": 1774007980,
"narHash": "sha256-Qjw6ILt8cb2HQQpCmWNLMZZ63wEo1KjTQt+1BcQBr7k=", "narHash": "sha256-FOnZjElEI8pqqCvB6K/1JRHTE8o4rer8driivTpq2uo=",
"owner": "nix-community", "owner": "nix-community",
"repo": "home-manager", "repo": "home-manager",
"rev": "daa2c221320809f5514edde74d0ad0193ad54ed8", "rev": "9670de2921812bc4e0452f6e3efd8c859696c183",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -44,11 +44,11 @@
}, },
"nixos-hardware": { "nixos-hardware": {
"locked": { "locked": {
"lastModified": 1771969195, "lastModified": 1774018263,
"narHash": "sha256-qwcDBtrRvJbrrnv1lf/pREQi8t2hWZxVAyeMo7/E9sw=", "narHash": "sha256-HHYEwK1A22aSaxv2ibhMMkKvrDGKGlA/qObG4smrSqc=",
"owner": "nixos", "owner": "nixos",
"repo": "nixos-hardware", "repo": "nixos-hardware",
"rev": "41c6b421bdc301b2624486e11905c9af7b8ec68e", "rev": "2d4b4717b2534fad5c715968c1cece04a172b365",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -60,11 +60,11 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1772624091, "lastModified": 1773821835,
"narHash": "sha256-QKyJ0QGWBn6r0invrMAK8dmJoBYWoOWy7lN+UHzW1jc=", "narHash": "sha256-TJ3lSQtW0E2JrznGVm8hOQGVpXjJyXY2guAxku2O9A4=",
"owner": "nixos", "owner": "nixos",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "80bdc1e5ce51f56b19791b52b2901187931f5353", "rev": "b40629efe5d6ec48dd1efba650c797ddbd39ace0",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -76,11 +76,11 @@
}, },
"nixpkgs-master": { "nixpkgs-master": {
"locked": { "locked": {
"lastModified": 1772842888, "lastModified": 1774051532,
"narHash": "sha256-bQRYIwRb9xuEMHTLd5EzjHhYMKzbUbIo7abFV84iUjM=", "narHash": "sha256-d3CGMweyYIcPuTj5BKq+1Lx4zwlgL31nVtN647tOZKo=",
"owner": "nixos", "owner": "nixos",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "af5157af67f118e13172750f63012f199b61e3a1", "rev": "8620c0b5cc8fbe76502442181be1d0514bc3a1b7",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -125,11 +125,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1772495394, "lastModified": 1773889674,
"narHash": "sha256-hmIvE/slLKEFKNEJz27IZ8BKlAaZDcjIHmkZ7GCEjfw=", "narHash": "sha256-+ycaiVAk3MEshJTg35cBTUa0MizGiS+bgpYw/f8ohkg=",
"owner": "Mic92", "owner": "Mic92",
"repo": "sops-nix", "repo": "sops-nix",
"rev": "1d9b98a29a45abe9c4d3174bd36de9f28755e3ff", "rev": "29b6519f3e0780452bca0ac0be4584f04ac16cc5",
"type": "github" "type": "github"
}, },
"original": { "original": {

View File

@@ -1,73 +0,0 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

View File

@@ -1,23 +0,0 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

View File

@@ -1,13 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -1,31 +0,0 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.12.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
}
}

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -1,654 +0,0 @@
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: var(--color-bg);
color: var(--color-text);
}
.app {
max-width: 1000px;
margin: 0 auto;
padding: 20px;
}
nav {
display: flex;
align-items: center;
gap: 20px;
padding: 15px 0;
border-bottom: 1px solid var(--color-border);
margin-bottom: 20px;
}
.theme-toggle {
margin-left: auto;
}
nav a {
color: var(--color-primary);
text-decoration: none;
font-weight: 500;
}
nav a:hover {
text-decoration: underline;
}
main {
background: var(--color-bg-card);
padding: 20px;
border-radius: 8px;
box-shadow: var(--shadow);
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.header h1 {
margin: 0;
}
.btn {
display: inline-block;
padding: 8px 16px;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-bg-card);
color: var(--color-text);
text-decoration: none;
cursor: pointer;
font-size: 14px;
margin-left: 8px;
}
.btn:hover {
background: var(--color-bg-hover);
}
.btn-primary {
background: var(--color-primary);
border-color: var(--color-primary);
color: white;
}
.btn-primary:hover {
background: var(--color-primary-hover);
}
.btn-danger {
background: var(--color-danger);
border-color: var(--color-danger);
color: white;
}
.btn-danger:hover {
background: var(--color-danger-hover);
}
.btn-small {
padding: 4px 8px;
font-size: 12px;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
padding: 12px;
text-align: left;
border-bottom: 1px solid var(--color-border-light);
}
th {
font-weight: 600;
background: var(--color-bg-muted);
}
tr:hover {
background: var(--color-bg-muted);
}
.error {
background: var(--color-bg-error);
color: var(--color-text-error);
padding: 10px;
border-radius: 4px;
margin-bottom: 20px;
}
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 10px;
margin-bottom: 20px;
}
.section {
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid var(--color-border-light);
}
.section h3 {
margin-top: 0;
margin-bottom: 15px;
}
.section h4 {
margin: 15px 0 10px;
font-size: 14px;
color: var(--color-text-muted);
}
.section ul {
list-style: none;
padding: 0;
margin: 0;
}
.section li {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 0;
border-bottom: 1px solid var(--color-border-lighter);
}
.tag {
display: inline-block;
background: var(--color-tag-bg);
padding: 2px 8px;
border-radius: 12px;
font-size: 12px;
color: var(--color-text-muted);
}
.add-form {
display: flex;
gap: 10px;
margin-top: 15px;
flex-wrap: wrap;
}
.add-form select,
.add-form input {
padding: 8px;
border: 1px solid var(--color-border);
border-radius: 4px;
min-width: 200px;
background: var(--color-bg-card);
color: var(--color-text);
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
font-weight: 500;
margin-bottom: 5px;
}
.form-group input,
.form-group textarea,
.form-group select {
width: 100%;
padding: 10px;
border: 1px solid var(--color-border);
border-radius: 4px;
font-size: 14px;
background: var(--color-bg-card);
color: var(--color-text);
}
.form-group textarea {
resize: vertical;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
.checkbox-group {
display: flex;
flex-wrap: wrap;
gap: 15px;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 5px;
cursor: pointer;
}
.form-actions {
display: flex;
gap: 10px;
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid var(--color-border-light);
}
.need-list .header {
margin-bottom: 20px;
}
.need-form {
background: var(--color-bg-muted);
padding: 20px;
border-radius: 4px;
margin-bottom: 20px;
}
.need-items {
list-style: none;
padding: 0;
}
.need-items li {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 15px;
border: 1px solid var(--color-border-light);
border-radius: 4px;
margin-bottom: 10px;
}
.need-info p {
margin: 5px 0 0;
color: var(--color-text-muted);
font-size: 14px;
}
a {
color: var(--color-primary);
}
a:hover {
text-decoration: underline;
}
/* Graph styles */
.graph-container {
width: 100%;
}
.graph-hint {
color: var(--color-text-muted);
font-size: 14px;
margin-bottom: 15px;
}
.selected-info {
margin-top: 15px;
padding: 15px;
background: var(--color-bg-muted);
border-radius: 8px;
}
.selected-info h3 {
margin: 0 0 10px;
}
.selected-info p {
margin: 5px 0;
color: var(--color-text-muted);
}
.legend {
margin-top: 20px;
padding: 15px;
background: var(--color-bg-muted);
border-radius: 8px;
}
.legend h4 {
margin: 0 0 10px;
font-size: 14px;
}
.legend-items {
display: flex;
flex-wrap: wrap;
gap: 15px;
}
.legend-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--color-text-muted);
}
.legend-line {
width: 30px;
border-radius: 2px;
}
/* Weight control styles */
.weight-control {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--color-text-muted);
}
.weight-control input[type="range"] {
width: 80px;
cursor: pointer;
}
.weight-value {
min-width: 20px;
text-align: center;
font-weight: 600;
}
.weight-display {
font-size: 12px;
color: var(--color-text-muted);
margin-left: auto;
}
/* ID Card Styles */
.id-card {
width: 100%;
}
.id-card-inner {
background: linear-gradient(135deg, #0a0a0f 0%, #1a1a2e 50%, #0a0a0f 100%);
background-image:
radial-gradient(white 1px, transparent 1px),
linear-gradient(135deg, #0a0a0f 0%, #1a1a2e 50%, #0a0a0f 100%);
background-size: 50px 50px, 100% 100%;
background-position: 0 0, 0 0;
color: #fff;
border-radius: 12px;
padding: 25px;
min-height: 500px;
position: relative;
overflow: hidden;
}
.id-card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 15px;
}
.id-card-header-left {
flex: 1;
}
.id-card-header-right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 10px;
}
.id-card-title {
font-size: 2.5rem;
font-weight: 700;
margin: 0;
color: #fff;
text-shadow: 2px 2px 4px rgba(0,0,0,0.5);
}
.id-profile-pic {
width: 80px;
height: 80px;
border-radius: 8px;
object-fit: cover;
border: 2px solid rgba(255,255,255,0.3);
}
.id-profile-placeholder {
width: 80px;
height: 80px;
border-radius: 8px;
background: linear-gradient(135deg, #4ecdc4 0%, #44a8a0 100%);
display: flex;
align-items: center;
justify-content: center;
border: 2px solid rgba(255,255,255,0.3);
}
.id-profile-placeholder span {
font-size: 2rem;
font-weight: 700;
color: #fff;
text-shadow: 1px 1px 2px rgba(0,0,0,0.3);
}
.id-card-actions {
display: flex;
gap: 8px;
}
.id-card-actions .btn {
background: rgba(255,255,255,0.1);
border-color: rgba(255,255,255,0.3);
color: #fff;
}
.id-card-actions .btn:hover {
background: rgba(255,255,255,0.2);
}
.id-card-body {
display: grid;
grid-template-columns: 1fr 1.5fr;
gap: 30px;
}
.id-card-left {
display: flex;
flex-direction: column;
gap: 8px;
}
.id-field {
font-size: 1rem;
line-height: 1.4;
}
.id-field-block {
margin-top: 15px;
font-size: 0.95rem;
line-height: 1.5;
}
.id-label {
color: #4ecdc4;
font-weight: 500;
}
.id-card-right {
display: flex;
flex-direction: column;
gap: 20px;
}
.id-bio {
font-size: 0.9rem;
line-height: 1.6;
color: #e0e0e0;
}
.id-relationships {
margin-top: 10px;
}
.id-section-title {
font-size: 1.5rem;
margin: 0 0 15px;
color: #fff;
border-bottom: 1px solid rgba(255,255,255,0.2);
padding-bottom: 8px;
}
.id-rel-group {
margin-bottom: 12px;
font-size: 0.9rem;
line-height: 1.6;
}
.id-rel-label {
color: #a0a0a0;
}
.id-rel-group a {
color: #4ecdc4;
text-decoration: none;
}
.id-rel-group a:hover {
text-decoration: underline;
}
.id-rel-type {
color: #888;
font-size: 0.85em;
}
.id-card-warnings {
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid rgba(255,255,255,0.2);
display: flex;
flex-wrap: wrap;
gap: 20px;
}
.id-warning {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.9rem;
color: #ff6b6b;
}
.warning-dot {
width: 8px;
height: 8px;
background: #ff6b6b;
border-radius: 50%;
flex-shrink: 0;
}
.warning-desc {
color: #ccc;
}
/* Management section */
.id-card-manage {
margin-top: 20px;
background: var(--color-bg-muted);
border-radius: 8px;
padding: 15px;
}
.id-card-manage summary {
cursor: pointer;
font-weight: 600;
font-size: 1.1rem;
padding: 5px 0;
}
.id-card-manage[open] summary {
margin-bottom: 15px;
border-bottom: 1px solid var(--color-border-light);
padding-bottom: 10px;
}
.manage-section {
margin-bottom: 25px;
}
.manage-section h3 {
margin: 0 0 15px;
font-size: 1rem;
}
.manage-relationships {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 15px;
}
.manage-rel-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px;
background: var(--color-bg-card);
border-radius: 6px;
flex-wrap: wrap;
}
.manage-rel-item a {
font-weight: 500;
min-width: 120px;
}
.manage-needs-list {
list-style: none;
padding: 0;
margin: 0 0 15px;
}
.manage-needs-list li {
display: flex;
align-items: center;
gap: 12px;
padding: 10px;
background: var(--color-bg-card);
border-radius: 6px;
margin-bottom: 8px;
}
.manage-needs-list li .btn {
margin-left: auto;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.id-card-body {
grid-template-columns: 1fr;
}
.id-card-title {
font-size: 1.8rem;
}
.id-card-header {
flex-direction: column;
gap: 15px;
}
}

View File

@@ -1,50 +0,0 @@
import { useEffect, useState } from "react";
import { Link, Route, Routes } from "react-router-dom";
import { ContactDetail } from "./components/ContactDetail";
import { ContactForm } from "./components/ContactForm";
import { ContactList } from "./components/ContactList";
import { NeedList } from "./components/NeedList";
import { RelationshipGraph } from "./components/RelationshipGraph";
import "./App.css";
function App() {
const [theme, setTheme] = useState<"light" | "dark">(() => {
return (localStorage.getItem("theme") as "light" | "dark") || "light";
});
useEffect(() => {
document.documentElement.setAttribute("data-theme", theme);
localStorage.setItem("theme", theme);
}, [theme]);
const toggleTheme = () => {
setTheme((prev) => (prev === "light" ? "dark" : "light"));
};
return (
<div className="app">
<nav>
<Link to="/contacts">Contacts</Link>
<Link to="/graph">Graph</Link>
<Link to="/needs">Needs</Link>
<button className="btn btn-small theme-toggle" onClick={toggleTheme}>
{theme === "light" ? "Dark" : "Light"}
</button>
</nav>
<main>
<Routes>
<Route path="/" element={<ContactList />} />
<Route path="/contacts" element={<ContactList />} />
<Route path="/contacts/new" element={<ContactForm />} />
<Route path="/contacts/:id" element={<ContactDetail />} />
<Route path="/contacts/:id/edit" element={<ContactForm />} />
<Route path="/graph" element={<RelationshipGraph />} />
<Route path="/needs" element={<NeedList />} />
</Routes>
</main>
</div>
);
}
export default App;

View File

@@ -1,105 +0,0 @@
import type {
Contact,
ContactCreate,
ContactListItem,
ContactRelationship,
ContactRelationshipCreate,
ContactRelationshipUpdate,
ContactUpdate,
GraphData,
Need,
NeedCreate,
} from "../types";
const API_BASE = "";
async function request<T>(
endpoint: string,
options?: RequestInit
): Promise<T> {
const response = await fetch(`${API_BASE}${endpoint}`, {
...options,
headers: {
"Content-Type": "application/json",
...options?.headers,
},
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.detail || `HTTP ${response.status}`);
}
return response.json();
}
export const api = {
// Needs
needs: {
list: () => request<Need[]>("/api/needs"),
get: (id: number) => request<Need>(`/api/needs/${id}`),
create: (data: NeedCreate) =>
request<Need>("/api/needs", {
method: "POST",
body: JSON.stringify(data),
}),
delete: (id: number) =>
request<{ deleted: boolean }>(`/api/needs/${id}`, { method: "DELETE" }),
},
// Contacts
contacts: {
list: (skip = 0, limit = 100) =>
request<ContactListItem[]>(`/api/contacts?skip=${skip}&limit=${limit}`),
get: (id: number) => request<Contact>(`/api/contacts/${id}`),
create: (data: ContactCreate) =>
request<Contact>("/api/contacts", {
method: "POST",
body: JSON.stringify(data),
}),
update: (id: number, data: ContactUpdate) =>
request<Contact>(`/api/contacts/${id}`, {
method: "PATCH",
body: JSON.stringify(data),
}),
delete: (id: number) =>
request<{ deleted: boolean }>(`/api/contacts/${id}`, { method: "DELETE" }),
// Contact-Need relationships
addNeed: (contactId: number, needId: number) =>
request<{ added: boolean }>(`/api/contacts/${contactId}/needs/${needId}`, {
method: "POST",
}),
removeNeed: (contactId: number, needId: number) =>
request<{ removed: boolean }>(`/api/contacts/${contactId}/needs/${needId}`, {
method: "DELETE",
}),
// Contact-Contact relationships
getRelationships: (contactId: number) =>
request<ContactRelationship[]>(`/api/contacts/${contactId}/relationships`),
addRelationship: (contactId: number, data: ContactRelationshipCreate) =>
request<ContactRelationship>(`/api/contacts/${contactId}/relationships`, {
method: "POST",
body: JSON.stringify(data),
}),
updateRelationship: (contactId: number, relatedContactId: number, data: ContactRelationshipUpdate) =>
request<ContactRelationship>(
`/api/contacts/${contactId}/relationships/${relatedContactId}`,
{
method: "PATCH",
body: JSON.stringify(data),
}
),
removeRelationship: (contactId: number, relatedContactId: number) =>
request<{ deleted: boolean }>(
`/api/contacts/${contactId}/relationships/${relatedContactId}`,
{ method: "DELETE" }
),
},
// Graph
graph: {
get: () => request<GraphData>("/api/graph"),
},
};

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -1,456 +0,0 @@
import { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { api } from "../api/client";
import type { Contact, ContactListItem, Need, RelationshipTypeValue } from "../types";
import { RELATIONSHIP_TYPES } from "../types";
export function ContactDetail() {
const { id } = useParams<{ id: string }>();
const [contact, setContact] = useState<Contact | null>(null);
const [allNeeds, setAllNeeds] = useState<Need[]>([]);
const [allContacts, setAllContacts] = useState<ContactListItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [newNeedId, setNewNeedId] = useState<number | "">("");
const [newRelContactId, setNewRelContactId] = useState<number | "">("");
const [newRelType, setNewRelType] = useState<RelationshipTypeValue | "">("");
useEffect(() => {
if (!id) return;
Promise.all([
api.contacts.get(Number(id)),
api.needs.list(),
api.contacts.list(),
])
.then(([c, n, contacts]) => {
setContact(c);
setAllNeeds(n);
setAllContacts(contacts.filter((ct) => ct.id !== Number(id)));
})
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, [id]);
const handleAddNeed = async () => {
if (!contact || newNeedId === "") return;
try {
await api.contacts.addNeed(contact.id, Number(newNeedId));
const updated = await api.contacts.get(contact.id);
setContact(updated);
setNewNeedId("");
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to add need");
}
};
const handleRemoveNeed = async (needId: number) => {
if (!contact) return;
try {
await api.contacts.removeNeed(contact.id, needId);
const updated = await api.contacts.get(contact.id);
setContact(updated);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to remove need");
}
};
const handleAddRelationship = async () => {
if (!contact || newRelContactId === "" || newRelType === "") return;
try {
await api.contacts.addRelationship(contact.id, {
related_contact_id: Number(newRelContactId),
relationship_type: newRelType,
});
const updated = await api.contacts.get(contact.id);
setContact(updated);
setNewRelContactId("");
setNewRelType("");
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to add relationship"
);
}
};
const handleRemoveRelationship = async (relatedContactId: number) => {
if (!contact) return;
try {
await api.contacts.removeRelationship(contact.id, relatedContactId);
const updated = await api.contacts.get(contact.id);
setContact(updated);
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to remove relationship"
);
}
};
const handleUpdateWeight = async (relatedContactId: number, newWeight: number) => {
if (!contact) return;
try {
await api.contacts.updateRelationship(contact.id, relatedContactId, {
closeness_weight: newWeight,
});
const updated = await api.contacts.get(contact.id);
setContact(updated);
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to update weight"
);
}
};
if (loading) return <div>Loading...</div>;
if (error) return <div className="error">Error: {error}</div>;
if (!contact) return <div>Contact not found</div>;
const availableNeeds = allNeeds.filter(
(n) => !contact.needs.some((cn) => cn.id === n.id)
);
const getContactName = (contactId: number) => {
const c = allContacts.find((ct) => ct.id === contactId);
return c?.name || `Contact #${contactId}`;
};
const getRelationshipDisplayName = (type: string) => {
const rt = RELATIONSHIP_TYPES.find((r) => r.value === type);
return rt?.displayName || type;
};
// Group relationships by category for display
const groupRelationships = () => {
const familial: typeof contact.related_to = [];
const friends: typeof contact.related_to = [];
const partners: typeof contact.related_to = [];
const professional: typeof contact.related_to = [];
const other: typeof contact.related_to = [];
const familialTypes = ['parent', 'child', 'sibling', 'grandparent', 'grandchild', 'aunt_uncle', 'niece_nephew', 'cousin', 'in_law'];
const friendTypes = ['best_friend', 'close_friend', 'friend', 'acquaintance', 'neighbor'];
const partnerTypes = ['spouse', 'partner'];
const professionalTypes = ['mentor', 'mentee', 'business_partner', 'colleague', 'manager', 'direct_report', 'client'];
for (const rel of contact.related_to) {
if (familialTypes.includes(rel.relationship_type)) {
familial.push(rel);
} else if (friendTypes.includes(rel.relationship_type)) {
friends.push(rel);
} else if (partnerTypes.includes(rel.relationship_type)) {
partners.push(rel);
} else if (professionalTypes.includes(rel.relationship_type)) {
professional.push(rel);
} else {
other.push(rel);
}
}
return { familial, friends, partners, professional, other };
};
const relationshipGroups = groupRelationships();
return (
<div className="id-card">
<div className="id-card-inner">
{/* Header with name and profile pic */}
<div className="id-card-header">
<div className="id-card-header-left">
<h1 className="id-card-title">I.D.: {contact.name}</h1>
</div>
<div className="id-card-header-right">
{contact.profile_pic ? (
<img
src={contact.profile_pic}
alt={`${contact.name}'s profile`}
className="id-profile-pic"
/>
) : (
<div className="id-profile-placeholder">
<span>{contact.name.charAt(0).toUpperCase()}</span>
</div>
)}
<div className="id-card-actions">
<Link to={`/contacts/${contact.id}/edit`} className="btn btn-small">
Edit
</Link>
<Link to="/contacts" className="btn btn-small">
Back
</Link>
</div>
</div>
</div>
<div className="id-card-body">
{/* Left column - Basic info */}
<div className="id-card-left">
{contact.legal_name && (
<div className="id-field">Legal name: {contact.legal_name}</div>
)}
{contact.suffix && (
<div className="id-field">Suffix: {contact.suffix}</div>
)}
{contact.gender && (
<div className="id-field">Gender: {contact.gender}</div>
)}
{contact.age && (
<div className="id-field">Age: {contact.age}</div>
)}
{contact.current_job && (
<div className="id-field">Job: {contact.current_job}</div>
)}
{contact.social_structure_style && (
<div className="id-field">Social style: {contact.social_structure_style}</div>
)}
{contact.self_sufficiency_score !== null && (
<div className="id-field">Self-Sufficiency: {contact.self_sufficiency_score}</div>
)}
{contact.timezone && (
<div className="id-field">Timezone: {contact.timezone}</div>
)}
{contact.safe_conversation_starters && (
<div className="id-field-block">
<span className="id-label">Safe con starters:</span> {contact.safe_conversation_starters}
</div>
)}
{contact.topics_to_avoid && (
<div className="id-field-block">
<span className="id-label">Topics to avoid:</span> {contact.topics_to_avoid}
</div>
)}
{contact.goals && (
<div className="id-field-block">
<span className="id-label">Goals:</span> {contact.goals}
</div>
)}
</div>
{/* Right column - Bio and Relationships */}
<div className="id-card-right">
{contact.bio && (
<div className="id-bio">
<span className="id-label">Bio:</span> {contact.bio}
</div>
)}
<div className="id-relationships">
<h2 className="id-section-title">Relationships</h2>
{relationshipGroups.familial.length > 0 && (
<div className="id-rel-group">
<span className="id-rel-label">Familial:</span>{" "}
{relationshipGroups.familial.map((rel, i) => (
<span key={rel.related_contact_id}>
<Link to={`/contacts/${rel.related_contact_id}`}>
{getContactName(rel.related_contact_id)}
</Link>
<span className="id-rel-type">({getRelationshipDisplayName(rel.relationship_type)})</span>
{i < relationshipGroups.familial.length - 1 && ", "}
</span>
))}
</div>
)}
{relationshipGroups.partners.length > 0 && (
<div className="id-rel-group">
<span className="id-rel-label">Partners:</span>{" "}
{relationshipGroups.partners.map((rel, i) => (
<span key={rel.related_contact_id}>
<Link to={`/contacts/${rel.related_contact_id}`}>
{getContactName(rel.related_contact_id)}
</Link>
{i < relationshipGroups.partners.length - 1 && ", "}
</span>
))}
</div>
)}
{relationshipGroups.friends.length > 0 && (
<div className="id-rel-group">
<span className="id-rel-label">Friends:</span>{" "}
{relationshipGroups.friends.map((rel, i) => (
<span key={rel.related_contact_id}>
<Link to={`/contacts/${rel.related_contact_id}`}>
{getContactName(rel.related_contact_id)}
</Link>
{i < relationshipGroups.friends.length - 1 && ", "}
</span>
))}
</div>
)}
{relationshipGroups.professional.length > 0 && (
<div className="id-rel-group">
<span className="id-rel-label">Professional:</span>{" "}
{relationshipGroups.professional.map((rel, i) => (
<span key={rel.related_contact_id}>
<Link to={`/contacts/${rel.related_contact_id}`}>
{getContactName(rel.related_contact_id)}
</Link>
<span className="id-rel-type">({getRelationshipDisplayName(rel.relationship_type)})</span>
{i < relationshipGroups.professional.length - 1 && ", "}
</span>
))}
</div>
)}
{relationshipGroups.other.length > 0 && (
<div className="id-rel-group">
<span className="id-rel-label">Other:</span>{" "}
{relationshipGroups.other.map((rel, i) => (
<span key={rel.related_contact_id}>
<Link to={`/contacts/${rel.related_contact_id}`}>
{getContactName(rel.related_contact_id)}
</Link>
<span className="id-rel-type">({getRelationshipDisplayName(rel.relationship_type)})</span>
{i < relationshipGroups.other.length - 1 && ", "}
</span>
))}
</div>
)}
{contact.related_from.length > 0 && (
<div className="id-rel-group">
<span className="id-rel-label">Known by:</span>{" "}
{contact.related_from.map((rel, i) => (
<span key={rel.contact_id}>
<Link to={`/contacts/${rel.contact_id}`}>
{getContactName(rel.contact_id)}
</Link>
{i < contact.related_from.length - 1 && ", "}
</span>
))}
</div>
)}
</div>
</div>
</div>
{/* Needs/Warnings at bottom */}
{contact.needs.length > 0 && (
<div className="id-card-warnings">
{contact.needs.map((need) => (
<div key={need.id} className="id-warning">
<span className="warning-dot"></span>
Warning: {need.name}
{need.description && <span className="warning-desc"> - {need.description}</span>}
</div>
))}
</div>
)}
</div>
{/* Management section (expandable) */}
<details className="id-card-manage">
<summary>Manage Contact</summary>
<div className="manage-section">
<h3>Manage Relationships</h3>
<div className="manage-relationships">
{contact.related_to.map((rel) => (
<div key={rel.related_contact_id} className="manage-rel-item">
<Link to={`/contacts/${rel.related_contact_id}`}>
{getContactName(rel.related_contact_id)}
</Link>
<span className="tag">{getRelationshipDisplayName(rel.relationship_type)}</span>
<label className="weight-control">
<span>Closeness:</span>
<input
type="range"
min="1"
max="10"
value={rel.closeness_weight}
onChange={(e) => handleUpdateWeight(rel.related_contact_id, Number(e.target.value))}
/>
<span className="weight-value">{rel.closeness_weight}</span>
</label>
<button
onClick={() => handleRemoveRelationship(rel.related_contact_id)}
className="btn btn-small btn-danger"
>
Remove
</button>
</div>
))}
</div>
{allContacts.length > 0 && (
<div className="add-form">
<select
value={newRelContactId}
onChange={(e) =>
setNewRelContactId(
e.target.value ? Number(e.target.value) : ""
)
}
>
<option value="">Select contact...</option>
{allContacts.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
<select
value={newRelType}
onChange={(e) => setNewRelType(e.target.value as RelationshipTypeValue | "")}
>
<option value="">Select relationship type...</option>
{RELATIONSHIP_TYPES.map((rt) => (
<option key={rt.value} value={rt.value}>
{rt.displayName}
</option>
))}
</select>
<button onClick={handleAddRelationship} className="btn btn-primary">
Add Relationship
</button>
</div>
)}
</div>
<div className="manage-section">
<h3>Manage Needs/Warnings</h3>
<ul className="manage-needs-list">
{contact.needs.map((need) => (
<li key={need.id}>
<strong>{need.name}</strong>
{need.description && <span> - {need.description}</span>}
<button
onClick={() => handleRemoveNeed(need.id)}
className="btn btn-small btn-danger"
>
Remove
</button>
</li>
))}
</ul>
{availableNeeds.length > 0 && (
<div className="add-form">
<select
value={newNeedId}
onChange={(e) =>
setNewNeedId(e.target.value ? Number(e.target.value) : "")
}
>
<option value="">Select a need...</option>
{availableNeeds.map((n) => (
<option key={n.id} value={n.id}>
{n.name}
</option>
))}
</select>
<button onClick={handleAddNeed} className="btn btn-primary">
Add Need
</button>
</div>
)}
</div>
</details>
</div>
);
}

View File

@@ -1,325 +0,0 @@
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { api } from "../api/client";
import type { ContactCreate, Need } from "../types";
export function ContactForm() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const isEdit = Boolean(id);
const [allNeeds, setAllNeeds] = useState<Need[]>([]);
const [loading, setLoading] = useState(isEdit);
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [form, setForm] = useState<ContactCreate>({
name: "",
age: null,
bio: null,
current_job: null,
gender: null,
goals: null,
legal_name: null,
profile_pic: null,
safe_conversation_starters: null,
self_sufficiency_score: null,
social_structure_style: null,
ssn: null,
suffix: null,
timezone: null,
topics_to_avoid: null,
need_ids: [],
});
useEffect(() => {
const loadData = async () => {
try {
const needs = await api.needs.list();
setAllNeeds(needs);
if (id) {
const contact = await api.contacts.get(Number(id));
setForm({
name: contact.name,
age: contact.age,
bio: contact.bio,
current_job: contact.current_job,
gender: contact.gender,
goals: contact.goals,
legal_name: contact.legal_name,
profile_pic: contact.profile_pic,
safe_conversation_starters: contact.safe_conversation_starters,
self_sufficiency_score: contact.self_sufficiency_score,
social_structure_style: contact.social_structure_style,
ssn: contact.ssn,
suffix: contact.suffix,
timezone: contact.timezone,
topics_to_avoid: contact.topics_to_avoid,
need_ids: contact.needs.map((n) => n.id),
});
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load data");
} finally {
setLoading(false);
}
};
loadData();
}, [id]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSubmitting(true);
setError(null);
try {
if (isEdit) {
await api.contacts.update(Number(id), form);
navigate(`/contacts/${id}`);
} else {
const created = await api.contacts.create(form);
navigate(`/contacts/${created.id}`);
}
} catch (err) {
setError(err instanceof Error ? err.message : "Save failed");
setSubmitting(false);
}
};
const updateField = <K extends keyof ContactCreate>(
field: K,
value: ContactCreate[K]
) => {
setForm((prev) => ({ ...prev, [field]: value }));
};
const toggleNeed = (needId: number) => {
setForm((prev) => ({
...prev,
need_ids: prev.need_ids?.includes(needId)
? prev.need_ids.filter((id) => id !== needId)
: [...(prev.need_ids || []), needId],
}));
};
if (loading) return <div>Loading...</div>;
return (
<div className="contact-form">
<h1>{isEdit ? "Edit Contact" : "New Contact"}</h1>
{error && <div className="error">{error}</div>}
<form onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="name">Name *</label>
<input
id="name"
type="text"
value={form.name}
onChange={(e) => updateField("name", e.target.value)}
required
/>
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="legal_name">Legal Name</label>
<input
id="legal_name"
type="text"
value={form.legal_name || ""}
onChange={(e) =>
updateField("legal_name", e.target.value || null)
}
/>
</div>
<div className="form-group">
<label htmlFor="suffix">Suffix</label>
<input
id="suffix"
type="text"
value={form.suffix || ""}
onChange={(e) => updateField("suffix", e.target.value || null)}
/>
</div>
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="age">Age</label>
<input
id="age"
type="number"
value={form.age ?? ""}
onChange={(e) =>
updateField("age", e.target.value ? Number(e.target.value) : null)
}
/>
</div>
<div className="form-group">
<label htmlFor="gender">Gender</label>
<input
id="gender"
type="text"
value={form.gender || ""}
onChange={(e) => updateField("gender", e.target.value || null)}
/>
</div>
</div>
<div className="form-group">
<label htmlFor="current_job">Current Job</label>
<input
id="current_job"
type="text"
value={form.current_job || ""}
onChange={(e) =>
updateField("current_job", e.target.value || null)
}
/>
</div>
<div className="form-group">
<label htmlFor="timezone">Timezone</label>
<input
id="timezone"
type="text"
value={form.timezone || ""}
onChange={(e) => updateField("timezone", e.target.value || null)}
/>
</div>
<div className="form-group">
<label htmlFor="profile_pic">Profile Picture URL</label>
<input
id="profile_pic"
type="url"
placeholder="https://example.com/photo.jpg"
value={form.profile_pic || ""}
onChange={(e) => updateField("profile_pic", e.target.value || null)}
/>
</div>
<div className="form-group">
<label htmlFor="bio">Bio</label>
<textarea
id="bio"
value={form.bio || ""}
onChange={(e) => updateField("bio", e.target.value || null)}
rows={3}
/>
</div>
<div className="form-group">
<label htmlFor="goals">Goals</label>
<textarea
id="goals"
value={form.goals || ""}
onChange={(e) => updateField("goals", e.target.value || null)}
rows={3}
/>
</div>
<div className="form-group">
<label htmlFor="social_structure_style">Social Structure Style</label>
<input
id="social_structure_style"
type="text"
value={form.social_structure_style || ""}
onChange={(e) =>
updateField("social_structure_style", e.target.value || null)
}
/>
</div>
<div className="form-group">
<label htmlFor="self_sufficiency_score">
Self-Sufficiency Score (1-10)
</label>
<input
id="self_sufficiency_score"
type="number"
min="1"
max="10"
value={form.self_sufficiency_score ?? ""}
onChange={(e) =>
updateField(
"self_sufficiency_score",
e.target.value ? Number(e.target.value) : null
)
}
/>
</div>
<div className="form-group">
<label htmlFor="safe_conversation_starters">
Safe Conversation Starters
</label>
<textarea
id="safe_conversation_starters"
value={form.safe_conversation_starters || ""}
onChange={(e) =>
updateField("safe_conversation_starters", e.target.value || null)
}
rows={2}
/>
</div>
<div className="form-group">
<label htmlFor="topics_to_avoid">Topics to Avoid</label>
<textarea
id="topics_to_avoid"
value={form.topics_to_avoid || ""}
onChange={(e) =>
updateField("topics_to_avoid", e.target.value || null)
}
rows={2}
/>
</div>
<div className="form-group">
<label htmlFor="ssn">SSN</label>
<input
id="ssn"
type="text"
value={form.ssn || ""}
onChange={(e) => updateField("ssn", e.target.value || null)}
/>
</div>
{allNeeds.length > 0 && (
<div className="form-group">
<label>Needs/Accommodations</label>
<div className="checkbox-group">
{allNeeds.map((need) => (
<label key={need.id} className="checkbox-label">
<input
type="checkbox"
checked={form.need_ids?.includes(need.id) || false}
onChange={() => toggleNeed(need.id)}
/>
{need.name}
</label>
))}
</div>
</div>
)}
<div className="form-actions">
<button type="submit" className="btn btn-primary" disabled={submitting}>
{submitting ? "Saving..." : "Save"}
</button>
<button
type="button"
className="btn"
onClick={() => navigate(isEdit ? `/contacts/${id}` : "/contacts")}
>
Cancel
</button>
</div>
</form>
</div>
);
}

View File

@@ -1,79 +0,0 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { api } from "../api/client";
import type { ContactListItem } from "../types";
export function ContactList() {
const [contacts, setContacts] = useState<ContactListItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
api.contacts
.list()
.then(setContacts)
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, []);
const handleDelete = async (id: number) => {
if (!confirm("Delete this contact?")) return;
try {
await api.contacts.delete(id);
setContacts((prev) => prev.filter((c) => c.id !== id));
} catch (err) {
setError(err instanceof Error ? err.message : "Delete failed");
}
};
if (loading) return <div>Loading...</div>;
if (error) return <div className="error">Error: {error}</div>;
return (
<div className="contact-list">
<div className="header">
<h1>Contacts</h1>
<Link to="/contacts/new" className="btn btn-primary">
Add Contact
</Link>
</div>
{contacts.length === 0 ? (
<p>No contacts yet.</p>
) : (
<table>
<thead>
<tr>
<th>Name</th>
<th>Job</th>
<th>Timezone</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{contacts.map((contact) => (
<tr key={contact.id}>
<td>
<Link to={`/contacts/${contact.id}`}>{contact.name}</Link>
</td>
<td>{contact.current_job || "-"}</td>
<td>{contact.timezone || "-"}</td>
<td>
<Link to={`/contacts/${contact.id}/edit`} className="btn">
Edit
</Link>
<button
onClick={() => handleDelete(contact.id)}
className="btn btn-danger"
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
}

View File

@@ -1,117 +0,0 @@
import { useEffect, useState } from "react";
import { api } from "../api/client";
import type { Need, NeedCreate } from "../types";
export function NeedList() {
const [needs, setNeeds] = useState<Need[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showForm, setShowForm] = useState(false);
const [form, setForm] = useState<NeedCreate>({ name: "", description: null });
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
api.needs
.list()
.then(setNeeds)
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!form.name.trim()) return;
setSubmitting(true);
try {
const created = await api.needs.create(form);
setNeeds((prev) => [...prev, created]);
setForm({ name: "", description: null });
setShowForm(false);
} catch (err) {
setError(err instanceof Error ? err.message : "Create failed");
} finally {
setSubmitting(false);
}
};
const handleDelete = async (id: number) => {
if (!confirm("Delete this need?")) return;
try {
await api.needs.delete(id);
setNeeds((prev) => prev.filter((n) => n.id !== id));
} catch (err) {
setError(err instanceof Error ? err.message : "Delete failed");
}
};
if (loading) return <div>Loading...</div>;
return (
<div className="need-list">
<div className="header">
<h1>Needs / Accommodations</h1>
<button
onClick={() => setShowForm(!showForm)}
className="btn btn-primary"
>
{showForm ? "Cancel" : "Add Need"}
</button>
</div>
{error && <div className="error">{error}</div>}
{showForm && (
<form onSubmit={handleSubmit} className="need-form">
<div className="form-group">
<label htmlFor="name">Name *</label>
<input
id="name"
type="text"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder="e.g., Light Sensitive, ADHD"
required
/>
</div>
<div className="form-group">
<label htmlFor="description">Description</label>
<textarea
id="description"
value={form.description || ""}
onChange={(e) =>
setForm({ ...form, description: e.target.value || null })
}
placeholder="Optional description..."
rows={2}
/>
</div>
<button type="submit" className="btn btn-primary" disabled={submitting}>
{submitting ? "Creating..." : "Create"}
</button>
</form>
)}
{needs.length === 0 ? (
<p>No needs defined yet.</p>
) : (
<ul className="need-items">
{needs.map((need) => (
<li key={need.id}>
<div className="need-info">
<strong>{need.name}</strong>
{need.description && <p>{need.description}</p>}
</div>
<button
onClick={() => handleDelete(need.id)}
className="btn btn-danger"
>
Delete
</button>
</li>
))}
</ul>
)}
</div>
);
}

View File

@@ -1,330 +0,0 @@
import { useEffect, useRef, useState } from "react";
import { api } from "../api/client";
import type { GraphData, GraphEdge, GraphNode } from "../types";
import { RELATIONSHIP_TYPES } from "../types";
interface SimNode extends GraphNode {
x: number;
y: number;
vx: number;
vy: number;
}
interface SimEdge extends GraphEdge {
sourceNode: SimNode;
targetNode: SimNode;
}
export function RelationshipGraph() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [data, setData] = useState<GraphData | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [selectedNode, setSelectedNode] = useState<SimNode | null>(null);
const nodesRef = useRef<SimNode[]>([]);
const edgesRef = useRef<SimEdge[]>([]);
const dragNodeRef = useRef<SimNode | null>(null);
const animationRef = useRef<number>(0);
useEffect(() => {
api.graph.get()
.then(setData)
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, []);
useEffect(() => {
if (!data || !canvasRef.current) return;
const canvas = canvasRef.current;
const maybeCtx = canvas.getContext("2d");
if (!maybeCtx) return;
const ctx: CanvasRenderingContext2D = maybeCtx;
const width = canvas.width;
const height = canvas.height;
const centerX = width / 2;
const centerY = height / 2;
// Initialize nodes with random positions
const nodes: SimNode[] = data.nodes.map((node) => ({
...node,
x: centerX + (Math.random() - 0.5) * 300,
y: centerY + (Math.random() - 0.5) * 300,
vx: 0,
vy: 0,
}));
nodesRef.current = nodes;
const nodeMap = new Map(nodes.map((n) => [n.id, n]));
// Create edges with node references
const edges: SimEdge[] = data.edges
.map((edge) => {
const sourceNode = nodeMap.get(edge.source);
const targetNode = nodeMap.get(edge.target);
if (!sourceNode || !targetNode) return null;
return { ...edge, sourceNode, targetNode };
})
.filter((e): e is SimEdge => e !== null);
edgesRef.current = edges;
// Force simulation parameters
const repulsion = 5000;
const springStrength = 0.05;
const baseSpringLength = 150;
const damping = 0.9;
const centerPull = 0.01;
function simulate() {
const nodes = nodesRef.current;
const edges = edgesRef.current;
// Reset forces
for (const node of nodes) {
node.vx = 0;
node.vy = 0;
}
// Repulsion between all nodes
for (let i = 0; i < nodes.length; i++) {
for (let j = i + 1; j < nodes.length; j++) {
const dx = nodes[j].x - nodes[i].x;
const dy = nodes[j].y - nodes[i].y;
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
const force = repulsion / (dist * dist);
const fx = (dx / dist) * force;
const fy = (dy / dist) * force;
nodes[i].vx -= fx;
nodes[i].vy -= fy;
nodes[j].vx += fx;
nodes[j].vy += fy;
}
}
// Spring forces for edges - closer relationships = shorter springs
// Weight is 1-10, normalize to 0-1 for calculations
for (const edge of edges) {
const dx = edge.targetNode.x - edge.sourceNode.x;
const dy = edge.targetNode.y - edge.sourceNode.y;
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
// Higher weight (1-10) = shorter ideal length
// Normalize: weight 10 -> 0.5x length, weight 1 -> 1.4x length
const normalizedWeight = edge.closeness_weight / 10;
const idealLength = baseSpringLength * (1.5 - normalizedWeight);
const displacement = dist - idealLength;
const force = springStrength * displacement;
const fx = (dx / dist) * force;
const fy = (dy / dist) * force;
edge.sourceNode.vx += fx;
edge.sourceNode.vy += fy;
edge.targetNode.vx -= fx;
edge.targetNode.vy -= fy;
}
// Pull toward center
for (const node of nodes) {
node.vx += (centerX - node.x) * centerPull;
node.vy += (centerY - node.y) * centerPull;
}
// Apply velocities with damping (skip dragged node)
for (const node of nodes) {
if (node === dragNodeRef.current) continue;
node.x += node.vx * damping;
node.y += node.vy * damping;
// Keep within bounds
node.x = Math.max(30, Math.min(width - 30, node.x));
node.y = Math.max(30, Math.min(height - 30, node.y));
}
}
function getEdgeColor(weight: number): string {
// Interpolate from light gray (distant) to dark blue (close)
// weight is 1-10, normalize to 0-1
const normalized = weight / 10;
const hue = 220;
const saturation = 70;
const lightness = 80 - normalized * 40;
return `hsl(${hue}, ${saturation}%, ${lightness}%)`;
}
function draw(context: CanvasRenderingContext2D) {
const nodes = nodesRef.current;
const edges = edgesRef.current;
context.clearRect(0, 0, width, height);
// Draw edges
for (const edge of edges) {
// Weight is 1-10, scale line width accordingly
const lineWidth = 1 + (edge.closeness_weight / 10) * 3;
context.strokeStyle = getEdgeColor(edge.closeness_weight);
context.lineWidth = lineWidth;
context.beginPath();
context.moveTo(edge.sourceNode.x, edge.sourceNode.y);
context.lineTo(edge.targetNode.x, edge.targetNode.y);
context.stroke();
// Draw relationship type label at midpoint
const midX = (edge.sourceNode.x + edge.targetNode.x) / 2;
const midY = (edge.sourceNode.y + edge.targetNode.y) / 2;
context.fillStyle = "#666";
context.font = "10px sans-serif";
context.textAlign = "center";
const typeInfo = RELATIONSHIP_TYPES.find(t => t.value === edge.relationship_type);
const label = typeInfo?.displayName || edge.relationship_type;
context.fillText(label, midX, midY - 5);
}
// Draw nodes
for (const node of nodes) {
const isSelected = node === selectedNode;
const radius = isSelected ? 25 : 20;
// Node circle
context.beginPath();
context.arc(node.x, node.y, radius, 0, Math.PI * 2);
context.fillStyle = isSelected ? "#0066cc" : "#fff";
context.fill();
context.strokeStyle = "#0066cc";
context.lineWidth = 2;
context.stroke();
// Node label
context.fillStyle = isSelected ? "#fff" : "#333";
context.font = "12px sans-serif";
context.textAlign = "center";
context.textBaseline = "middle";
const name = node.name.length > 10 ? node.name.slice(0, 9) + "…" : node.name;
context.fillText(name, node.x, node.y);
}
}
function animate() {
simulate();
draw(ctx);
animationRef.current = requestAnimationFrame(animate);
}
animate();
return () => {
cancelAnimationFrame(animationRef.current);
};
}, [data, selectedNode]);
// Mouse interaction handlers
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
function getNodeAtPosition(x: number, y: number): SimNode | null {
for (const node of nodesRef.current) {
const dx = x - node.x;
const dy = y - node.y;
if (dx * dx + dy * dy < 400) {
return node;
}
}
return null;
}
function handleMouseDown(e: MouseEvent) {
const rect = canvas!.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const node = getNodeAtPosition(x, y);
if (node) {
dragNodeRef.current = node;
setSelectedNode(node);
}
}
function handleMouseMove(e: MouseEvent) {
if (!dragNodeRef.current) return;
const rect = canvas!.getBoundingClientRect();
dragNodeRef.current.x = e.clientX - rect.left;
dragNodeRef.current.y = e.clientY - rect.top;
}
function handleMouseUp() {
dragNodeRef.current = null;
}
canvas.addEventListener("mousedown", handleMouseDown);
canvas.addEventListener("mousemove", handleMouseMove);
canvas.addEventListener("mouseup", handleMouseUp);
canvas.addEventListener("mouseleave", handleMouseUp);
return () => {
canvas.removeEventListener("mousedown", handleMouseDown);
canvas.removeEventListener("mousemove", handleMouseMove);
canvas.removeEventListener("mouseup", handleMouseUp);
canvas.removeEventListener("mouseleave", handleMouseUp);
};
}, []);
if (loading) return <p>Loading graph...</p>;
if (error) return <div className="error">{error}</div>;
if (!data) return <p>No data available</p>;
return (
<div className="graph-container">
<div className="header">
<h1>Relationship Graph</h1>
</div>
<p className="graph-hint">
Drag nodes to reposition. Closer relationships have shorter, darker edges.
</p>
<canvas
ref={canvasRef}
width={900}
height={600}
style={{
border: "1px solid var(--color-border)",
borderRadius: "8px",
background: "var(--color-bg)",
cursor: "grab",
}}
/>
{selectedNode && (
<div className="selected-info">
<h3>{selectedNode.name}</h3>
{selectedNode.current_job && <p>Job: {selectedNode.current_job}</p>}
<a href={`/contacts/${selectedNode.id}`}>View details</a>
</div>
)}
<div className="legend">
<h4>Relationship Closeness (1-10)</h4>
<div className="legend-items">
<div className="legend-item">
<span className="legend-line" style={{ background: getEdgeColorCSS(10), height: "4px" }}></span>
<span>10 - Very Close (Spouse, Partner)</span>
</div>
<div className="legend-item">
<span className="legend-line" style={{ background: getEdgeColorCSS(7), height: "3px" }}></span>
<span>7 - Close (Family, Best Friend)</span>
</div>
<div className="legend-item">
<span className="legend-line" style={{ background: getEdgeColorCSS(4), height: "2px" }}></span>
<span>4 - Moderate (Friend, Colleague)</span>
</div>
<div className="legend-item">
<span className="legend-line" style={{ background: getEdgeColorCSS(2), height: "1px" }}></span>
<span>2 - Distant (Acquaintance)</span>
</div>
</div>
</div>
</div>
);
}
function getEdgeColorCSS(weight: number): string {
// weight is 1-10, normalize to 0-1
const normalized = weight / 10;
const hue = 220;
const saturation = 70;
const lightness = 80 - normalized * 40;
return `hsl(${hue}, ${saturation}%, ${lightness}%)`;
}

View File

@@ -1,62 +0,0 @@
:root {
/* Light theme (default) */
--color-bg: #f5f5f5;
--color-bg-card: #ffffff;
--color-bg-hover: #f0f0f0;
--color-bg-muted: #f9f9f9;
--color-bg-error: #ffe0e0;
--color-text: #333333;
--color-text-muted: #666666;
--color-text-error: #cc0000;
--color-border: #dddddd;
--color-border-light: #eeeeee;
--color-border-lighter: #f0f0f0;
--color-primary: #0066cc;
--color-primary-hover: #0055aa;
--color-danger: #cc3333;
--color-danger-hover: #aa2222;
--color-tag-bg: #e0e0e0;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
line-height: 1.5;
font-weight: 400;
color: var(--color-text);
background-color: var(--color-bg);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
[data-theme="dark"] {
--color-bg: #1a1a1a;
--color-bg-card: #2d2d2d;
--color-bg-hover: #3d3d3d;
--color-bg-muted: #252525;
--color-bg-error: #4a2020;
--color-text: #e0e0e0;
--color-text-muted: #a0a0a0;
--color-text-error: #ff6b6b;
--color-border: #404040;
--color-border-light: #353535;
--color-border-lighter: #303030;
--color-primary: #4da6ff;
--color-primary-hover: #7dbfff;
--color-danger: #ff6b6b;
--color-danger-hover: #ff8a8a;
--color-tag-bg: #404040;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}

View File

@@ -1,13 +0,0 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App.tsx";
import "./index.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>
);

View File

@@ -1,155 +0,0 @@
export interface Need {
id: number;
name: string;
description: string | null;
}
export interface NeedCreate {
name: string;
description?: string | null;
}
export const RELATIONSHIP_TYPES = [
{ value: 'spouse', displayName: 'Spouse', defaultWeight: 10 },
{ value: 'partner', displayName: 'Partner', defaultWeight: 10 },
{ value: 'parent', displayName: 'Parent', defaultWeight: 9 },
{ value: 'child', displayName: 'Child', defaultWeight: 9 },
{ value: 'sibling', displayName: 'Sibling', defaultWeight: 9 },
{ value: 'best_friend', displayName: 'Best Friend', defaultWeight: 8 },
{ value: 'grandparent', displayName: 'Grandparent', defaultWeight: 7 },
{ value: 'grandchild', displayName: 'Grandchild', defaultWeight: 7 },
{ value: 'aunt_uncle', displayName: 'Aunt/Uncle', defaultWeight: 7 },
{ value: 'niece_nephew', displayName: 'Niece/Nephew', defaultWeight: 7 },
{ value: 'cousin', displayName: 'Cousin', defaultWeight: 7 },
{ value: 'in_law', displayName: 'In-Law', defaultWeight: 7 },
{ value: 'close_friend', displayName: 'Close Friend', defaultWeight: 6 },
{ value: 'friend', displayName: 'Friend', defaultWeight: 6 },
{ value: 'mentor', displayName: 'Mentor', defaultWeight: 5 },
{ value: 'mentee', displayName: 'Mentee', defaultWeight: 5 },
{ value: 'business_partner', displayName: 'Business Partner', defaultWeight: 5 },
{ value: 'colleague', displayName: 'Colleague', defaultWeight: 4 },
{ value: 'manager', displayName: 'Manager', defaultWeight: 4 },
{ value: 'direct_report', displayName: 'Direct Report', defaultWeight: 4 },
{ value: 'client', displayName: 'Client', defaultWeight: 4 },
{ value: 'acquaintance', displayName: 'Acquaintance', defaultWeight: 3 },
{ value: 'neighbor', displayName: 'Neighbor', defaultWeight: 3 },
{ value: 'ex', displayName: 'Ex', defaultWeight: 2 },
{ value: 'other', displayName: 'Other', defaultWeight: 2 },
] as const;
export type RelationshipTypeValue = typeof RELATIONSHIP_TYPES[number]['value'];
export interface ContactRelationship {
contact_id: number;
related_contact_id: number;
relationship_type: string;
closeness_weight: number;
}
export interface ContactRelationshipCreate {
related_contact_id: number;
relationship_type: RelationshipTypeValue;
closeness_weight?: number;
}
export interface ContactRelationshipUpdate {
relationship_type?: RelationshipTypeValue;
closeness_weight?: number;
}
export interface GraphNode {
id: number;
name: string;
current_job: string | null;
}
export interface GraphEdge {
source: number;
target: number;
relationship_type: string;
closeness_weight: number;
}
export interface GraphData {
nodes: GraphNode[];
edges: GraphEdge[];
}
export interface Contact {
id: number;
name: string;
age: number | null;
bio: string | null;
current_job: string | null;
gender: string | null;
goals: string | null;
legal_name: string | null;
profile_pic: string | null;
safe_conversation_starters: string | null;
self_sufficiency_score: number | null;
social_structure_style: string | null;
ssn: string | null;
suffix: string | null;
timezone: string | null;
topics_to_avoid: string | null;
needs: Need[];
related_to: ContactRelationship[];
related_from: ContactRelationship[];
}
export interface ContactListItem {
id: number;
name: string;
age: number | null;
bio: string | null;
current_job: string | null;
gender: string | null;
goals: string | null;
legal_name: string | null;
profile_pic: string | null;
safe_conversation_starters: string | null;
self_sufficiency_score: number | null;
social_structure_style: string | null;
ssn: string | null;
suffix: string | null;
timezone: string | null;
topics_to_avoid: string | null;
}
export interface ContactCreate {
name: string;
age?: number | null;
bio?: string | null;
current_job?: string | null;
gender?: string | null;
goals?: string | null;
legal_name?: string | null;
profile_pic?: string | null;
safe_conversation_starters?: string | null;
self_sufficiency_score?: number | null;
social_structure_style?: string | null;
ssn?: string | null;
suffix?: string | null;
timezone?: string | null;
topics_to_avoid?: string | null;
need_ids?: number[];
}
export interface ContactUpdate {
name?: string | null;
age?: number | null;
bio?: string | null;
current_job?: string | null;
gender?: string | null;
goals?: string | null;
legal_name?: string | null;
profile_pic?: string | null;
safe_conversation_starters?: string | null;
self_sufficiency_score?: number | null;
social_structure_style?: string | null;
ssn?: string | null;
suffix?: string | null;
timezone?: string | null;
topics_to_avoid?: string | null;
need_ids?: number[] | null;
}

View File

@@ -1,28 +0,0 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

View File

@@ -1,7 +0,0 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -1,26 +0,0 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -1,11 +0,0 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
proxy: {
"/api": "http://localhost:8000",
},
},
});

View File

@@ -34,7 +34,6 @@
pytest-mock pytest-mock
pytest-xdist pytest-xdist
python-multipart python-multipart
requests
ruff ruff
scalene scalene
sqlalchemy sqlalchemy
@@ -43,7 +42,6 @@
textual textual
tinytuya tinytuya
typer typer
types-requests
websockets websockets
] ]
); );

View File

@@ -18,7 +18,6 @@ dependencies = [
"psycopg[binary]", "psycopg[binary]",
"pydantic", "pydantic",
"pyyaml", "pyyaml",
"requests",
"sqlalchemy", "sqlalchemy",
"typer", "typer",
"websockets", "websockets",
@@ -27,7 +26,6 @@ dependencies = [
[project.scripts] [project.scripts]
database = "python.database_cli:app" database = "python.database_cli:app"
van-inventory = "python.van_inventory.main:serve" van-inventory = "python.van_inventory.main:serve"
sheet-music-ocr = "python.sheet_music_ocr.main:app"
[dependency-groups] [dependency-groups]
dev = [ dev = [
@@ -38,7 +36,6 @@ dev = [
"pytest-xdist", "pytest-xdist",
"pytest", "pytest",
"ruff", "ruff",
"types-requests",
] ]
[tool.ruff] [tool.ruff]
@@ -113,4 +110,5 @@ exclude_lines = [
[tool.pytest.ini_options] [tool.pytest.ini_options]
addopts = "-n auto -ra" addopts = "-n auto -ra"
testpaths = ["tests"]
# --cov=system_tools --cov-report=term-missing --cov-report=xml --cov-report=html --cov-branch # --cov=system_tools --cov-report=term-missing --cov-report=xml --cov-report=html --cov-branch

View File

@@ -0,0 +1,66 @@
"""adding roles to signal devices.
Revision ID: 2ef7ba690159
Revises: a1b2c3d4e5f6
Create Date: 2026-03-16 19:22:38.020350
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import sqlalchemy as sa
from alembic import op
from python.orm import RichieBase
if TYPE_CHECKING:
from collections.abc import Sequence
# revision identifiers, used by Alembic.
revision: str = "2ef7ba690159"
down_revision: str | None = "a1b2c3d4e5f6"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
schema = RichieBase.schema_name
def upgrade() -> None:
"""Upgrade."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"role",
sa.Column("name", sa.String(length=50), nullable=False),
sa.Column("id", sa.SmallInteger(), nullable=False),
sa.Column("created", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_role")),
sa.UniqueConstraint("name", name=op.f("uq_role_name")),
schema=schema,
)
op.create_table(
"device_role",
sa.Column("device_id", sa.Integer(), nullable=False),
sa.Column("role_id", sa.SmallInteger(), nullable=False),
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("created", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(
["device_id"], [f"{schema}.signal_device.id"], name=op.f("fk_device_role_device_id_signal_device")
),
sa.ForeignKeyConstraint(["role_id"], [f"{schema}.role.id"], name=op.f("fk_device_role_role_id_role")),
sa.PrimaryKeyConstraint("id", name=op.f("pk_device_role")),
sa.UniqueConstraint("device_id", "role_id", name="uq_device_role_device_role"),
schema=schema,
)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table("device_role", schema=schema)
op.drop_table("role", schema=schema)
# ### end Alembic commands ###

View File

@@ -0,0 +1,171 @@
"""seprating signal_bot database.
Revision ID: 6b275323f435
Revises: 2ef7ba690159
Create Date: 2026-03-18 08:34:28.785885
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
from python.orm import RichieBase
if TYPE_CHECKING:
from collections.abc import Sequence
# revision identifiers, used by Alembic.
revision: str = "6b275323f435"
down_revision: str | None = "2ef7ba690159"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
schema = RichieBase.schema_name
def upgrade() -> None:
"""Upgrade."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table("device_role", schema=schema)
op.drop_table("signal_device", schema=schema)
op.drop_table("role", schema=schema)
op.drop_table("dead_letter_message", schema=schema)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"dead_letter_message",
sa.Column("source", sa.VARCHAR(), autoincrement=False, nullable=False),
sa.Column("message", sa.TEXT(), autoincrement=False, nullable=False),
sa.Column("received_at", postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=False),
sa.Column(
"status",
postgresql.ENUM("UNPROCESSED", "PROCESSED", name="message_status", schema=schema),
autoincrement=False,
nullable=False,
),
sa.Column("id", sa.INTEGER(), autoincrement=True, nullable=False),
sa.Column(
"created",
postgresql.TIMESTAMP(timezone=True),
server_default=sa.text("now()"),
autoincrement=False,
nullable=False,
),
sa.Column(
"updated",
postgresql.TIMESTAMP(timezone=True),
server_default=sa.text("now()"),
autoincrement=False,
nullable=False,
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_dead_letter_message")),
schema=schema,
)
op.create_table(
"role",
sa.Column("name", sa.VARCHAR(length=50), autoincrement=False, nullable=False),
sa.Column(
"id",
sa.SMALLINT(),
server_default=sa.text(f"nextval('{schema}.role_id_seq'::regclass)"),
autoincrement=True,
nullable=False,
),
sa.Column(
"created",
postgresql.TIMESTAMP(timezone=True),
server_default=sa.text("now()"),
autoincrement=False,
nullable=False,
),
sa.Column(
"updated",
postgresql.TIMESTAMP(timezone=True),
server_default=sa.text("now()"),
autoincrement=False,
nullable=False,
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_role")),
sa.UniqueConstraint(
"name", name=op.f("uq_role_name"), postgresql_include=[], postgresql_nulls_not_distinct=False
),
schema=schema,
)
op.create_table(
"signal_device",
sa.Column("phone_number", sa.VARCHAR(length=50), autoincrement=False, nullable=False),
sa.Column("safety_number", sa.VARCHAR(), autoincrement=False, nullable=True),
sa.Column(
"trust_level",
postgresql.ENUM("VERIFIED", "UNVERIFIED", "BLOCKED", name="trust_level", schema=schema),
autoincrement=False,
nullable=False,
),
sa.Column("last_seen", postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=False),
sa.Column("id", sa.INTEGER(), autoincrement=True, nullable=False),
sa.Column(
"created",
postgresql.TIMESTAMP(timezone=True),
server_default=sa.text("now()"),
autoincrement=False,
nullable=False,
),
sa.Column(
"updated",
postgresql.TIMESTAMP(timezone=True),
server_default=sa.text("now()"),
autoincrement=False,
nullable=False,
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_signal_device")),
sa.UniqueConstraint(
"phone_number",
name=op.f("uq_signal_device_phone_number"),
postgresql_include=[],
postgresql_nulls_not_distinct=False,
),
schema=schema,
)
op.create_table(
"device_role",
sa.Column("device_id", sa.INTEGER(), autoincrement=False, nullable=False),
sa.Column("role_id", sa.SMALLINT(), autoincrement=False, nullable=False),
sa.Column("id", sa.INTEGER(), autoincrement=True, nullable=False),
sa.Column(
"created",
postgresql.TIMESTAMP(timezone=True),
server_default=sa.text("now()"),
autoincrement=False,
nullable=False,
),
sa.Column(
"updated",
postgresql.TIMESTAMP(timezone=True),
server_default=sa.text("now()"),
autoincrement=False,
nullable=False,
),
sa.ForeignKeyConstraint(
["device_id"], [f"{schema}.signal_device.id"], name=op.f("fk_device_role_device_id_signal_device")
),
sa.ForeignKeyConstraint(["role_id"], [f"{schema}.role.id"], name=op.f("fk_device_role_role_id_role")),
sa.PrimaryKeyConstraint("id", name=op.f("pk_device_role")),
sa.UniqueConstraint(
"device_id",
"role_id",
name=op.f("uq_device_role_device_role"),
postgresql_include=[],
postgresql_nulls_not_distinct=False,
),
schema=schema,
)
# ### end Alembic commands ###

View File

@@ -0,0 +1,100 @@
"""seprating signal_bot database.
Revision ID: 6eaf696e07a5
Revises:
Create Date: 2026-03-17 21:35:37.612672
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
from python.orm import SignalBotBase
if TYPE_CHECKING:
from collections.abc import Sequence
# revision identifiers, used by Alembic.
revision: str = "6eaf696e07a5"
down_revision: str | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
schema = SignalBotBase.schema_name
def upgrade() -> None:
"""Upgrade."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"dead_letter_message",
sa.Column("source", sa.String(), nullable=False),
sa.Column("message", sa.Text(), nullable=False),
sa.Column("received_at", sa.DateTime(timezone=True), nullable=False),
sa.Column(
"status", postgresql.ENUM("UNPROCESSED", "PROCESSED", name="message_status", schema=schema), nullable=False
),
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("created", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_dead_letter_message")),
schema=schema,
)
op.create_table(
"role",
sa.Column("name", sa.String(length=50), nullable=False),
sa.Column("id", sa.SmallInteger(), nullable=False),
sa.Column("created", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_role")),
sa.UniqueConstraint("name", name=op.f("uq_role_name")),
schema=schema,
)
op.create_table(
"signal_device",
sa.Column("phone_number", sa.String(length=50), nullable=False),
sa.Column("safety_number", sa.String(), nullable=True),
sa.Column(
"trust_level",
postgresql.ENUM("VERIFIED", "UNVERIFIED", "BLOCKED", name="trust_level", schema=schema),
nullable=False,
),
sa.Column("last_seen", sa.DateTime(timezone=True), nullable=False),
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("created", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_signal_device")),
sa.UniqueConstraint("phone_number", name=op.f("uq_signal_device_phone_number")),
schema=schema,
)
op.create_table(
"device_role",
sa.Column("device_id", sa.Integer(), nullable=False),
sa.Column("role_id", sa.SmallInteger(), nullable=False),
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("created", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(
["device_id"], [f"{schema}.signal_device.id"], name=op.f("fk_device_role_device_id_signal_device")
),
sa.ForeignKeyConstraint(["role_id"], [f"{schema}.role.id"], name=op.f("fk_device_role_role_id_role")),
sa.PrimaryKeyConstraint("id", name=op.f("pk_device_role")),
sa.UniqueConstraint("device_id", "role_id", name="uq_device_role_device_role"),
schema=schema,
)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table("device_role", schema=schema)
op.drop_table("signal_device", schema=schema)
op.drop_table("role", schema=schema)
op.drop_table("dead_letter_message", schema=schema)
# ### end Alembic commands ###

View File

@@ -0,0 +1,72 @@
"""test.
Revision ID: 66bdd532bcab
Revises: 6eaf696e07a5
Create Date: 2026-03-18 19:21:14.561568
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
from python.orm import SignalBotBase
if TYPE_CHECKING:
from collections.abc import Sequence
# revision identifiers, used by Alembic.
revision: str = "66bdd532bcab"
down_revision: str | None = "6eaf696e07a5"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
schema = SignalBotBase.schema_name
def upgrade() -> None:
"""Upgrade."""
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column(
"dead_letter_message",
"status",
existing_type=postgresql.ENUM("UNPROCESSED", "PROCESSED", name="message_status", schema=schema),
type_=sa.Enum("UNPROCESSED", "PROCESSED", name="message_status", native_enum=False),
existing_nullable=False,
schema=schema,
)
op.alter_column(
"signal_device",
"trust_level",
existing_type=postgresql.ENUM("VERIFIED", "UNVERIFIED", "BLOCKED", name="trust_level", schema=schema),
type_=sa.Enum("VERIFIED", "UNVERIFIED", "BLOCKED", name="trust_level", native_enum=False),
existing_nullable=False,
schema=schema,
)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade."""
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column(
"signal_device",
"trust_level",
existing_type=sa.Enum("VERIFIED", "UNVERIFIED", "BLOCKED", name="trust_level", native_enum=False),
type_=postgresql.ENUM("VERIFIED", "UNVERIFIED", "BLOCKED", name="trust_level", schema=schema),
existing_nullable=False,
schema=schema,
)
op.alter_column(
"dead_letter_message",
"status",
existing_type=sa.Enum("UNPROCESSED", "PROCESSED", name="message_status", native_enum=False),
type_=postgresql.ENUM("UNPROCESSED", "PROCESSED", name="message_status", schema=schema),
existing_nullable=False,
schema=schema,
)
# ### end Alembic commands ###

View File

@@ -1,27 +1,23 @@
"""FastAPI interface for Contact database.""" """FastAPI interface for Contact database."""
import logging import logging
import shutil
import subprocess
import tempfile
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from os import environ
from pathlib import Path
from typing import Annotated from typing import Annotated
import typer import typer
import uvicorn import uvicorn
from fastapi import FastAPI from fastapi import FastAPI
from python.api.routers import contact_router, create_frontend_router from python.api.middleware import ZstdMiddleware
from python.api.routers import contact_router, views_router
from python.common import configure_logger from python.common import configure_logger
from python.orm.common import get_postgres_engine from python.orm.common import get_postgres_engine
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def create_app(frontend_dir: Path | None = None) -> FastAPI: def create_app() -> FastAPI:
"""Create and configure the FastAPI application.""" """Create and configure the FastAPI application."""
@asynccontextmanager @asynccontextmanager
@@ -32,84 +28,23 @@ def create_app(frontend_dir: Path | None = None) -> FastAPI:
app.state.engine.dispose() app.state.engine.dispose()
app = FastAPI(title="Contact Database API", lifespan=lifespan) app = FastAPI(title="Contact Database API", lifespan=lifespan)
app.add_middleware(ZstdMiddleware)
app.include_router(contact_router) app.include_router(contact_router)
app.include_router(views_router)
if frontend_dir:
logger.info(f"Serving frontend from {frontend_dir}")
frontend_router = create_frontend_router(frontend_dir)
app.include_router(frontend_router)
return app return app
def build_frontend(source_dir: Path | None, cache_dir: Path | None = None) -> Path | None:
"""Run npm build and copy output to a temp directory.
Works even if source_dir is read-only by copying to a temp directory first.
Args:
source_dir: Frontend source directory.
cache_dir: Optional npm cache directory for faster repeated builds.
Returns:
Path to frontend build directory, or None if no source_dir provided.
"""
if not source_dir:
return None
if not source_dir.exists():
error = f"Frontend directory {source_dir} does not exist"
raise FileExistsError(error)
logger.info("Building frontend from %s...", source_dir)
# Copy source to a writable temp directory
build_dir = Path(tempfile.mkdtemp(prefix="contact_frontend_build_"))
shutil.copytree(source_dir, build_dir, dirs_exist_ok=True)
env = dict(environ)
if cache_dir:
cache_dir.mkdir(parents=True, exist_ok=True)
env["npm_config_cache"] = str(cache_dir)
subprocess.run(["npm", "install"], cwd=build_dir, env=env, check=True) # noqa: S607
subprocess.run(["npm", "run", "build"], cwd=build_dir, env=env, check=True) # noqa: S607
dist_dir = build_dir / "dist"
if not dist_dir.exists():
error = f"Build output not found at {dist_dir}"
raise FileNotFoundError(error)
output_dir = Path(tempfile.mkdtemp(prefix="contact_frontend_"))
shutil.copytree(dist_dir, output_dir, dirs_exist_ok=True)
logger.info(f"Frontend built and copied to {output_dir}")
shutil.rmtree(build_dir)
return output_dir
def serve( def serve(
host: Annotated[str, typer.Option("--host", "-h", help="Host to bind to")], host: Annotated[str, typer.Option("--host", "-h", help="Host to bind to")],
frontend_dir: Annotated[
Path | None,
typer.Option(
"--frontend-dir",
"-f",
help="Frontend source directory. If provided, runs npm build and serves from temp dir.",
),
] = None,
port: Annotated[int, typer.Option("--port", "-p", help="Port to bind to")] = 8000, port: Annotated[int, typer.Option("--port", "-p", help="Port to bind to")] = 8000,
log_level: Annotated[str, typer.Option("--log-level", "-l", help="Log level")] = "INFO", log_level: Annotated[str, typer.Option("--log-level", "-l", help="Log level")] = "INFO",
) -> None: ) -> None:
"""Start the Contact API server.""" """Start the Contact API server."""
configure_logger(log_level) configure_logger(log_level)
cache_dir = Path(environ["HOME"]) / ".npm" app = create_app()
serve_dir = build_frontend(frontend_dir, cache_dir=cache_dir)
app = create_app(frontend_dir=serve_dir)
uvicorn.run(app, host=host, port=port) uvicorn.run(app, host=host, port=port)

49
python/api/middleware.py Normal file
View File

@@ -0,0 +1,49 @@
"""Middleware for the FastAPI application."""
from compression import zstd
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.requests import Request
from starlette.responses import Response
MINIMUM_RESPONSE_SIZE = 500
class ZstdMiddleware(BaseHTTPMiddleware):
"""Middleware that compresses responses with zstd when the client supports it."""
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
"""Compress the response with zstd if the client accepts it."""
accepted_encodings = request.headers.get("accept-encoding", "")
if "zstd" not in accepted_encodings:
return await call_next(request)
response = await call_next(request)
if response.headers.get("content-encoding") or "text/event-stream" in response.headers.get("content-type", ""):
return response
body = b""
async for chunk in response.body_iterator:
body += chunk if isinstance(chunk, bytes) else chunk.encode()
if len(body) < MINIMUM_RESPONSE_SIZE:
return Response(
content=body,
status_code=response.status_code,
headers=dict(response.headers),
media_type=response.media_type,
)
compressed = zstd.compress(body)
headers = dict(response.headers)
headers["content-encoding"] = "zstd"
headers["content-length"] = str(len(compressed))
headers.pop("transfer-encoding", None)
return Response(
content=compressed,
status_code=response.status_code,
headers=headers,
media_type=response.media_type,
)

View File

@@ -1,6 +1,6 @@
"""API routers.""" """API routers."""
from python.api.routers.contact import router as contact_router from python.api.routers.contact import router as contact_router
from python.api.routers.frontend import create_frontend_router from python.api.routers.views import router as views_router
__all__ = ["contact_router", "create_frontend_router"] __all__ = ["contact_router", "views_router"]

View File

@@ -1,6 +1,10 @@
"""Contact API router.""" """Contact API router."""
from fastapi import APIRouter, HTTPException from pathlib import Path
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
@@ -8,6 +12,14 @@ from sqlalchemy.orm import selectinload
from python.api.dependencies import DbSession from python.api.dependencies import DbSession
from python.orm.richie.contact import Contact, ContactRelationship, Need, RelationshipType from python.orm.richie.contact import Contact, ContactRelationship, Need, RelationshipType
TEMPLATES_DIR = Path(__file__).parent.parent / "templates"
templates = Jinja2Templates(directory=TEMPLATES_DIR)
def _is_htmx(request: Request) -> bool:
"""Check if the request is from HTMX."""
return request.headers.get("HX-Request") == "true"
class NeedBase(BaseModel): class NeedBase(BaseModel):
"""Base schema for Need.""" """Base schema for Need."""
@@ -180,14 +192,16 @@ def get_need(need_id: int, db: DbSession) -> Need:
return need return need
@router.delete("/needs/{need_id}") @router.delete("/needs/{need_id}", response_model=None)
def delete_need(need_id: int, db: DbSession) -> dict[str, bool]: def delete_need(need_id: int, request: Request, db: DbSession) -> dict[str, bool] | HTMLResponse:
"""Delete a need by ID.""" """Delete a need by ID."""
need = db.get(Need, need_id) need = db.get(Need, need_id)
if not need: if not need:
raise HTTPException(status_code=404, detail="Need not found") raise HTTPException(status_code=404, detail="Need not found")
db.delete(need) db.delete(need)
db.commit() db.commit()
if _is_htmx(request):
return HTMLResponse("")
return {"deleted": True} return {"deleted": True}
@@ -261,14 +275,16 @@ def update_contact(
return db_contact return db_contact
@router.delete("/contacts/{contact_id}") @router.delete("/contacts/{contact_id}", response_model=None)
def delete_contact(contact_id: int, db: DbSession) -> dict[str, bool]: def delete_contact(contact_id: int, request: Request, db: DbSession) -> dict[str, bool] | HTMLResponse:
"""Delete a contact by ID.""" """Delete a contact by ID."""
contact = db.get(Contact, contact_id) contact = db.get(Contact, contact_id)
if not contact: if not contact:
raise HTTPException(status_code=404, detail="Contact not found") raise HTTPException(status_code=404, detail="Contact not found")
db.delete(contact) db.delete(contact)
db.commit() db.commit()
if _is_htmx(request):
return HTMLResponse("")
return {"deleted": True} return {"deleted": True}
@@ -294,12 +310,13 @@ def add_need_to_contact(
return {"added": True} return {"added": True}
@router.delete("/contacts/{contact_id}/needs/{need_id}") @router.delete("/contacts/{contact_id}/needs/{need_id}", response_model=None)
def remove_need_from_contact( def remove_need_from_contact(
contact_id: int, contact_id: int,
need_id: int, need_id: int,
request: Request,
db: DbSession, db: DbSession,
) -> dict[str, bool]: ) -> dict[str, bool] | HTMLResponse:
"""Remove a need from a contact.""" """Remove a need from a contact."""
contact = db.get(Contact, contact_id) contact = db.get(Contact, contact_id)
if not contact: if not contact:
@@ -313,6 +330,8 @@ def remove_need_from_contact(
contact.needs.remove(need) contact.needs.remove(need)
db.commit() db.commit()
if _is_htmx(request):
return HTMLResponse("")
return {"removed": True} return {"removed": True}
@@ -404,12 +423,13 @@ def update_contact_relationship(
return relationship return relationship
@router.delete("/contacts/{contact_id}/relationships/{related_contact_id}") @router.delete("/contacts/{contact_id}/relationships/{related_contact_id}", response_model=None)
def remove_contact_relationship( def remove_contact_relationship(
contact_id: int, contact_id: int,
related_contact_id: int, related_contact_id: int,
request: Request,
db: DbSession, db: DbSession,
) -> dict[str, bool]: ) -> dict[str, bool] | HTMLResponse:
"""Remove a relationship between two contacts.""" """Remove a relationship between two contacts."""
relationship = db.scalar( relationship = db.scalar(
select(ContactRelationship).where( select(ContactRelationship).where(
@@ -422,6 +442,8 @@ def remove_contact_relationship(
db.delete(relationship) db.delete(relationship)
db.commit() db.commit()
if _is_htmx(request):
return HTMLResponse("")
return {"deleted": True} return {"deleted": True}

View File

@@ -1,24 +0,0 @@
"""Frontend SPA router."""
from pathlib import Path
from fastapi import APIRouter
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
def create_frontend_router(frontend_dir: Path) -> APIRouter:
"""Create a router for serving the frontend SPA."""
router = APIRouter(tags=["frontend"])
router.mount("/assets", StaticFiles(directory=frontend_dir / "assets"), name="assets")
@router.get("/{full_path:path}")
async def serve_spa(full_path: str) -> FileResponse:
"""Serve React SPA for all non-API routes."""
file_path = frontend_dir / full_path
if file_path.is_file():
return FileResponse(file_path)
return FileResponse(frontend_dir / "index.html")
return router

345
python/api/routers/views.py Normal file
View File

@@ -0,0 +1,345 @@
"""HTMX server-rendered view router."""
from pathlib import Path
from typing import Annotated, Any
from fastapi import APIRouter, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
from python.api.dependencies import DbSession
from python.orm.richie.contact import Contact, ContactRelationship, Need, RelationshipType
TEMPLATES_DIR = Path(__file__).parent.parent / "templates"
templates = Jinja2Templates(directory=TEMPLATES_DIR)
router = APIRouter(tags=["views"])
FAMILIAL_TYPES = {
"parent",
"child",
"sibling",
"grandparent",
"grandchild",
"aunt_uncle",
"niece_nephew",
"cousin",
"in_law",
}
FRIEND_TYPES = {"best_friend", "close_friend", "friend", "acquaintance", "neighbor"}
PARTNER_TYPES = {"spouse", "partner"}
PROFESSIONAL_TYPES = {"mentor", "mentee", "business_partner", "colleague", "manager", "direct_report", "client"}
CONTACT_STRING_FIELDS = (
"name",
"legal_name",
"suffix",
"gender",
"current_job",
"timezone",
"profile_pic",
"bio",
"goals",
"social_structure_style",
"safe_conversation_starters",
"topics_to_avoid",
"ssn",
)
CONTACT_INT_FIELDS = ("age", "self_sufficiency_score")
def _group_relationships(relationships: list[ContactRelationship]) -> dict[str, list[ContactRelationship]]:
"""Group relationships by category."""
groups: dict[str, list[ContactRelationship]] = {
"familial": [],
"partners": [],
"friends": [],
"professional": [],
"other": [],
}
for rel in relationships:
if rel.relationship_type in FAMILIAL_TYPES:
groups["familial"].append(rel)
elif rel.relationship_type in PARTNER_TYPES:
groups["partners"].append(rel)
elif rel.relationship_type in FRIEND_TYPES:
groups["friends"].append(rel)
elif rel.relationship_type in PROFESSIONAL_TYPES:
groups["professional"].append(rel)
else:
groups["other"].append(rel)
return groups
def _build_contact_name_map(database: Session, contact: Contact) -> dict[int, str]:
"""Build a mapping of contact IDs to names for relationship display."""
related_ids = {rel.related_contact_id for rel in contact.related_to}
related_ids |= {rel.contact_id for rel in contact.related_from}
related_ids.discard(contact.id)
if not related_ids:
return {}
related_contacts = list(database.scalars(select(Contact).where(Contact.id.in_(related_ids))).all())
return {related.id: related.name for related in related_contacts}
def _get_relationship_type_display() -> dict[str, str]:
"""Build a mapping of relationship type values to display names."""
return {rel_type.value: rel_type.display_name for rel_type in RelationshipType}
async def _parse_contact_form(request: Request) -> dict[str, Any]:
"""Parse contact form data from a multipart/form request."""
form_data = await request.form()
result: dict[str, Any] = {}
for field in CONTACT_STRING_FIELDS:
value = form_data.get(field, "")
result[field] = str(value) if value else None
for field in CONTACT_INT_FIELDS:
value = form_data.get(field, "")
result[field] = int(value) if value else None
result["need_ids"] = [int(value) for value in form_data.getlist("need_ids")]
return result
def _save_contact_from_form(database: Session, contact: Contact, form_result: dict[str, Any]) -> None:
"""Apply parsed form data to a Contact and save associated needs."""
need_ids = form_result.pop("need_ids")
for key, value in form_result.items():
setattr(contact, key, value)
if need_ids:
contact.needs = list(database.scalars(select(Need).where(Need.id.in_(need_ids))).all())
else:
contact.needs = []
@router.get("/", response_class=HTMLResponse)
@router.get("/contacts", response_class=HTMLResponse)
def contact_list_page(request: Request, database: DbSession) -> HTMLResponse:
"""Render the contacts list page."""
contacts = list(database.scalars(select(Contact)).all())
return templates.TemplateResponse(request, "contact_list.html", {"contacts": contacts})
@router.get("/contacts/new", response_class=HTMLResponse)
def new_contact_page(request: Request, database: DbSession) -> HTMLResponse:
"""Render the new contact form page."""
all_needs = list(database.scalars(select(Need)).all())
return templates.TemplateResponse(request, "contact_form.html", {"contact": None, "all_needs": all_needs})
@router.post("/htmx/contacts/new")
async def create_contact_form(request: Request, database: DbSession) -> RedirectResponse:
"""Handle the create contact form submission."""
form_result = await _parse_contact_form(request)
contact = Contact()
_save_contact_from_form(database, contact, form_result)
database.add(contact)
database.commit()
database.refresh(contact)
return RedirectResponse(url=f"/contacts/{contact.id}", status_code=303)
@router.get("/contacts/{contact_id}", response_class=HTMLResponse)
def contact_detail_page(contact_id: int, request: Request, database: DbSession) -> HTMLResponse:
"""Render the contact detail page."""
contact = database.scalar(
select(Contact)
.where(Contact.id == contact_id)
.options(
selectinload(Contact.needs),
selectinload(Contact.related_to),
selectinload(Contact.related_from),
)
)
if not contact:
raise HTTPException(status_code=404, detail="Contact not found")
contact_names = _build_contact_name_map(database, contact)
grouped_relationships = _group_relationships(contact.related_to)
all_contacts = list(database.scalars(select(Contact)).all())
all_needs = list(database.scalars(select(Need)).all())
available_needs = [need for need in all_needs if need not in contact.needs]
return templates.TemplateResponse(
request,
"contact_detail.html",
{
"contact": contact,
"contact_names": contact_names,
"grouped_relationships": grouped_relationships,
"all_contacts": all_contacts,
"available_needs": available_needs,
"relationship_types": list(RelationshipType),
},
)
@router.get("/contacts/{contact_id}/edit", response_class=HTMLResponse)
def edit_contact_page(contact_id: int, request: Request, database: DbSession) -> HTMLResponse:
"""Render the edit contact form page."""
contact = database.scalar(select(Contact).where(Contact.id == contact_id).options(selectinload(Contact.needs)))
if not contact:
raise HTTPException(status_code=404, detail="Contact not found")
all_needs = list(database.scalars(select(Need)).all())
return templates.TemplateResponse(request, "contact_form.html", {"contact": contact, "all_needs": all_needs})
@router.post("/htmx/contacts/{contact_id}/edit")
async def update_contact_form(contact_id: int, request: Request, database: DbSession) -> RedirectResponse:
"""Handle the edit contact form submission."""
contact = database.get(Contact, contact_id)
if not contact:
raise HTTPException(status_code=404, detail="Contact not found")
form_result = await _parse_contact_form(request)
_save_contact_from_form(database, contact, form_result)
database.commit()
return RedirectResponse(url=f"/contacts/{contact_id}", status_code=303)
@router.post("/htmx/contacts/{contact_id}/add-need", response_class=HTMLResponse)
def add_need_to_contact_htmx(
contact_id: int,
request: Request,
database: DbSession,
need_id: Annotated[int, Form()],
) -> HTMLResponse:
"""Add a need to a contact and return updated manage-needs partial."""
contact = database.scalar(select(Contact).where(Contact.id == contact_id).options(selectinload(Contact.needs)))
if not contact:
raise HTTPException(status_code=404, detail="Contact not found")
need = database.get(Need, need_id)
if not need:
raise HTTPException(status_code=404, detail="Need not found")
if need not in contact.needs:
contact.needs.append(need)
database.commit()
database.refresh(contact)
return templates.TemplateResponse(request, "partials/manage_needs.html", {"contact": contact})
@router.post("/htmx/contacts/{contact_id}/add-relationship", response_class=HTMLResponse)
def add_relationship_htmx(
contact_id: int,
request: Request,
database: DbSession,
related_contact_id: Annotated[int, Form()],
relationship_type: Annotated[str, Form()],
) -> HTMLResponse:
"""Add a relationship and return updated manage-relationships partial."""
contact = database.scalar(select(Contact).where(Contact.id == contact_id).options(selectinload(Contact.related_to)))
if not contact:
raise HTTPException(status_code=404, detail="Contact not found")
related_contact = database.get(Contact, related_contact_id)
if not related_contact:
raise HTTPException(status_code=404, detail="Related contact not found")
rel_type = RelationshipType(relationship_type)
weight = rel_type.default_weight
relationship = ContactRelationship(
contact_id=contact_id,
related_contact_id=related_contact_id,
relationship_type=relationship_type,
closeness_weight=weight,
)
database.add(relationship)
database.commit()
database.refresh(contact)
contact_names = _build_contact_name_map(database, contact)
return templates.TemplateResponse(
request,
"partials/manage_relationships.html",
{"contact": contact, "contact_names": contact_names},
)
@router.post("/htmx/contacts/{contact_id}/relationships/{related_contact_id}/weight")
def update_relationship_weight_htmx(
contact_id: int,
related_contact_id: int,
database: DbSession,
closeness_weight: Annotated[int, Form()],
) -> HTMLResponse:
"""Update a relationship's closeness weight from HTMX range input."""
relationship = database.scalar(
select(ContactRelationship).where(
ContactRelationship.contact_id == contact_id,
ContactRelationship.related_contact_id == related_contact_id,
)
)
if not relationship:
raise HTTPException(status_code=404, detail="Relationship not found")
relationship.closeness_weight = closeness_weight
database.commit()
return HTMLResponse("")
@router.post("/htmx/needs", response_class=HTMLResponse)
def create_need_htmx(
request: Request,
database: DbSession,
name: Annotated[str, Form()],
description: Annotated[str, Form()] = "",
) -> HTMLResponse:
"""Create a need via form data and return updated needs list."""
need = Need(name=name, description=description or None)
database.add(need)
database.commit()
needs = list(database.scalars(select(Need)).all())
return templates.TemplateResponse(request, "partials/need_items.html", {"needs": needs})
@router.get("/needs", response_class=HTMLResponse)
def needs_page(request: Request, database: DbSession) -> HTMLResponse:
"""Render the needs list page."""
needs = list(database.scalars(select(Need)).all())
return templates.TemplateResponse(request, "need_list.html", {"needs": needs})
@router.get("/graph", response_class=HTMLResponse)
def graph_page(request: Request, database: DbSession) -> HTMLResponse:
"""Render the relationship graph page."""
contacts = list(database.scalars(select(Contact)).all())
relationships = list(database.scalars(select(ContactRelationship)).all())
graph_data = {
"nodes": [{"id": contact.id, "name": contact.name, "current_job": contact.current_job} for contact in contacts],
"edges": [
{
"source": rel.contact_id,
"target": rel.related_contact_id,
"relationship_type": rel.relationship_type,
"closeness_weight": rel.closeness_weight,
}
for rel in relationships
],
}
return templates.TemplateResponse(
request,
"graph.html",
{
"graph_data": graph_data,
"relationship_type_display": _get_relationship_type_display(),
},
)

View File

@@ -0,0 +1,198 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Contact Database{% endblock %}</title>
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<style>
:root {
--color-bg: #f5f5f5;
--color-bg-card: #ffffff;
--color-bg-hover: #f0f0f0;
--color-bg-muted: #f9f9f9;
--color-bg-error: #ffe0e0;
--color-text: #333333;
--color-text-muted: #666666;
--color-text-error: #cc0000;
--color-border: #dddddd;
--color-border-light: #eeeeee;
--color-border-lighter: #f0f0f0;
--color-primary: #0066cc;
--color-primary-hover: #0055aa;
--color-danger: #cc3333;
--color-danger-hover: #aa2222;
--color-tag-bg: #e0e0e0;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
line-height: 1.5;
color: var(--color-text);
background-color: var(--color-bg);
}
[data-theme="dark"] {
--color-bg: #1a1a1a;
--color-bg-card: #2d2d2d;
--color-bg-hover: #3d3d3d;
--color-bg-muted: #252525;
--color-bg-error: #4a2020;
--color-text: #e0e0e0;
--color-text-muted: #a0a0a0;
--color-text-error: #ff6b6b;
--color-border: #404040;
--color-border-light: #353535;
--color-border-lighter: #303030;
--color-primary: #4da6ff;
--color-primary-hover: #7dbfff;
--color-danger: #ff6b6b;
--color-danger-hover: #ff8a8a;
--color-tag-bg: #404040;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--color-bg); color: var(--color-text); }
.app { max-width: 1000px; margin: 0 auto; padding: 20px; }
nav { display: flex; align-items: center; gap: 20px; padding: 15px 0; border-bottom: 1px solid var(--color-border); margin-bottom: 20px; }
nav a { color: var(--color-primary); text-decoration: none; font-weight: 500; }
nav a:hover { text-decoration: underline; }
.theme-toggle { margin-left: auto; }
main { background: var(--color-bg-card); padding: 20px; border-radius: 8px; box-shadow: var(--shadow); }
.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
.header h1 { margin: 0; }
a { color: var(--color-primary); }
a:hover { text-decoration: underline; }
.btn { display: inline-block; padding: 8px 16px; border: 1px solid var(--color-border); border-radius: 4px; background: var(--color-bg-card); color: var(--color-text); text-decoration: none; cursor: pointer; font-size: 14px; margin-left: 8px; }
.btn:hover { background: var(--color-bg-hover); }
.btn-primary { background: var(--color-primary); border-color: var(--color-primary); color: white; }
.btn-primary:hover { background: var(--color-primary-hover); }
.btn-danger { background: var(--color-danger); border-color: var(--color-danger); color: white; }
.btn-danger:hover { background: var(--color-danger-hover); }
.btn-small { padding: 4px 8px; font-size: 12px; }
.btn:disabled { opacity: 0.6; cursor: not-allowed; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 12px; text-align: left; border-bottom: 1px solid var(--color-border-light); }
th { font-weight: 600; background: var(--color-bg-muted); }
tr:hover { background: var(--color-bg-muted); }
.error { background: var(--color-bg-error); color: var(--color-text-error); padding: 10px; border-radius: 4px; margin-bottom: 20px; }
.tag { display: inline-block; background: var(--color-tag-bg); padding: 2px 8px; border-radius: 12px; font-size: 12px; color: var(--color-text-muted); }
.add-form { display: flex; gap: 10px; margin-top: 15px; flex-wrap: wrap; }
.add-form select, .add-form input { padding: 8px; border: 1px solid var(--color-border); border-radius: 4px; min-width: 200px; background: var(--color-bg-card); color: var(--color-text); }
.form-group { margin-bottom: 20px; }
.form-group label { display: block; font-weight: 500; margin-bottom: 5px; }
.form-group input, .form-group textarea, .form-group select { width: 100%; padding: 10px; border: 1px solid var(--color-border); border-radius: 4px; font-size: 14px; background: var(--color-bg-card); color: var(--color-text); }
.form-group textarea { resize: vertical; }
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
.checkbox-group { display: flex; flex-wrap: wrap; gap: 15px; }
.checkbox-label { display: flex; align-items: center; gap: 5px; cursor: pointer; }
.form-actions { display: flex; gap: 10px; margin-top: 30px; padding-top: 20px; border-top: 1px solid var(--color-border-light); }
.need-form { background: var(--color-bg-muted); padding: 20px; border-radius: 4px; margin-bottom: 20px; }
.need-items { list-style: none; padding: 0; }
.need-items li { display: flex; justify-content: space-between; align-items: flex-start; padding: 15px; border: 1px solid var(--color-border-light); border-radius: 4px; margin-bottom: 10px; }
.need-info p { margin: 5px 0 0; color: var(--color-text-muted); font-size: 14px; }
.graph-container { width: 100%; }
.graph-hint { color: var(--color-text-muted); font-size: 14px; margin-bottom: 15px; }
.selected-info { margin-top: 15px; padding: 15px; background: var(--color-bg-muted); border-radius: 8px; }
.selected-info h3 { margin: 0 0 10px; }
.selected-info p { margin: 5px 0; color: var(--color-text-muted); }
.legend { margin-top: 20px; padding: 15px; background: var(--color-bg-muted); border-radius: 8px; }
.legend h4 { margin: 0 0 10px; font-size: 14px; }
.legend-items { display: flex; flex-wrap: wrap; gap: 15px; }
.legend-item { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--color-text-muted); }
.legend-line { width: 30px; border-radius: 2px; }
.id-card { width: 100%; }
.id-card-inner { background: linear-gradient(135deg, #0a0a0f 0%, #1a1a2e 50%, #0a0a0f 100%); background-image: radial-gradient(white 1px, transparent 1px), linear-gradient(135deg, #0a0a0f 0%, #1a1a2e 50%, #0a0a0f 100%); background-size: 50px 50px, 100% 100%; color: #fff; border-radius: 12px; padding: 25px; min-height: 500px; position: relative; overflow: hidden; }
.id-card-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 15px; }
.id-card-header-left { flex: 1; }
.id-card-header-right { display: flex; flex-direction: column; align-items: flex-end; gap: 10px; }
.id-card-title { font-size: 2.5rem; font-weight: 700; margin: 0; color: #fff; text-shadow: 2px 2px 4px rgba(0,0,0,0.5); }
.id-profile-pic { width: 80px; height: 80px; border-radius: 8px; object-fit: cover; border: 2px solid rgba(255,255,255,0.3); }
.id-profile-placeholder { width: 80px; height: 80px; border-radius: 8px; background: linear-gradient(135deg, #4ecdc4 0%, #44a8a0 100%); display: flex; align-items: center; justify-content: center; border: 2px solid rgba(255,255,255,0.3); }
.id-profile-placeholder span { font-size: 2rem; font-weight: 700; color: #fff; text-shadow: 1px 1px 2px rgba(0,0,0,0.3); }
.id-card-actions { display: flex; gap: 8px; }
.id-card-actions .btn { background: rgba(255,255,255,0.1); border-color: rgba(255,255,255,0.3); color: #fff; }
.id-card-actions .btn:hover { background: rgba(255,255,255,0.2); }
.id-card-body { display: grid; grid-template-columns: 1fr 1.5fr; gap: 30px; }
.id-card-left { display: flex; flex-direction: column; gap: 8px; }
.id-field { font-size: 1rem; line-height: 1.4; }
.id-field-block { margin-top: 15px; font-size: 0.95rem; line-height: 1.5; }
.id-label { color: #4ecdc4; font-weight: 500; }
.id-card-right { display: flex; flex-direction: column; gap: 20px; }
.id-bio { font-size: 0.9rem; line-height: 1.6; color: #e0e0e0; }
.id-relationships { margin-top: 10px; }
.id-section-title { font-size: 1.5rem; margin: 0 0 15px; color: #fff; border-bottom: 1px solid rgba(255,255,255,0.2); padding-bottom: 8px; }
.id-rel-group { margin-bottom: 12px; font-size: 0.9rem; line-height: 1.6; }
.id-rel-label { color: #a0a0a0; }
.id-rel-group a { color: #4ecdc4; text-decoration: none; }
.id-rel-group a:hover { text-decoration: underline; }
.id-rel-type { color: #888; font-size: 0.85em; }
.id-card-warnings { margin-top: 30px; padding-top: 20px; border-top: 1px solid rgba(255,255,255,0.2); display: flex; flex-wrap: wrap; gap: 20px; }
.id-warning { display: flex; align-items: center; gap: 8px; font-size: 0.9rem; color: #ff6b6b; }
.warning-dot { width: 8px; height: 8px; background: #ff6b6b; border-radius: 50%; flex-shrink: 0; }
.warning-desc { color: #ccc; }
.id-card-manage { margin-top: 20px; background: var(--color-bg-muted); border-radius: 8px; padding: 15px; }
.id-card-manage summary { cursor: pointer; font-weight: 600; font-size: 1.1rem; padding: 5px 0; }
.id-card-manage[open] summary { margin-bottom: 15px; border-bottom: 1px solid var(--color-border-light); padding-bottom: 10px; }
.manage-section { margin-bottom: 25px; }
.manage-section h3 { margin: 0 0 15px; font-size: 1rem; }
.manage-relationships { display: flex; flex-direction: column; gap: 10px; margin-bottom: 15px; }
.manage-rel-item { display: flex; align-items: center; gap: 12px; padding: 10px; background: var(--color-bg-card); border-radius: 6px; flex-wrap: wrap; }
.manage-rel-item a { font-weight: 500; min-width: 120px; }
.weight-control { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--color-text-muted); }
.weight-control input[type="range"] { width: 80px; cursor: pointer; }
.weight-value { min-width: 20px; text-align: center; font-weight: 600; }
.manage-needs-list { list-style: none; padding: 0; margin: 0 0 15px; }
.manage-needs-list li { display: flex; align-items: center; gap: 12px; padding: 10px; background: var(--color-bg-card); border-radius: 6px; margin-bottom: 8px; }
.manage-needs-list li .btn { margin-left: auto; }
.htmx-indicator { display: none; }
.htmx-request .htmx-indicator { display: inline; }
.htmx-request.htmx-indicator { display: inline; }
@media (max-width: 768px) {
.id-card-body { grid-template-columns: 1fr; }
.id-card-title { font-size: 1.8rem; }
.id-card-header { flex-direction: column; gap: 15px; }
}
</style>
</head>
<body>
<div class="app">
<nav>
<a href="/contacts">Contacts</a>
<a href="/graph">Graph</a>
<a href="/needs">Needs</a>
<button class="btn btn-small theme-toggle" onclick="toggleTheme()">
<span id="theme-label">Dark</span>
</button>
</nav>
<main id="main-content">
{% block content %}{% endblock %}
</main>
</div>
<script>
function toggleTheme() {
const html = document.documentElement;
const current = html.getAttribute('data-theme');
const next = current === 'light' ? 'dark' : 'light';
html.setAttribute('data-theme', next);
localStorage.setItem('theme', next);
document.getElementById('theme-label').textContent = next === 'light' ? 'Dark' : 'Light';
}
(function() {
const saved = localStorage.getItem('theme') || 'light';
document.documentElement.setAttribute('data-theme', saved);
document.getElementById('theme-label').textContent = saved === 'light' ? 'Dark' : 'Light';
})();
</script>
</body>
</html>

View File

@@ -0,0 +1,204 @@
{% extends "base.html" %}
{% block title %}{{ contact.name }}{% endblock %}
{% block content %}
<div class="id-card">
<div class="id-card-inner">
<div class="id-card-header">
<div class="id-card-header-left">
<h1 class="id-card-title">I.D.: {{ contact.name }}</h1>
</div>
<div class="id-card-header-right">
{% if contact.profile_pic %}
<img src="{{ contact.profile_pic }}" alt="{{ contact.name }}'s profile" class="id-profile-pic">
{% else %}
<div class="id-profile-placeholder">
<span>{{ contact.name[0]|upper }}</span>
</div>
{% endif %}
<div class="id-card-actions">
<a href="/contacts/{{ contact.id }}/edit" class="btn btn-small">Edit</a>
<a href="/contacts" class="btn btn-small">Back</a>
</div>
</div>
</div>
<div class="id-card-body">
<div class="id-card-left">
{% if contact.legal_name %}
<div class="id-field">Legal name: {{ contact.legal_name }}</div>
{% endif %}
{% if contact.suffix %}
<div class="id-field">Suffix: {{ contact.suffix }}</div>
{% endif %}
{% if contact.gender %}
<div class="id-field">Gender: {{ contact.gender }}</div>
{% endif %}
{% if contact.age %}
<div class="id-field">Age: {{ contact.age }}</div>
{% endif %}
{% if contact.current_job %}
<div class="id-field">Job: {{ contact.current_job }}</div>
{% endif %}
{% if contact.social_structure_style %}
<div class="id-field">Social style: {{ contact.social_structure_style }}</div>
{% endif %}
{% if contact.self_sufficiency_score is not none %}
<div class="id-field">Self-Sufficiency: {{ contact.self_sufficiency_score }}</div>
{% endif %}
{% if contact.timezone %}
<div class="id-field">Timezone: {{ contact.timezone }}</div>
{% endif %}
{% if contact.safe_conversation_starters %}
<div class="id-field-block">
<span class="id-label">Safe con starters:</span> {{ contact.safe_conversation_starters }}
</div>
{% endif %}
{% if contact.topics_to_avoid %}
<div class="id-field-block">
<span class="id-label">Topics to avoid:</span> {{ contact.topics_to_avoid }}
</div>
{% endif %}
{% if contact.goals %}
<div class="id-field-block">
<span class="id-label">Goals:</span> {{ contact.goals }}
</div>
{% endif %}
</div>
<div class="id-card-right">
{% if contact.bio %}
<div class="id-bio">
<span class="id-label">Bio:</span> {{ contact.bio }}
</div>
{% endif %}
<div class="id-relationships">
<h2 class="id-section-title">Relationships</h2>
{% if grouped_relationships.familial %}
<div class="id-rel-group">
<span class="id-rel-label">Familial:</span>
{% for rel in grouped_relationships.familial %}
<a href="/contacts/{{ rel.related_contact_id }}">{{ contact_names[rel.related_contact_id] }}</a><span class="id-rel-type">({{ rel.relationship_type|replace("_", " ")|title }})</span>{% if not loop.last %}, {% endif %}
{% endfor %}
</div>
{% endif %}
{% if grouped_relationships.partners %}
<div class="id-rel-group">
<span class="id-rel-label">Partners:</span>
{% for rel in grouped_relationships.partners %}
<a href="/contacts/{{ rel.related_contact_id }}">{{ contact_names[rel.related_contact_id] }}</a>{% if not loop.last %}, {% endif %}
{% endfor %}
</div>
{% endif %}
{% if grouped_relationships.friends %}
<div class="id-rel-group">
<span class="id-rel-label">Friends:</span>
{% for rel in grouped_relationships.friends %}
<a href="/contacts/{{ rel.related_contact_id }}">{{ contact_names[rel.related_contact_id] }}</a>{% if not loop.last %}, {% endif %}
{% endfor %}
</div>
{% endif %}
{% if grouped_relationships.professional %}
<div class="id-rel-group">
<span class="id-rel-label">Professional:</span>
{% for rel in grouped_relationships.professional %}
<a href="/contacts/{{ rel.related_contact_id }}">{{ contact_names[rel.related_contact_id] }}</a><span class="id-rel-type">({{ rel.relationship_type|replace("_", " ")|title }})</span>{% if not loop.last %}, {% endif %}
{% endfor %}
</div>
{% endif %}
{% if grouped_relationships.other %}
<div class="id-rel-group">
<span class="id-rel-label">Other:</span>
{% for rel in grouped_relationships.other %}
<a href="/contacts/{{ rel.related_contact_id }}">{{ contact_names[rel.related_contact_id] }}</a><span class="id-rel-type">({{ rel.relationship_type|replace("_", " ")|title }})</span>{% if not loop.last %}, {% endif %}
{% endfor %}
</div>
{% endif %}
{% if contact.related_from %}
<div class="id-rel-group">
<span class="id-rel-label">Known by:</span>
{% for rel in contact.related_from %}
<a href="/contacts/{{ rel.contact_id }}">{{ contact_names[rel.contact_id] }}</a>{% if not loop.last %}, {% endif %}
{% endfor %}
</div>
{% endif %}
</div>
</div>
</div>
{% if contact.needs %}
<div class="id-card-warnings">
{% for need in contact.needs %}
<div class="id-warning">
<span class="warning-dot"></span>
Warning: {{ need.name }}
{% if need.description %}<span class="warning-desc"> - {{ need.description }}</span>{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
</div>
<details class="id-card-manage">
<summary>Manage Contact</summary>
<div class="manage-section">
<h3>Manage Relationships</h3>
<div id="manage-relationships" class="manage-relationships">
{% include "partials/manage_relationships.html" %}
</div>
{% if all_contacts %}
<form hx-post="/htmx/contacts/{{ contact.id }}/add-relationship"
hx-target="#manage-relationships"
hx-swap="innerHTML"
class="add-form">
<select name="related_contact_id" required>
<option value="">Select contact...</option>
{% for other in all_contacts %}
{% if other.id != contact.id %}
<option value="{{ other.id }}">{{ other.name }}</option>
{% endif %}
{% endfor %}
</select>
<select name="relationship_type" required>
<option value="">Select relationship type...</option>
{% for rel_type in relationship_types %}
<option value="{{ rel_type.value }}">{{ rel_type.display_name }}</option>
{% endfor %}
</select>
<button type="submit" class="btn btn-primary">Add Relationship</button>
</form>
{% endif %}
</div>
<div class="manage-section">
<h3>Manage Needs/Warnings</h3>
<div id="manage-needs">
{% include "partials/manage_needs.html" %}
</div>
{% if available_needs %}
<form hx-post="/htmx/contacts/{{ contact.id }}/add-need"
hx-target="#manage-needs"
hx-swap="innerHTML"
class="add-form">
<select name="need_id" required>
<option value="">Select a need...</option>
{% for need in available_needs %}
<option value="{{ need.id }}">{{ need.name }}</option>
{% endfor %}
</select>
<button type="submit" class="btn btn-primary">Add Need</button>
</form>
{% endif %}
</div>
</details>
</div>
{% endblock %}

View File

@@ -0,0 +1,115 @@
{% extends "base.html" %}
{% block title %}{{ "Edit " + contact.name if contact else "New Contact" }}{% endblock %}
{% block content %}
<div class="contact-form">
<h1>{{ "Edit Contact" if contact else "New Contact" }}</h1>
{% if contact %}
<form method="post" action="/htmx/contacts/{{ contact.id }}/edit">
{% else %}
<form method="post" action="/htmx/contacts/new">
{% endif %}
<div class="form-group">
<label for="name">Name *</label>
<input id="name" name="name" type="text" value="{{ contact.name if contact else '' }}" required>
</div>
<div class="form-row">
<div class="form-group">
<label for="legal_name">Legal Name</label>
<input id="legal_name" name="legal_name" type="text" value="{{ contact.legal_name or '' }}">
</div>
<div class="form-group">
<label for="suffix">Suffix</label>
<input id="suffix" name="suffix" type="text" value="{{ contact.suffix or '' }}">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="age">Age</label>
<input id="age" name="age" type="number" value="{{ contact.age if contact and contact.age is not none else '' }}">
</div>
<div class="form-group">
<label for="gender">Gender</label>
<input id="gender" name="gender" type="text" value="{{ contact.gender or '' }}">
</div>
</div>
<div class="form-group">
<label for="current_job">Current Job</label>
<input id="current_job" name="current_job" type="text" value="{{ contact.current_job or '' }}">
</div>
<div class="form-group">
<label for="timezone">Timezone</label>
<input id="timezone" name="timezone" type="text" value="{{ contact.timezone or '' }}">
</div>
<div class="form-group">
<label for="profile_pic">Profile Picture URL</label>
<input id="profile_pic" name="profile_pic" type="url" placeholder="https://example.com/photo.jpg" value="{{ contact.profile_pic or '' }}">
</div>
<div class="form-group">
<label for="bio">Bio</label>
<textarea id="bio" name="bio" rows="3">{{ contact.bio or '' }}</textarea>
</div>
<div class="form-group">
<label for="goals">Goals</label>
<textarea id="goals" name="goals" rows="3">{{ contact.goals or '' }}</textarea>
</div>
<div class="form-group">
<label for="social_structure_style">Social Structure Style</label>
<input id="social_structure_style" name="social_structure_style" type="text" value="{{ contact.social_structure_style or '' }}">
</div>
<div class="form-group">
<label for="self_sufficiency_score">Self-Sufficiency Score (1-10)</label>
<input id="self_sufficiency_score" name="self_sufficiency_score" type="number" min="1" max="10" value="{{ contact.self_sufficiency_score if contact and contact.self_sufficiency_score is not none else '' }}">
</div>
<div class="form-group">
<label for="safe_conversation_starters">Safe Conversation Starters</label>
<textarea id="safe_conversation_starters" name="safe_conversation_starters" rows="2">{{ contact.safe_conversation_starters or '' }}</textarea>
</div>
<div class="form-group">
<label for="topics_to_avoid">Topics to Avoid</label>
<textarea id="topics_to_avoid" name="topics_to_avoid" rows="2">{{ contact.topics_to_avoid or '' }}</textarea>
</div>
<div class="form-group">
<label for="ssn">SSN</label>
<input id="ssn" name="ssn" type="text" value="{{ contact.ssn or '' }}">
</div>
{% if all_needs %}
<div class="form-group">
<label>Needs/Accommodations</label>
<div class="checkbox-group">
{% for need in all_needs %}
<label class="checkbox-label">
<input type="checkbox" name="need_ids" value="{{ need.id }}"
{% if contact and need in contact.needs %}checked{% endif %}>
{{ need.name }}
</label>
{% endfor %}
</div>
</div>
{% endif %}
<div class="form-actions">
<button type="submit" class="btn btn-primary">Save</button>
{% if contact %}
<a href="/contacts/{{ contact.id }}" class="btn">Cancel</a>
{% else %}
<a href="/contacts" class="btn">Cancel</a>
{% endif %}
</div>
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,14 @@
{% extends "base.html" %}
{% block title %}Contacts{% endblock %}
{% block content %}
<div class="contact-list">
<div class="header">
<h1>Contacts</h1>
<a href="/contacts/new" class="btn btn-primary">Add Contact</a>
</div>
<div id="contact-table">
{% include "partials/contact_table.html" %}
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,198 @@
{% extends "base.html" %}
{% block title %}Relationship Graph{% endblock %}
{% block content %}
<div class="graph-container">
<div class="header">
<h1>Relationship Graph</h1>
</div>
<p class="graph-hint">Drag nodes to reposition. Closer relationships have shorter, darker edges.</p>
<canvas id="graph-canvas" width="900" height="600"
style="border: 1px solid var(--color-border); border-radius: 8px; background: var(--color-bg); cursor: grab;">
</canvas>
<div id="selected-info"></div>
<div class="legend">
<h4>Relationship Closeness (1-10)</h4>
<div class="legend-items">
<div class="legend-item">
<span class="legend-line" style="background: hsl(220, 70%, 40%); height: 4px; display: inline-block;"></span>
<span>10 - Very Close (Spouse, Partner)</span>
</div>
<div class="legend-item">
<span class="legend-line" style="background: hsl(220, 70%, 52%); height: 3px; display: inline-block;"></span>
<span>7 - Close (Family, Best Friend)</span>
</div>
<div class="legend-item">
<span class="legend-line" style="background: hsl(220, 70%, 64%); height: 2px; display: inline-block;"></span>
<span>4 - Moderate (Friend, Colleague)</span>
</div>
<div class="legend-item">
<span class="legend-line" style="background: hsl(220, 70%, 72%); height: 1px; display: inline-block;"></span>
<span>2 - Distant (Acquaintance)</span>
</div>
</div>
</div>
</div>
<script>
(function() {
const RELATIONSHIP_DISPLAY = {{ relationship_type_display|tojson }};
const graphData = {{ graph_data|tojson }};
const canvas = document.getElementById('graph-canvas');
const ctx = canvas.getContext('2d');
const width = canvas.width;
const height = canvas.height;
const centerX = width / 2;
const centerY = height / 2;
const nodes = graphData.nodes.map(function(node) {
return Object.assign({}, node, {
x: centerX + (Math.random() - 0.5) * 300,
y: centerY + (Math.random() - 0.5) * 300,
vx: 0,
vy: 0
});
});
const nodeMap = new Map(nodes.map(function(node) { return [node.id, node]; }));
const edges = graphData.edges.map(function(edge) {
const sourceNode = nodeMap.get(edge.source);
const targetNode = nodeMap.get(edge.target);
if (!sourceNode || !targetNode) return null;
return Object.assign({}, edge, { sourceNode: sourceNode, targetNode: targetNode });
}).filter(function(edge) { return edge !== null; });
let dragNode = null;
let selectedNode = null;
const repulsion = 5000;
const springStrength = 0.05;
const baseSpringLength = 150;
const damping = 0.9;
const centerPull = 0.01;
function simulate() {
for (const node of nodes) { node.vx = 0; node.vy = 0; }
for (let i = 0; i < nodes.length; i++) {
for (let j = i + 1; j < nodes.length; j++) {
const dx = nodes[j].x - nodes[i].x;
const dy = nodes[j].y - nodes[i].y;
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
const force = repulsion / (dist * dist);
const fx = (dx / dist) * force;
const fy = (dy / dist) * force;
nodes[i].vx -= fx; nodes[i].vy -= fy;
nodes[j].vx += fx; nodes[j].vy += fy;
}
}
for (const edge of edges) {
const dx = edge.targetNode.x - edge.sourceNode.x;
const dy = edge.targetNode.y - edge.sourceNode.y;
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
const normalizedWeight = edge.closeness_weight / 10;
const idealLength = baseSpringLength * (1.5 - normalizedWeight);
const displacement = dist - idealLength;
const force = springStrength * displacement;
const fx = (dx / dist) * force;
const fy = (dy / dist) * force;
edge.sourceNode.vx += fx; edge.sourceNode.vy += fy;
edge.targetNode.vx -= fx; edge.targetNode.vy -= fy;
}
for (const node of nodes) {
node.vx += (centerX - node.x) * centerPull;
node.vy += (centerY - node.y) * centerPull;
}
for (const node of nodes) {
if (node === dragNode) continue;
node.x += node.vx * damping;
node.y += node.vy * damping;
node.x = Math.max(30, Math.min(width - 30, node.x));
node.y = Math.max(30, Math.min(height - 30, node.y));
}
}
function getEdgeColor(weight) {
const normalized = weight / 10;
return 'hsl(220, 70%, ' + (80 - normalized * 40) + '%)';
}
function draw() {
ctx.clearRect(0, 0, width, height);
for (const edge of edges) {
const lineWidth = 1 + (edge.closeness_weight / 10) * 3;
ctx.strokeStyle = getEdgeColor(edge.closeness_weight);
ctx.lineWidth = lineWidth;
ctx.beginPath();
ctx.moveTo(edge.sourceNode.x, edge.sourceNode.y);
ctx.lineTo(edge.targetNode.x, edge.targetNode.y);
ctx.stroke();
const midX = (edge.sourceNode.x + edge.targetNode.x) / 2;
const midY = (edge.sourceNode.y + edge.targetNode.y) / 2;
ctx.fillStyle = '#666';
ctx.font = '10px sans-serif';
ctx.textAlign = 'center';
const label = RELATIONSHIP_DISPLAY[edge.relationship_type] || edge.relationship_type;
ctx.fillText(label, midX, midY - 5);
}
for (const node of nodes) {
const isSelected = node === selectedNode;
const radius = isSelected ? 25 : 20;
ctx.beginPath();
ctx.arc(node.x, node.y, radius, 0, Math.PI * 2);
ctx.fillStyle = isSelected ? '#0066cc' : '#fff';
ctx.fill();
ctx.strokeStyle = '#0066cc';
ctx.lineWidth = 2;
ctx.stroke();
ctx.fillStyle = isSelected ? '#fff' : '#333';
ctx.font = '12px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const name = node.name.length > 10 ? node.name.slice(0, 9) + '\u2026' : node.name;
ctx.fillText(name, node.x, node.y);
}
}
function animate() {
simulate();
draw();
requestAnimationFrame(animate);
}
animate();
function getNodeAt(x, y) {
for (const node of nodes) {
const dx = x - node.x;
const dy = y - node.y;
if (dx * dx + dy * dy < 400) return node;
}
return null;
}
canvas.addEventListener('mousedown', function(event) {
const rect = canvas.getBoundingClientRect();
const node = getNodeAt(event.clientX - rect.left, event.clientY - rect.top);
if (node) {
dragNode = node;
selectedNode = node;
const infoDiv = document.getElementById('selected-info');
let html = '<div class="selected-info"><h3>' + node.name + '</h3>';
if (node.current_job) html += '<p>Job: ' + node.current_job + '</p>';
html += '<a href="/contacts/' + node.id + '">View details</a></div>';
infoDiv.innerHTML = html;
}
});
canvas.addEventListener('mousemove', function(event) {
if (!dragNode) return;
const rect = canvas.getBoundingClientRect();
dragNode.x = event.clientX - rect.left;
dragNode.y = event.clientY - rect.top;
});
canvas.addEventListener('mouseup', function() { dragNode = null; });
canvas.addEventListener('mouseleave', function() { dragNode = null; });
})();
</script>
{% endblock %}

View File

@@ -0,0 +1,31 @@
{% extends "base.html" %}
{% block title %}Needs{% endblock %}
{% block content %}
<div class="need-list">
<div class="header">
<h1>Needs / Accommodations</h1>
<button class="btn btn-primary" onclick="document.getElementById('need-form').toggleAttribute('hidden')">Add Need</button>
</div>
<form id="need-form" hidden
hx-post="/htmx/needs"
hx-target="#need-items"
hx-swap="innerHTML"
hx-on::after-request="if(event.detail.successful) this.reset()"
class="need-form">
<div class="form-group">
<label for="name">Name *</label>
<input id="name" name="name" type="text" placeholder="e.g., Light Sensitive, ADHD" required>
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea id="description" name="description" placeholder="Optional description..." rows="2"></textarea>
</div>
<button type="submit" class="btn btn-primary">Create</button>
</form>
<div id="need-items">
{% include "partials/need_items.html" %}
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,33 @@
{% if contacts %}
<table>
<thead>
<tr>
<th>Name</th>
<th>Job</th>
<th>Timezone</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for contact in contacts %}
<tr id="contact-row-{{ contact.id }}">
<td><a href="/contacts/{{ contact.id }}">{{ contact.name }}</a></td>
<td>{{ contact.current_job or "-" }}</td>
<td>{{ contact.timezone or "-" }}</td>
<td>
<a href="/contacts/{{ contact.id }}/edit" class="btn">Edit</a>
<button class="btn btn-danger"
hx-delete="/api/contacts/{{ contact.id }}"
hx-target="#contact-row-{{ contact.id }}"
hx-swap="outerHTML"
hx-confirm="Delete this contact?">
Delete
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No contacts yet.</p>
{% endif %}

View File

@@ -0,0 +1,14 @@
<ul class="manage-needs-list">
{% for need in contact.needs %}
<li id="contact-need-{{ need.id }}">
<strong>{{ need.name }}</strong>
{% if need.description %}<span> - {{ need.description }}</span>{% endif %}
<button class="btn btn-small btn-danger"
hx-delete="/api/contacts/{{ contact.id }}/needs/{{ need.id }}"
hx-target="#contact-need-{{ need.id }}"
hx-swap="outerHTML">
Remove
</button>
</li>
{% endfor %}
</ul>

View File

@@ -0,0 +1,23 @@
{% for rel in contact.related_to %}
<div class="manage-rel-item" id="rel-{{ contact.id }}-{{ rel.related_contact_id }}">
<a href="/contacts/{{ rel.related_contact_id }}">{{ contact_names[rel.related_contact_id] }}</a>
<span class="tag">{{ rel.relationship_type|replace("_", " ")|title }}</span>
<label class="weight-control">
<span>Closeness:</span>
<input type="range" min="1" max="10" value="{{ rel.closeness_weight }}"
hx-post="/htmx/contacts/{{ contact.id }}/relationships/{{ rel.related_contact_id }}/weight"
hx-trigger="change"
hx-include="this"
name="closeness_weight"
hx-swap="none"
oninput="this.nextElementSibling.textContent = this.value">
<span class="weight-value">{{ rel.closeness_weight }}</span>
</label>
<button class="btn btn-small btn-danger"
hx-delete="/api/contacts/{{ contact.id }}/relationships/{{ rel.related_contact_id }}"
hx-target="#rel-{{ contact.id }}-{{ rel.related_contact_id }}"
hx-swap="outerHTML">
Remove
</button>
</div>
{% endfor %}

View File

@@ -0,0 +1,21 @@
{% if needs %}
<ul class="need-items">
{% for need in needs %}
<li id="need-item-{{ need.id }}">
<div class="need-info">
<strong>{{ need.name }}</strong>
{% if need.description %}<p>{{ need.description }}</p>{% endif %}
</div>
<button class="btn btn-danger"
hx-delete="/api/needs/{{ need.id }}"
hx-target="#need-item-{{ need.id }}"
hx-swap="outerHTML"
hx-confirm="Delete this need?">
Delete
</button>
</li>
{% endfor %}
</ul>
{% else %}
<p>No needs defined yet.</p>
{% endif %}

View File

@@ -83,6 +83,13 @@ DATABASES: dict[str, DatabaseConfig] = {
base_class_name="VanInventoryBase", base_class_name="VanInventoryBase",
models_module="python.orm.van_inventory.models", models_module="python.orm.van_inventory.models",
), ),
"signal_bot": DatabaseConfig(
env_prefix="SIGNALBOT",
version_location="python/alembic/signal_bot/versions",
base_module="python.orm.signal_bot.base",
base_class_name="SignalBotBase",
models_module="python.orm.signal_bot.models",
),
} }

View File

@@ -1,9 +1,11 @@
"""ORM package exports.""" """ORM package exports."""
from python.orm.richie.base import RichieBase from python.orm.richie.base import RichieBase
from python.orm.signal_bot.base import SignalBotBase
from python.orm.van_inventory.base import VanInventoryBase from python.orm.van_inventory.base import VanInventoryBase
__all__ = [ __all__ = [
"RichieBase", "RichieBase",
"SignalBotBase",
"VanInventoryBase", "VanInventoryBase",
] ]

View File

@@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from python.orm.richie.base import RichieBase, TableBase from python.orm.richie.base import RichieBase, TableBase, TableBaseBig, TableBaseSmall
from python.orm.richie.congress import Bill, Legislator, Vote, VoteRecord from python.orm.richie.congress import Bill, Legislator, Vote, VoteRecord
from python.orm.richie.contact import ( from python.orm.richie.contact import (
Contact, Contact,
@@ -11,21 +11,19 @@ from python.orm.richie.contact import (
Need, Need,
RelationshipType, RelationshipType,
) )
from python.orm.richie.dead_letter_message import DeadLetterMessage
from python.orm.richie.signal_device import SignalDevice
__all__ = [ __all__ = [
"Bill", "Bill",
"Contact", "Contact",
"ContactNeed", "ContactNeed",
"ContactRelationship", "ContactRelationship",
"DeadLetterMessage",
"Legislator", "Legislator",
"Need", "Need",
"RelationshipType", "RelationshipType",
"RichieBase", "RichieBase",
"SignalDevice",
"TableBase", "TableBase",
"TableBaseBig",
"TableBaseSmall",
"Vote", "Vote",
"VoteRecord", "VoteRecord",
] ]

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
from sqlalchemy import DateTime, MetaData, func from sqlalchemy import BigInteger, DateTime, MetaData, SmallInteger, func
from sqlalchemy.ext.declarative import AbstractConcreteBase from sqlalchemy.ext.declarative import AbstractConcreteBase
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
@@ -22,12 +22,9 @@ class RichieBase(DeclarativeBase):
) )
class TableBase(AbstractConcreteBase, RichieBase): class _TableMixin:
"""Abstract concrete base for richie tables with IDs and timestamps.""" """Shared timestamp columns for all table bases."""
__abstract__ = True
id: Mapped[int] = mapped_column(primary_key=True)
created: Mapped[datetime] = mapped_column( created: Mapped[datetime] = mapped_column(
DateTime(timezone=True), DateTime(timezone=True),
server_default=func.now(), server_default=func.now(),
@@ -37,3 +34,27 @@ class TableBase(AbstractConcreteBase, RichieBase):
server_default=func.now(), server_default=func.now(),
onupdate=func.now(), onupdate=func.now(),
) )
class TableBaseSmall(_TableMixin, AbstractConcreteBase, RichieBase):
"""Table with SmallInteger primary key."""
__abstract__ = True
id: Mapped[int] = mapped_column(SmallInteger, primary_key=True)
class TableBase(_TableMixin, AbstractConcreteBase, RichieBase):
"""Table with Integer primary key."""
__abstract__ = True
id: Mapped[int] = mapped_column(primary_key=True)
class TableBaseBig(_TableMixin, AbstractConcreteBase, RichieBase):
"""Table with BigInteger primary key."""
__abstract__ = True
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)

View File

@@ -1,26 +0,0 @@
"""Dead letter queue for Signal bot messages that fail processing."""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, Text
from sqlalchemy.dialects.postgresql import ENUM
from sqlalchemy.orm import Mapped, mapped_column
from python.orm.richie.base import TableBase
from python.signal_bot.models import MessageStatus
class DeadLetterMessage(TableBase):
"""A Signal message that failed processing and was sent to the dead letter queue."""
__tablename__ = "dead_letter_message"
source: Mapped[str]
message: Mapped[str] = mapped_column(Text)
received_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
status: Mapped[MessageStatus] = mapped_column(
ENUM(MessageStatus, name="message_status", create_type=True, schema="main"),
default=MessageStatus.UNPROCESSED,
)

View File

@@ -1,26 +0,0 @@
"""Signal bot device registry models."""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, String
from sqlalchemy.dialects.postgresql import ENUM
from sqlalchemy.orm import Mapped, mapped_column
from python.orm.richie.base import TableBase
from python.signal_bot.models import TrustLevel
class SignalDevice(TableBase):
"""A Signal device tracked by phone number and safety number."""
__tablename__ = "signal_device"
phone_number: Mapped[str] = mapped_column(String(50), unique=True)
safety_number: Mapped[str | None]
trust_level: Mapped[TrustLevel] = mapped_column(
ENUM(TrustLevel, name="trust_level", create_type=True, schema="main"),
default=TrustLevel.UNVERIFIED,
)
last_seen: Mapped[datetime] = mapped_column(DateTime(timezone=True))

View File

@@ -0,0 +1,16 @@
"""Signal bot database ORM exports."""
from __future__ import annotations
from python.orm.signal_bot.base import SignalBotBase, SignalBotTableBase, SignalBotTableBaseSmall
from python.orm.signal_bot.models import DeadLetterMessage, DeviceRole, RoleRecord, SignalDevice
__all__ = [
"DeadLetterMessage",
"DeviceRole",
"RoleRecord",
"SignalBotBase",
"SignalBotTableBase",
"SignalBotTableBaseSmall",
"SignalDevice",
]

View File

@@ -0,0 +1,52 @@
"""Signal bot database ORM base."""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, MetaData, SmallInteger, func
from sqlalchemy.ext.declarative import AbstractConcreteBase
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from python.orm.common import NAMING_CONVENTION
class SignalBotBase(DeclarativeBase):
"""Base class for signal_bot database ORM models."""
schema_name = "main"
metadata = MetaData(
schema=schema_name,
naming_convention=NAMING_CONVENTION,
)
class _TableMixin:
"""Shared timestamp columns for all table bases."""
created: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
)
updated: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
)
class SignalBotTableBaseSmall(_TableMixin, AbstractConcreteBase, SignalBotBase):
"""Table with SmallInteger primary key."""
__abstract__ = True
id: Mapped[int] = mapped_column(SmallInteger, primary_key=True)
class SignalBotTableBase(_TableMixin, AbstractConcreteBase, SignalBotBase):
"""Table with Integer primary key."""
__abstract__ = True
id: Mapped[int] = mapped_column(primary_key=True)

View File

@@ -0,0 +1,62 @@
"""Signal bot device, role, and dead letter ORM models."""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, Enum, ForeignKey, SmallInteger, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from python.orm.signal_bot.base import SignalBotTableBase, SignalBotTableBaseSmall
from python.signal_bot.models import MessageStatus, TrustLevel
class RoleRecord(SignalBotTableBaseSmall):
"""Lookup table for RBAC roles, keyed by smallint."""
__tablename__ = "role"
name: Mapped[str] = mapped_column(String(50), unique=True)
class DeviceRole(SignalBotTableBase):
"""Association between a device and a role."""
__tablename__ = "device_role"
__table_args__ = (
UniqueConstraint("device_id", "role_id", name="uq_device_role_device_role"),
{"schema": "main"},
)
device_id: Mapped[int] = mapped_column(ForeignKey("main.signal_device.id"))
role_id: Mapped[int] = mapped_column(SmallInteger, ForeignKey("main.role.id"))
class SignalDevice(SignalBotTableBase):
"""A Signal device tracked by phone number and safety number."""
__tablename__ = "signal_device"
phone_number: Mapped[str] = mapped_column(String(50), unique=True)
safety_number: Mapped[str | None]
trust_level: Mapped[TrustLevel] = mapped_column(
Enum(TrustLevel, name="trust_level", create_constraint=False, native_enum=False),
default=TrustLevel.UNVERIFIED,
)
last_seen: Mapped[datetime] = mapped_column(DateTime(timezone=True))
roles: Mapped[list[RoleRecord]] = relationship(secondary=DeviceRole.__table__)
class DeadLetterMessage(SignalBotTableBase):
"""A Signal message that failed processing and was sent to the dead letter queue."""
__tablename__ = "dead_letter_message"
source: Mapped[str]
message: Mapped[str] = mapped_column(Text)
received_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
status: Mapped[MessageStatus] = mapped_column(
Enum(MessageStatus, name="message_status", create_constraint=False, native_enum=False),
default=MessageStatus.UNPROCESSED,
)

View File

@@ -1 +0,0 @@
"""Sheet music OCR tool using Audiveris."""

View File

@@ -1,62 +0,0 @@
"""Audiveris subprocess wrapper for optical music recognition."""
from __future__ import annotations
import shutil
import subprocess
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from pathlib import Path
class AudiverisError(Exception):
"""Raised when Audiveris processing fails."""
def find_audiveris() -> str:
"""Find the Audiveris executable on PATH.
Returns:
Path to the audiveris executable.
Raises:
AudiverisError: If Audiveris is not found.
"""
path = shutil.which("audiveris")
if not path:
msg = "Audiveris not found on PATH. Install it via 'nix develop' or add it to your environment."
raise AudiverisError(msg)
return path
def run_audiveris(input_path: Path, output_dir: Path) -> Path:
"""Run Audiveris on an input file and return the path to the generated .mxl.
Args:
input_path: Path to the input sheet music file (PDF, PNG, JPG, TIFF).
output_dir: Directory where Audiveris will write its output.
Returns:
Path to the generated .mxl file.
Raises:
AudiverisError: If Audiveris fails or produces no output.
"""
audiveris = find_audiveris()
result = subprocess.run(
[audiveris, "-batch", "-export", "-output", str(output_dir), str(input_path)],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
msg = f"Audiveris failed (exit {result.returncode}):\n{result.stderr}"
raise AudiverisError(msg)
mxl_files = list(output_dir.rglob("*.mxl"))
if not mxl_files:
msg = f"Audiveris produced no .mxl output in {output_dir}"
raise AudiverisError(msg)
return mxl_files[0]

View File

@@ -1,123 +0,0 @@
"""CLI tool for converting scanned sheet music to MusicXML.
Usage:
sheet-music-ocr convert scan.pdf
sheet-music-ocr convert scan.png -o output.mxml
sheet-music-ocr review output.mxml --provider claude
"""
from __future__ import annotations
import tempfile
import zipfile
from pathlib import Path
from typing import Annotated
import typer
from python.sheet_music_ocr.audiveris import AudiverisError, run_audiveris
from python.sheet_music_ocr.review import LLMProvider, ReviewError, review_mxml
SUPPORTED_EXTENSIONS = {".pdf", ".png", ".jpg", ".jpeg", ".tiff", ".tif"}
app = typer.Typer(help="Convert scanned sheet music to MusicXML using Audiveris.")
def extract_mxml_from_mxl(mxl_path: Path, output_path: Path) -> Path:
"""Extract the MusicXML file from an .mxl archive.
An .mxl file is a ZIP archive containing one or more .xml MusicXML files.
Args:
mxl_path: Path to the .mxl file.
output_path: Path where the extracted .mxml file should be written.
Returns:
The output path.
Raises:
FileNotFoundError: If no MusicXML file is found inside the archive.
"""
with zipfile.ZipFile(mxl_path, "r") as zf:
xml_names = [n for n in zf.namelist() if n.endswith(".xml") and not n.startswith("META-INF")]
if not xml_names:
msg = f"No MusicXML (.xml) file found inside {mxl_path}"
raise FileNotFoundError(msg)
with zf.open(xml_names[0]) as src, output_path.open("wb") as dst:
dst.write(src.read())
return output_path
@app.command()
def convert(
input_file: Annotated[Path, typer.Argument(help="Path to sheet music scan (PDF, PNG, JPG, TIFF).")],
output: Annotated[
Path | None,
typer.Option("--output", "-o", help="Output .mxml file path. Defaults to <input_stem>.mxml."),
] = None,
) -> None:
"""Convert a scanned sheet music file to MusicXML."""
if not input_file.exists():
typer.echo(f"Error: {input_file} does not exist.", err=True)
raise typer.Exit(code=1)
if input_file.suffix.lower() not in SUPPORTED_EXTENSIONS:
typer.echo(
f"Error: Unsupported format '{input_file.suffix}'. Supported: {', '.join(sorted(SUPPORTED_EXTENSIONS))}",
err=True,
)
raise typer.Exit(code=1)
output_path = output or input_file.with_suffix(".mxml")
with tempfile.TemporaryDirectory() as tmpdir:
try:
mxl_path = run_audiveris(input_file, Path(tmpdir))
except AudiverisError as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(code=1) from e
try:
extract_mxml_from_mxl(mxl_path, output_path)
except FileNotFoundError as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(code=1) from e
typer.echo(f"Written: {output_path}")
@app.command()
def review(
input_file: Annotated[Path, typer.Argument(help="Path to MusicXML (.mxml) file to review.")],
output: Annotated[
Path | None,
typer.Option("--output", "-o", help="Output path for corrected .mxml. Defaults to overwriting input."),
] = None,
provider: Annotated[
LLMProvider,
typer.Option("--provider", "-p", help="LLM provider to use."),
] = LLMProvider.CLAUDE,
) -> None:
"""Review and fix a MusicXML file using an LLM."""
if not input_file.exists():
typer.echo(f"Error: {input_file} does not exist.", err=True)
raise typer.Exit(code=1)
if input_file.suffix.lower() != ".mxml":
typer.echo("Error: Input file must be a .mxml file.", err=True)
raise typer.Exit(code=1)
output_path = output or input_file
try:
corrected = review_mxml(input_file, provider)
except ReviewError as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(code=1) from e
output_path.write_text(corrected, encoding="utf-8")
typer.echo(f"Reviewed: {output_path}")
if __name__ == "__main__":
app()

View File

@@ -1,126 +0,0 @@
"""LLM-based MusicXML review and correction.
Supports both Claude (Anthropic) and OpenAI APIs for reviewing
MusicXML output from Audiveris and suggesting/applying fixes.
"""
from __future__ import annotations
import enum
import os
from typing import TYPE_CHECKING
import httpx
if TYPE_CHECKING:
from pathlib import Path
REVIEW_PROMPT = """\
You are a music notation expert. Review the following MusicXML file produced by \
optical music recognition (Audiveris). Look for and fix common OCR errors including:
- Incorrect note pitches or durations
- Wrong or missing key signatures, time signatures, or clefs
- Incorrect rest durations or placements
- Missing or incorrect accidentals
- Wrong beam groupings or tuplets
- Garbled or misspelled lyrics and text annotations
- Missing or incorrect dynamic markings
- Incorrect measure numbers or barline types
- Voice/staff assignment errors
Return ONLY the corrected MusicXML. Do not include any explanation, commentary, or \
markdown formatting. Output the raw XML directly.
Here is the MusicXML to review:
"""
_TIMEOUT = 300
class LLMProvider(enum.StrEnum):
"""Supported LLM providers."""
CLAUDE = "claude"
OPENAI = "openai"
class ReviewError(Exception):
"""Raised when LLM review fails."""
def _get_api_key(provider: LLMProvider) -> str:
env_var = "ANTHROPIC_API_KEY" if provider == LLMProvider.CLAUDE else "OPENAI_API_KEY"
key = os.environ.get(env_var)
if not key:
msg = f"{env_var} environment variable is not set."
raise ReviewError(msg)
return key
def _call_claude(content: str, api_key: str) -> str:
response = httpx.post(
"https://api.anthropic.com/v1/messages",
headers={
"x-api-key": api_key,
"anthropic-version": "2023-06-01",
"content-type": "application/json",
},
json={
"model": "claude-sonnet-4-20250514",
"max_tokens": 16384,
"messages": [{"role": "user", "content": REVIEW_PROMPT + content}],
},
timeout=_TIMEOUT,
)
if response.status_code != 200: # noqa: PLR2004
msg = f"Claude API error ({response.status_code}): {response.text}"
raise ReviewError(msg)
data = response.json()
return data["content"][0]["text"]
def _call_openai(content: str, api_key: str) -> str:
response = httpx.post(
"https://api.openai.com/v1/chat/completions",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
json={
"model": "gpt-4o",
"messages": [{"role": "user", "content": REVIEW_PROMPT + content}],
"max_tokens": 16384,
},
timeout=_TIMEOUT,
)
if response.status_code != 200: # noqa: PLR2004
msg = f"OpenAI API error ({response.status_code}): {response.text}"
raise ReviewError(msg)
data = response.json()
return data["choices"][0]["message"]["content"]
def review_mxml(mxml_path: Path, provider: LLMProvider) -> str:
"""Review a MusicXML file using an LLM and return corrected content.
Args:
mxml_path: Path to the .mxml file to review.
provider: Which LLM provider to use.
Returns:
The corrected MusicXML content as a string.
Raises:
ReviewError: If the API call fails or the key is missing.
FileNotFoundError: If the input file does not exist.
"""
content = mxml_path.read_text(encoding="utf-8")
api_key = _get_api_key(provider)
if provider == LLMProvider.CLAUDE:
return _call_claude(content, api_key)
return _call_openai(content, api_key)

View File

@@ -0,0 +1,64 @@
"""Location command for the Signal bot."""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any
import httpx
if TYPE_CHECKING:
from python.signal_bot.models import SignalMessage
from python.signal_bot.signal_client import SignalClient
logger = logging.getLogger(__name__)
def _get_entity_state(ha_url: str, ha_token: str, entity_id: str) -> dict[str, Any]:
"""Fetch an entity's state from Home Assistant."""
entity_url = f"{ha_url}/api/states/{entity_id}"
logger.debug(f"Fetching {entity_url=}")
response = httpx.get(
entity_url,
headers={"Authorization": f"Bearer {ha_token}"},
timeout=30,
)
response.raise_for_status()
return response.json()
def _format_location(latitude: str, longitude: str) -> str:
"""Render a friendly location response."""
return f"Van location: {latitude}, {longitude}\nhttps://maps.google.com/?q={latitude},{longitude}"
def handle_location_request(
message: SignalMessage,
signal: SignalClient,
ha_url: str | None,
ha_token: str | None,
) -> None:
"""Reply with van location from Home Assistant."""
if ha_url is None or ha_token is None:
signal.reply(message, "Location command is not configured (missing HA_URL or HA_TOKEN).")
return
lat_payload = None
lon_payload = None
try:
lat_payload = _get_entity_state(ha_url, ha_token, "sensor.van_last_known_latitude")
lon_payload = _get_entity_state(ha_url, ha_token, "sensor.van_last_known_longitude")
except httpx.HTTPError:
logger.exception("Couldn't fetch van location from Home Assistant right now.")
logger.debug(f"{ha_url=} {lat_payload=} {lon_payload=}")
signal.reply(message, "Couldn't fetch van location from Home Assistant right now.")
return
latitude = lat_payload.get("state", "")
longitude = lon_payload.get("state", "")
if not latitude or not longitude or latitude == "unavailable" or longitude == "unavailable":
signal.reply(message, "Van location is unavailable in Home Assistant right now.")
return
signal.reply(message, _format_location(latitude, longitude))

View File

@@ -6,12 +6,12 @@ import logging
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import TYPE_CHECKING, NamedTuple from typing import TYPE_CHECKING, NamedTuple
from sqlalchemy import select from sqlalchemy import delete, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from python.common import utcnow from python.common import utcnow
from python.orm.richie.signal_device import SignalDevice from python.orm.signal_bot.models import RoleRecord, SignalDevice
from python.signal_bot.models import TrustLevel from python.signal_bot.models import Role, TrustLevel
if TYPE_CHECKING: if TYPE_CHECKING:
from sqlalchemy.engine import Engine from sqlalchemy.engine import Engine
@@ -29,6 +29,7 @@ class _CacheEntry(NamedTuple):
trust_level: TrustLevel trust_level: TrustLevel
has_safety_number: bool has_safety_number: bool
safety_number: str | None safety_number: str | None
roles: list[Role]
class DeviceRegistry: class DeviceRegistry:
@@ -50,7 +51,7 @@ class DeviceRegistry:
"""Check if a phone number is verified.""" """Check if a phone number is verified."""
if entry := self._cached(phone_number): if entry := self._cached(phone_number):
return entry.trust_level == TrustLevel.VERIFIED return entry.trust_level == TrustLevel.VERIFIED
device = self.get_device(phone_number) device = self._load_device(phone_number)
return device is not None and device.trust_level == TrustLevel.VERIFIED return device is not None and device.trust_level == TrustLevel.VERIFIED
def record_contact(self, phone_number: str, safety_number: str | None = None) -> None: def record_contact(self, phone_number: str, safety_number: str | None = None) -> None:
@@ -83,20 +84,13 @@ class DeviceRegistry:
logger.info(f"New device registered: {phone_number}") logger.info(f"New device registered: {phone_number}")
session.commit() session.commit()
self._update_cache(phone_number, device)
ttl = _BLOCKED_TTL if device.trust_level == TrustLevel.BLOCKED else _DEFAULT_TTL
self._contact_cache[phone_number] = _CacheEntry(
expires=now + ttl,
trust_level=device.trust_level,
has_safety_number=device.safety_number is not None,
safety_number=device.safety_number,
)
def has_safety_number(self, phone_number: str) -> bool: def has_safety_number(self, phone_number: str) -> bool:
"""Check if a device has a safety number on file.""" """Check if a device has a safety number on file."""
if entry := self._cached(phone_number): if entry := self._cached(phone_number):
return entry.has_safety_number return entry.has_safety_number
device = self.get_device(phone_number) device = self._load_device(phone_number)
return device is not None and device.safety_number is not None return device is not None and device.safety_number is not None
def verify(self, phone_number: str) -> bool: def verify(self, phone_number: str) -> bool:
@@ -116,12 +110,7 @@ class DeviceRegistry:
device.trust_level = TrustLevel.VERIFIED device.trust_level = TrustLevel.VERIFIED
self.signal_client.trust_identity(phone_number, trust_all_known_keys=True) self.signal_client.trust_identity(phone_number, trust_all_known_keys=True)
session.commit() session.commit()
self._contact_cache[phone_number] = _CacheEntry( self._update_cache(phone_number, device)
expires=utcnow() + _DEFAULT_TTL,
trust_level=TrustLevel.VERIFIED,
has_safety_number=device.safety_number is not None,
safety_number=device.safety_number,
)
logger.info(f"Device verified: {phone_number}") logger.info(f"Device verified: {phone_number}")
return True return True
@@ -133,6 +122,84 @@ class DeviceRegistry:
"""Reset a device to unverified.""" """Reset a device to unverified."""
return self._set_trust(phone_number, TrustLevel.UNVERIFIED) return self._set_trust(phone_number, TrustLevel.UNVERIFIED)
# -- role management ------------------------------------------------------
def get_roles(self, phone_number: str) -> list[Role]:
"""Return the roles for a device, defaulting to empty."""
if entry := self._cached(phone_number):
return entry.roles
device = self._load_device(phone_number)
return _extract_roles(device) if device else []
def has_role(self, phone_number: str, role: Role) -> bool:
"""Check if a device has a specific role or is admin."""
roles = self.get_roles(phone_number)
return Role.ADMIN in roles or role in roles
def grant_role(self, phone_number: str, role: Role) -> bool:
"""Add a role to a device. Called by admin over SSH."""
with Session(self.engine) as session:
device = session.execute(
select(SignalDevice).where(SignalDevice.phone_number == phone_number)
).scalar_one_or_none()
if not device:
logger.warning(f"Cannot grant role for unknown device: {phone_number}")
return False
if any(record.name == role for record in device.roles):
return True
role_record = session.execute(select(RoleRecord).where(RoleRecord.name == role)).scalar_one_or_none()
if not role_record:
logger.warning(f"Unknown role: {role}")
return False
device.roles.append(role_record)
session.commit()
self._update_cache(phone_number, device)
logger.info(f"Device {phone_number} granted role {role}")
return True
def revoke_role(self, phone_number: str, role: Role) -> bool:
"""Remove a role from a device. Called by admin over SSH."""
with Session(self.engine) as session:
device = session.execute(
select(SignalDevice).where(SignalDevice.phone_number == phone_number)
).scalar_one_or_none()
if not device:
logger.warning(f"Cannot revoke role for unknown device: {phone_number}")
return False
device.roles = [record for record in device.roles if record.name != role]
session.commit()
self._update_cache(phone_number, device)
logger.info(f"Device {phone_number} revoked role {role}")
return True
def set_roles(self, phone_number: str, roles: list[Role]) -> bool:
"""Replace all roles for a device. Called by admin over SSH."""
with Session(self.engine) as session:
device = session.execute(
select(SignalDevice).where(SignalDevice.phone_number == phone_number)
).scalar_one_or_none()
if not device:
logger.warning(f"Cannot set roles for unknown device: {phone_number}")
return False
role_names = [str(role) for role in roles]
records = list(session.execute(select(RoleRecord).where(RoleRecord.name.in_(role_names))).scalars().all())
device.roles = records
session.commit()
self._update_cache(phone_number, device)
logger.info(f"Device {phone_number} roles set to {role_names}")
return True
# -- queries --------------------------------------------------------------
def list_devices(self) -> list[SignalDevice]: def list_devices(self) -> list[SignalDevice]:
"""Return all known devices.""" """Return all known devices."""
with Session(self.engine) as session: with Session(self.engine) as session:
@@ -147,6 +214,8 @@ class DeviceRegistry:
if number: if number:
self.record_contact(number, safety) self.record_contact(number, safety)
# -- internals ------------------------------------------------------------
def _cached(self, phone_number: str) -> _CacheEntry | None: def _cached(self, phone_number: str) -> _CacheEntry | None:
"""Return the cache entry if it exists and hasn't expired.""" """Return the cache entry if it exists and hasn't expired."""
entry = self._contact_cache.get(phone_number) entry = self._contact_cache.get(phone_number)
@@ -154,13 +223,24 @@ class DeviceRegistry:
return entry return entry
return None return None
def get_device(self, phone_number: str) -> SignalDevice | None: def _load_device(self, phone_number: str) -> SignalDevice | None:
"""Fetch a device by phone number.""" """Fetch a device by phone number (with joined roles)."""
with Session(self.engine) as session: with Session(self.engine) as session:
return session.execute( return session.execute(
select(SignalDevice).where(SignalDevice.phone_number == phone_number) select(SignalDevice).where(SignalDevice.phone_number == phone_number)
).scalar_one_or_none() ).scalar_one_or_none()
def _update_cache(self, phone_number: str, device: SignalDevice) -> None:
"""Refresh the cache entry for a device."""
ttl = _BLOCKED_TTL if device.trust_level == TrustLevel.BLOCKED else _DEFAULT_TTL
self._contact_cache[phone_number] = _CacheEntry(
expires=utcnow() + ttl,
trust_level=device.trust_level,
has_safety_number=device.safety_number is not None,
safety_number=device.safety_number,
roles=_extract_roles(device),
)
def _set_trust(self, phone_number: str, level: str, log_msg: str | None = None) -> bool: def _set_trust(self, phone_number: str, level: str, log_msg: str | None = None) -> bool:
"""Update the trust level for a device.""" """Update the trust level for a device."""
with Session(self.engine) as session: with Session(self.engine) as session:
@@ -173,13 +253,34 @@ class DeviceRegistry:
device.trust_level = level device.trust_level = level
session.commit() session.commit()
ttl = _BLOCKED_TTL if level == TrustLevel.BLOCKED else _DEFAULT_TTL self._update_cache(phone_number, device)
self._contact_cache[phone_number] = _CacheEntry(
expires=utcnow() + ttl,
trust_level=level,
has_safety_number=device.safety_number is not None,
safety_number=device.safety_number,
)
if log_msg: if log_msg:
logger.info(f"{log_msg}: {phone_number}") logger.info(f"{log_msg}: {phone_number}")
return True return True
def _extract_roles(device: SignalDevice) -> list[Role]:
"""Convert a device's RoleRecord objects to a list of Role enums."""
return [Role(record.name) for record in device.roles]
def sync_roles(engine: Engine) -> None:
"""Sync the Role enum to the role table, adding new and removing stale entries."""
expected = {role.value for role in Role}
with Session(engine) as session:
existing = {record.name for record in session.execute(select(RoleRecord)).scalars().all()}
to_add = expected - existing
to_remove = existing - expected
for name in to_add:
session.add(RoleRecord(name=name))
logger.info(f"Role added: {name}")
if to_remove:
session.execute(delete(RoleRecord).where(RoleRecord.name.in_(to_remove)))
for name in to_remove:
logger.info(f"Role removed: {name}")
session.commit()

View File

@@ -21,10 +21,18 @@ class LLMClient:
temperature: Sampling temperature. temperature: Sampling temperature.
""" """
def __init__(self, model: str, host: str, port: int = 11434, *, temperature: float = 0.1) -> None: def __init__(
self,
*,
model: str,
host: str,
port: int = 11434,
temperature: float = 0.1,
timeout: int = 300,
) -> None:
self.model = model self.model = model
self.temperature = temperature self.temperature = temperature
self._client = httpx.Client(base_url=f"http://{host}:{port}", timeout=120) self._client = httpx.Client(base_url=f"http://{host}:{port}", timeout=timeout)
def chat(self, prompt: str, image_data: bytes | None = None, system: str | None = None) -> str: def chat(self, prompt: str, image_data: bytes | None = None, system: str | None = None) -> str:
"""Send a text prompt and return the response.""" """Send a text prompt and return the response."""

View File

@@ -3,103 +3,117 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from dataclasses import dataclass
from os import getenv from os import getenv
from typing import Annotated from typing import TYPE_CHECKING, Annotated
if TYPE_CHECKING:
from collections.abc import Callable
import typer import typer
from alembic.command import upgrade
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from tenacity import before_sleep_log, retry, stop_after_attempt, wait_exponential from tenacity import before_sleep_log, retry, stop_after_attempt, wait_exponential
from python.common import configure_logger, utcnow from python.common import configure_logger, utcnow
from python.database_cli import DATABASES
from python.orm.common import get_postgres_engine from python.orm.common import get_postgres_engine
from python.orm.richie.dead_letter_message import DeadLetterMessage from python.orm.signal_bot.models import DeadLetterMessage
from python.signal_bot.commands.inventory import handle_inventory_update from python.signal_bot.commands.inventory import handle_inventory_update
from python.signal_bot.device_registry import DeviceRegistry from python.signal_bot.commands.location import handle_location_request
from python.signal_bot.device_registry import DeviceRegistry, sync_roles
from python.signal_bot.llm_client import LLMClient from python.signal_bot.llm_client import LLMClient
from python.signal_bot.models import BotConfig, MessageStatus, SignalMessage from python.signal_bot.models import BotConfig, MessageStatus, Role, SignalMessage
from python.signal_bot.signal_client import SignalClient from python.signal_bot.signal_client import SignalClient
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
HELP_TEXT = ( @dataclass(frozen=True, slots=True)
"Available commands:\n" class Command:
" inventory <text list> — update van inventory from a text list\n" """A registered bot command."""
" inventory (+ photo) — update van inventory from a receipt photo\n"
" status — show bot status\n" action: Callable[[SignalMessage, str], None]
" help — show this help message\n" help_text: str
"Send a receipt photo with the message 'inventory' to scan it.\n" role: Role | None # None = no role required (always allowed)
)
def help_action( class Bot:
"""Holds shared resources and dispatches incoming messages to command handlers."""
def __init__(
self,
signal: SignalClient, signal: SignalClient,
message: SignalMessage,
_llm: LLMClient,
_registry: DeviceRegistry,
_config: BotConfig,
_cmd: str,
) -> None:
"""Return the help text for the bot."""
signal.reply(message, HELP_TEXT)
def status_action(
signal: SignalClient,
message: SignalMessage,
llm: LLMClient, llm: LLMClient,
registry: DeviceRegistry, registry: DeviceRegistry,
_config: BotConfig, config: BotConfig,
_cmd: str, ) -> None:
) -> None: self.signal = signal
self.llm = llm
self.registry = registry
self.config = config
self.commands: dict[str, Command] = {
"help": Command(action=self._help, help_text="show this help message", role=None),
"status": Command(action=self._status, help_text="show bot status", role=Role.STATUS),
"inventory": Command(
action=self._inventory,
help_text="update van inventory from a text list or receipt photo",
role=Role.INVENTORY,
),
"location": Command(
action=self._location,
help_text="get current van location",
role=Role.LOCATION,
),
}
# -- actions --------------------------------------------------------------
def _help(self, message: SignalMessage, _cmd: str) -> None:
"""Return help text filtered to the sender's roles."""
self.signal.reply(message, self._build_help(self.registry.get_roles(message.source)))
def _status(self, message: SignalMessage, _cmd: str) -> None:
"""Return the status of the bot.""" """Return the status of the bot."""
models = llm.list_models() models = self.llm.list_models()
model_list = ", ".join(models[:10]) model_list = ", ".join(models[:10])
device_count = len(registry.list_devices()) device_count = len(self.registry.list_devices())
signal.reply( self.signal.reply(
message, message,
f"Bot online.\nLLM: {llm.model}\nAvailable models: {model_list}\nKnown devices: {device_count}", f"Bot online.\nLLM: {self.llm.model}\nAvailable models: {model_list}\nKnown devices: {device_count}",
) )
def _inventory(self, message: SignalMessage, _cmd: str) -> None:
def unknown_action(
signal: SignalClient,
message: SignalMessage,
_llm: LLMClient,
_registry: DeviceRegistry,
_config: BotConfig,
cmd: str,
) -> None:
"""Return an error message for an unknown command."""
signal.reply(message, f"Unknown command: {cmd}\n\n{HELP_TEXT}")
def inventory_action(
signal: SignalClient,
message: SignalMessage,
llm: LLMClient,
_registry: DeviceRegistry,
config: BotConfig,
_cmd: str,
) -> None:
"""Process an inventory update.""" """Process an inventory update."""
handle_inventory_update(message, signal, llm, config.inventory_api_url) handle_inventory_update(message, self.signal, self.llm, self.config.inventory_api_url)
def _location(self, message: SignalMessage, _cmd: str) -> None:
"""Reply with current van location."""
handle_location_request(message, self.signal, self.config.ha_url, self.config.ha_token)
def dispatch( # -- dispatch -------------------------------------------------------------
message: SignalMessage,
signal: SignalClient, def _build_help(self, roles: list[Role]) -> str:
llm: LLMClient, """Build help text showing only the commands the user can access."""
registry: DeviceRegistry, is_admin = Role.ADMIN in roles
config: BotConfig, lines = ["Available commands:"]
) -> None: for name, cmd in self.commands.items():
if cmd.role is None or is_admin or cmd.role in roles:
lines.append(f" {name:20s}{cmd.help_text}")
return "\n".join(lines)
def dispatch(self, message: SignalMessage) -> None:
"""Route an incoming message to the right command handler.""" """Route an incoming message to the right command handler."""
source = message.source source = message.source
if not registry.is_verified(source) or not registry.has_safety_number(source): if not self.registry.is_verified(source):
logger.info(f"Device {source} not verified, ignoring message") logger.info(f"Device {source} not verified, ignoring message")
return return
if not self.registry.has_safety_number(source) and self.registry.has_role(source, Role.ADMIN):
logger.warning(f"Admin device {source} missing safety number, ignoring message")
return
text = message.message.strip() text = message.message.strip()
parts = text.split() parts = text.split()
@@ -108,44 +122,38 @@ def dispatch(
cmd = parts[0].lower() if parts else "" cmd = parts[0].lower() if parts else ""
commands = {
"help": help_action,
"status": status_action,
"inventory": inventory_action,
}
logger.info(f"f{source=} running {cmd=} with {message=}") logger.info(f"f{source=} running {cmd=} with {message=}")
action = commands.get(cmd)
if action is None: command = self.commands.get(cmd)
if command is None:
if message.attachments: if message.attachments:
action = inventory_action command = self.commands["inventory"]
cmd = "inventory" cmd = "inventory"
else: else:
return return
action(signal, message, llm, registry, config, cmd) if command.role is not None and not self.registry.has_role(source, command.role):
logger.warning(f"Device {source} denied access to {cmd!r}")
self.signal.reply(message, f"Permission denied: you do not have the '{command.role}' role.")
return
command.action(message, cmd)
def _process_message( def process_message(self, message: SignalMessage) -> None:
message: SignalMessage,
signal: SignalClient,
llm: LLMClient,
registry: DeviceRegistry,
config: BotConfig,
) -> None:
"""Process a single message, sending it to the dead letter queue after repeated failures.""" """Process a single message, sending it to the dead letter queue after repeated failures."""
max_attempts = config.max_message_attempts max_attempts = self.config.max_message_attempts
for attempt in range(1, max_attempts + 1): for attempt in range(1, max_attempts + 1):
try: try:
safety_number = signal.get_safety_number(message.source) safety_number = self.signal.get_safety_number(message.source)
registry.record_contact(message.source, safety_number) self.registry.record_contact(message.source, safety_number)
dispatch(message, signal, llm, registry, config) self.dispatch(message)
except Exception: except Exception:
logger.exception(f"Failed to process message (attempt {attempt}/{max_attempts})") logger.exception(f"Failed to process message (attempt {attempt}/{max_attempts})")
else: else:
return return
logger.error(f"Message from {message.source} failed {max_attempts} times, sending to dead letter queue") logger.error(f"Message from {message.source} failed {max_attempts} times, sending to dead letter queue")
with Session(config.engine) as session: with Session(self.config.engine) as session:
session.add( session.add(
DeadLetterMessage( DeadLetterMessage(
source=message.source, source=message.source,
@@ -156,26 +164,20 @@ def _process_message(
) )
session.commit() session.commit()
def run(self) -> None:
def run_loop(
config: BotConfig,
signal: SignalClient,
llm: LLMClient,
registry: DeviceRegistry,
) -> None:
"""Listen for messages via WebSocket, reconnecting on failure.""" """Listen for messages via WebSocket, reconnecting on failure."""
logger.info("Bot started — listening via WebSocket") logger.info("Bot started — listening via WebSocket")
@retry( @retry(
stop=stop_after_attempt(config.max_retries), stop=stop_after_attempt(self.config.max_retries),
wait=wait_exponential(multiplier=config.reconnect_delay, max=config.max_reconnect_delay), wait=wait_exponential(multiplier=self.config.reconnect_delay, max=self.config.max_reconnect_delay),
before_sleep=before_sleep_log(logger, logging.WARNING), before_sleep=before_sleep_log(logger, logging.WARNING),
reraise=True, reraise=True,
) )
def _listen() -> None: def _listen() -> None:
for message in signal.listen(): for message in self.signal.listen():
logger.info(f"Message from {message.source}: {message.message[:80]}") logger.info(f"Message from {message.source}: {message.message[:80]}")
_process_message(message, signal, llm, registry, config) self.process_message(message)
try: try:
_listen() _listen()
@@ -185,7 +187,7 @@ def run_loop(
def main( def main(
log_level: Annotated[str, typer.Option()] = "INFO", log_level: Annotated[str, typer.Option()] = "DEBUG",
llm_timeout: Annotated[int, typer.Option()] = 600, llm_timeout: Annotated[int, typer.Option()] = 600,
) -> None: ) -> None:
"""Run the Signal command and control bot.""" """Run the Signal command and control bot."""
@@ -204,11 +206,16 @@ def main(
error = "INVENTORY_API_URL environment variable not set" error = "INVENTORY_API_URL environment variable not set"
raise ValueError(error) raise ValueError(error)
signal_bot_config = DATABASES["signal_bot"].alembic_config()
upgrade(signal_bot_config, "head")
engine = get_postgres_engine(name="SIGNALBOT") engine = get_postgres_engine(name="SIGNALBOT")
sync_roles(engine)
config = BotConfig( config = BotConfig(
signal_api_url=signal_api_url, signal_api_url=signal_api_url,
phone_number=phone_number, phone_number=phone_number,
inventory_api_url=inventory_api_url, inventory_api_url=inventory_api_url,
ha_url=getenv("HA_URL"),
ha_token=getenv("HA_TOKEN"),
engine=engine, engine=engine,
) )
@@ -224,7 +231,8 @@ def main(
LLMClient(model=llm_model, host=llm_host, port=llm_port, timeout=llm_timeout) as llm, LLMClient(model=llm_model, host=llm_host, port=llm_port, timeout=llm_timeout) as llm,
): ):
registry = DeviceRegistry(signal, engine) registry = DeviceRegistry(signal, engine)
run_loop(config, signal, llm, registry) bot = Bot(signal, llm, registry, config)
bot.run()
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -18,6 +18,15 @@ class TrustLevel(StrEnum):
BLOCKED = "blocked" BLOCKED = "blocked"
class Role(StrEnum):
"""RBAC roles — one per command, plus admin which grants all."""
ADMIN = "admin"
STATUS = "status"
INVENTORY = "inventory"
LOCATION = "location"
class MessageStatus(StrEnum): class MessageStatus(StrEnum):
"""Dead letter queue message status.""" """Dead letter queue message status."""
@@ -79,6 +88,8 @@ class BotConfig(BaseModel):
signal_api_url: str signal_api_url: str
phone_number: str phone_number: str
inventory_api_url: str inventory_api_url: str
ha_url: str | None = None
ha_token: str | None = None
engine: Engine engine: Engine
reconnect_delay: int = 5 reconnect_delay: int = 5
max_reconnect_delay: int = 300 max_reconnect_delay: int = 300

View File

@@ -4,7 +4,7 @@ import logging
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Annotated, Any from typing import Annotated, Any
import requests import httpx
import typer import typer
from apscheduler.schedulers.blocking import BlockingScheduler from apscheduler.schedulers.blocking import BlockingScheduler
from tenacity import before_sleep_log, retry, stop_after_attempt, wait_fixed from tenacity import before_sleep_log, retry, stop_after_attempt, wait_fixed
@@ -37,7 +37,7 @@ logger = logging.getLogger(__name__)
) )
def get_ha_state(url: str, token: str, entity_id: str) -> float: def get_ha_state(url: str, token: str, entity_id: str) -> float:
"""Get numeric state from Home Asasistant entity.""" """Get numeric state from Home Asasistant entity."""
response = requests.get( response = httpx.get(
f"{url}/api/states/{entity_id}", f"{url}/api/states/{entity_id}",
headers={"Authorization": f"Bearer {token}"}, headers={"Authorization": f"Bearer {token}"},
timeout=30, timeout=30,
@@ -102,7 +102,7 @@ def parse_hourly_forecast(data: dict[str, dict[str, Any]]) -> list[HourlyForecas
def fetch_weather(api_key: str, lat: float, lon: float) -> Weather: def fetch_weather(api_key: str, lat: float, lon: float) -> Weather:
"""Fetch weather from Pirate Weather API.""" """Fetch weather from Pirate Weather API."""
url = f"https://api.pirateweather.net/forecast/{api_key}/{lat},{lon}" url = f"https://api.pirateweather.net/forecast/{api_key}/{lat},{lon}"
response = requests.get(url, params={"units": "us"}, timeout=30) response = httpx.get(url, params={"units": "us"}, timeout=30)
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
@@ -204,7 +204,7 @@ def post_to_ha(url: str, token: str, weather: Weather) -> None:
for entity_id, data in sensors.items(): for entity_id, data in sensors.items():
if data["state"] is not None: if data["state"] is not None:
response = requests.post(f"{url}/api/states/{entity_id}", headers=headers, json=data, timeout=30) response = httpx.post(f"{url}/api/states/{entity_id}", headers=headers, json=data, timeout=30)
response.raise_for_status() response.raise_for_status()
# Post daily forecast as JSON attribute sensor # Post daily forecast as JSON attribute sensor
@@ -219,7 +219,7 @@ def post_to_ha(url: str, token: str, weather: Weather) -> None:
for daily_forecast in weather.daily_forecasts for daily_forecast in weather.daily_forecasts
] ]
response = requests.post( response = httpx.post(
f"{url}/api/states/sensor.van_weather_forecast_daily", f"{url}/api/states/sensor.van_weather_forecast_daily",
headers=headers, headers=headers,
json={"state": len(daily_forecast), "attributes": {"forecast": daily_forecast}}, json={"state": len(daily_forecast), "attributes": {"forecast": daily_forecast}},
@@ -238,7 +238,7 @@ def post_to_ha(url: str, token: str, weather: Weather) -> None:
for hourly_forecast in weather.hourly_forecasts for hourly_forecast in weather.hourly_forecasts
] ]
response = requests.post( response = httpx.post(
f"{url}/api/states/sensor.van_weather_forecast_hourly", f"{url}/api/states/sensor.van_weather_forecast_hourly",
headers=headers, headers=headers,
json={"state": len(hourly_forecast), "attributes": {"forecast": hourly_forecast}}, json={"state": len(hourly_forecast), "attributes": {"forecast": hourly_forecast}},

View File

@@ -14,8 +14,6 @@
ssh-to-age ssh-to-age
gnupg gnupg
age age
audiveris
]; ];
}; };
} }

View File

@@ -12,6 +12,7 @@
"${inputs.self}/common/optional/update.nix" "${inputs.self}/common/optional/update.nix"
"${inputs.self}/common/optional/yubikey.nix" "${inputs.self}/common/optional/yubikey.nix"
"${inputs.self}/common/optional/zerotier.nix" "${inputs.self}/common/optional/zerotier.nix"
"${inputs.self}/common/optional/brain_substituter.nix"
"${inputs.self}/common/optional/nvidia.nix" "${inputs.self}/common/optional/nvidia.nix"
./hardware.nix ./hardware.nix
./syncthing.nix ./syncthing.nix

View File

@@ -4,7 +4,7 @@
host = "0.0.0.0"; host = "0.0.0.0";
enable = true; enable = true;
syncModels = false; syncModels = true;
loadModels = [ loadModels = [
"codellama:7b" "codellama:7b"
"deepscaler:1.5b" "deepscaler:1.5b"
@@ -12,24 +12,33 @@
"deepseek-r1:32b" "deepseek-r1:32b"
"deepseek-r1:8b" "deepseek-r1:8b"
"devstral-small-2:24b" "devstral-small-2:24b"
"dolphin-llama3:70b"
"dolphin-llama3:8b" "dolphin-llama3:8b"
"functiongemma:270m" "functiongemma:270m"
"gemma3:12b" "gemma3:12b"
"gemma3:27b" "gemma3:27b"
"glm-4.7-flash:q4_K_M"
"gpt-oss:20b" "gpt-oss:20b"
"huihui_ai/dolphin3-abliterated:8b" "huihui_ai/dolphin3-abliterated:8b"
"lfm2:24b" "lfm2:24b"
"llama3.1:8b"
"llama3.2:1b"
"llama3.2:3b"
"magistral:24b" "magistral:24b"
"ministral-3:14b" "ministral-3:14b"
"nemotron-3-nano:30b" "nemotron-3-nano:30b"
"glm-4.7-flash:q4_K_M" "nemotron-cascade-2:30b"
"qwen3-coder:30b" "qwen3-coder:30b"
"qwen3-embedding:0.6b"
"qwen3-embedding:4b"
"qwen3-embedding:8b"
"qwen3-vl:2b"
"qwen3-vl:32b" "qwen3-vl:32b"
"qwen3-vl:4b"
"qwen3-vl:8b"
"qwen3:0.6b"
"qwen3:1.7b"
"qwen3:14b" "qwen3:14b"
"qwen3:30b"
"qwen3:32b"
"qwen3:4b"
"qwen3:8b"
"qwen3.5:27b" "qwen3.5:27b"
"qwen3.5:35b" "qwen3.5:35b"
"translategemma:12b" "translategemma:12b"

View File

@@ -24,6 +24,7 @@
gps_location = "!include ${./home_assistant/gps_location.yaml}"; gps_location = "!include ${./home_assistant/gps_location.yaml}";
heater = "!include ${./home_assistant/heater.yaml}"; heater = "!include ${./home_assistant/heater.yaml}";
van_weather = "!include ${./home_assistant/van_weather_template.yaml}"; van_weather = "!include ${./home_assistant/van_weather_template.yaml}";
status_indicator = "!include ${./home_assistant/status_indicator.yaml}";
}; };
}; };
recorder = { recorder = {

View File

@@ -60,25 +60,33 @@ template:
- name: Van Last Known Latitude - name: Van Last Known Latitude
unique_id: van_last_known_latitude unique_id: van_last_known_latitude
unit_of_measurement: "°" unit_of_measurement: "°"
availability: >-
{{ this.state | float(none) is not none
or (states('sensor.gps_latitude') | float(none) is not none
and states('sensor.gps_fix') | int(0) > 0) }}
state: >- state: >-
{% set lat = states('sensor.gps_latitude')|float(none) %} {% set lat = states('sensor.gps_latitude') | float(none) %}
{% set fix = states('sensor.gps_fix')|int(0) %} {% set fix = states('sensor.gps_fix') | int(0) %}
{% if lat is not none and fix > 0 %} {% if lat is not none and fix > 0 %}
{{ lat }} {{ lat }}
{% else %} {% else %}
{{ this.state | default('unavailable', true) }} {{ this.state }}
{% endif %} {% endif %}
- name: Van Last Known Longitude - name: Van Last Known Longitude
unique_id: van_last_known_longitude unique_id: van_last_known_longitude
unit_of_measurement: "°" unit_of_measurement: "°"
availability: >-
{{ this.state | float(none) is not none
or (states('sensor.gps_longitude') | float(none) is not none
and states('sensor.gps_fix') | int(0) > 0) }}
state: >- state: >-
{% set lon = states('sensor.gps_longitude')|float(none) %} {% set lon = states('sensor.gps_longitude') | float(none) %}
{% set fix = states('sensor.gps_fix')|int(0) %} {% set fix = states('sensor.gps_fix') | int(0) %}
{% if lon is not none and fix > 0 %} {% if lon is not none and fix > 0 %}
{{ lon }} {{ lon }}
{% else %} {% else %}
{{ this.state | default('unavailable', true) }} {{ this.state }}
{% endif %} {% endif %}
- name: GPS Location - name: GPS Location

View File

@@ -0,0 +1,129 @@
input_select:
richie_status:
name: "Richie Status"
options:
- Available
- Busy
- Do Not Disturb
icon: mdi:account
initial: Available
maple_status:
name: "Maple Status"
options:
- Available
- Busy
- Do Not Disturb
icon: mdi:account
initial: Available
template:
- sensor:
- name: "Richie Status Icon"
state: >
{{ states('input_select.richie_status') }}
icon: >
{% set status = states('input_select.richie_status') %}
{% if status == 'Available' %}mdi:circle
{% elif status == 'Busy' %}mdi:circle-half-full
{% else %}mdi:minus-circle{% endif %}
- name: "Maple Status Icon"
state: >
{{ states('input_select.maple_status') }}
icon: >
{% set status = states('input_select.maple_status') %}
{% if status == 'Available' %}mdi:circle
{% elif status == 'Busy' %}mdi:circle-half-full
{% else %}mdi:minus-circle{% endif %}
script:
# Richie
set_richie_available:
alias: "Richie → Available"
icon: mdi:circle
sequence:
- service: input_select.select_option
target:
entity_id: input_select.richie_status
data:
option: "Available"
set_richie_busy:
alias: "Richie → Busy"
icon: mdi:circle-half-full
sequence:
- service: input_select.select_option
target:
entity_id: input_select.richie_status
data:
option: "Busy"
set_richie_dnd:
alias: "Richie → Do Not Disturb"
icon: mdi:minus-circle
sequence:
- service: input_select.select_option
target:
entity_id: input_select.richie_status
data:
option: "Do Not Disturb"
cycle_richie_status:
alias: "Cycle Richie Status"
icon: mdi:account-switch
sequence:
- service: input_select.select_option
target:
entity_id: input_select.richie_status
data:
option: >
{% set current = states('input_select.richie_status') %}
{% if current == 'Available' %}Busy
{% elif current == 'Busy' %}Do Not Disturb
{% else %}Available{% endif %}
# Maple
set_maple_available:
alias: "Maple → Available"
icon: mdi:circle
sequence:
- service: input_select.select_option
target:
entity_id: input_select.maple_status
data:
option: "Available"
set_maple_busy:
alias: "Maple → Busy"
icon: mdi:circle-half-full
sequence:
- service: input_select.select_option
target:
entity_id: input_select.maple_status
data:
option: "Busy"
set_maple_dnd:
alias: "Maple → Do Not Disturb"
icon: mdi:minus-circle
sequence:
- service: input_select.select_option
target:
entity_id: input_select.maple_status
data:
option: "Do Not Disturb"
cycle_maple_status:
alias: "Cycle Maple Status"
icon: mdi:account-switch
sequence:
- service: input_select.select_option
target:
entity_id: input_select.maple_status
data:
option: >
{% set current = states('input_select.maple_status') %}
{% if current == 'Available' %}Busy
{% elif current == 'Busy' %}Do Not Disturb
{% else %}Available{% endif %}

View File

@@ -76,8 +76,80 @@ modbus:
state_class: measurement state_class: measurement
unique_id: dc_wattage unique_id: dc_wattage
# GPS # GPS (Slave ID 100)
- name: GPS Latitude - name: GPS Latitude ID 100
slave: 100
address: 2800
input_type: holding
data_type: int32
scale: 0.0000001
precision: 7
state_class: measurement
unique_id: gps_latitude_id_100
- name: GPS Longitude ID 100
slave: 100
address: 2802
input_type: holding
data_type: int32
scale: 0.0000001
precision: 7
state_class: measurement
unique_id: gps_longitude_id_100
- name: GPS Course ID 100
slave: 100
address: 2804
input_type: holding
data_type: uint16
scale: 0.01
precision: 2
unit_of_measurement: "°"
state_class: measurement
unique_id: gps_course_id_100
- name: GPS Speed ID 100
slave: 100
address: 2805
input_type: holding
data_type: uint16
scale: 0.01
precision: 2
unit_of_measurement: "m/s"
state_class: measurement
unique_id: gps_speed_id_100
- name: GPS Fix ID 100
slave: 100
address: 2806
input_type: holding
data_type: uint16
scale: 1
state_class: measurement
unique_id: gps_fix_id_100
- name: GPS Satellites ID 100
slave: 100
address: 2807
input_type: holding
data_type: uint16
scale: 1
state_class: measurement
unique_id: gps_satellites_id_100
- name: GPS Altitude ID 100
slave: 100
address: 2808
input_type: holding
data_type: int32
scale: 0.16
precision: 1
unit_of_measurement: "m"
state_class: measurement
unique_id: gps_altitude_id_100
# GPS (Unit ID 1)
- name: GPS Latitude ID 1
slave: 1 slave: 1
address: 2800 address: 2800
input_type: holding input_type: holding
@@ -85,9 +157,9 @@ modbus:
scale: 0.0000001 scale: 0.0000001
precision: 7 precision: 7
state_class: measurement state_class: measurement
unique_id: gps_latitude unique_id: gps_latitude_id_1
- name: GPS Longitude - name: GPS Longitude ID 1
slave: 1 slave: 1
address: 2802 address: 2802
input_type: holding input_type: holding
@@ -95,9 +167,9 @@ modbus:
scale: 0.0000001 scale: 0.0000001
precision: 7 precision: 7
state_class: measurement state_class: measurement
unique_id: gps_longitude unique_id: gps_longitude_id_1
- name: GPS Course - name: GPS Course ID 1
slave: 1 slave: 1
address: 2804 address: 2804
input_type: holding input_type: holding
@@ -106,9 +178,9 @@ modbus:
precision: 2 precision: 2
unit_of_measurement: "°" unit_of_measurement: "°"
state_class: measurement state_class: measurement
unique_id: gps_course unique_id: gps_course_id_1
- name: GPS Speed - name: GPS Speed ID 1
slave: 1 slave: 1
address: 2805 address: 2805
input_type: holding input_type: holding
@@ -117,27 +189,27 @@ modbus:
precision: 2 precision: 2
unit_of_measurement: "m/s" unit_of_measurement: "m/s"
state_class: measurement state_class: measurement
unique_id: gps_speed unique_id: gps_speed_id_1
- name: GPS Fix - name: GPS Fix ID 1
slave: 1 slave: 1
address: 2806 address: 2806
input_type: holding input_type: holding
data_type: uint16 data_type: uint16
scale: 1 scale: 1
state_class: measurement state_class: measurement
unique_id: gps_fix unique_id: gps_fix_id_1
- name: GPS Satellites - name: GPS Satellites ID 1
slave: 1 slave: 1
address: 2807 address: 2807
input_type: holding input_type: holding
data_type: uint16 data_type: uint16
scale: 1 scale: 1
state_class: measurement state_class: measurement
unique_id: gps_satellites unique_id: gps_satellites_id_1
- name: GPS Altitude - name: GPS Altitude ID 1
slave: 1 slave: 1
address: 2808 address: 2808
input_type: holding input_type: holding
@@ -146,7 +218,7 @@ modbus:
precision: 1 precision: 1
unit_of_measurement: "m" unit_of_measurement: "m"
state_class: measurement state_class: measurement
unique_id: gps_altitude unique_id: gps_altitude_id_1
# ---- CHARGER (Unit ID 223) ---- # ---- CHARGER (Unit ID 223) ----
- name: Charger Output 1 Voltage - name: Charger Output 1 Voltage
@@ -265,6 +337,108 @@ modbus:
template: template:
- sensor: - sensor:
# GPS aggregation: prefer slave 100, fall back to slave 1
- name: GPS Latitude
unique_id: gps_latitude
state_class: measurement
state: >-
{% set v100 = states('sensor.gps_latitude_id_100') %}
{% set v1 = states('sensor.gps_latitude_id_1') %}
{% if v100 not in ['unavailable', 'unknown'] and v100 | float(0) != 0 %}
{{ v100 }}
{% elif v1 not in ['unavailable', 'unknown'] and v1 | float(0) != 0 %}
{{ v1 }}
{% else %}
{{ v100 }}
{% endif %}
- name: GPS Longitude
unique_id: gps_longitude
state_class: measurement
state: >-
{% set v100 = states('sensor.gps_longitude_id_100') %}
{% set v1 = states('sensor.gps_longitude_id_1') %}
{% if v100 not in ['unavailable', 'unknown'] and v100 | float(0) != 0 %}
{{ v100 }}
{% elif v1 not in ['unavailable', 'unknown'] and v1 | float(0) != 0 %}
{{ v1 }}
{% else %}
{{ v100 }}
{% endif %}
- name: GPS Course
unique_id: gps_course
unit_of_measurement: "°"
state_class: measurement
state: >-
{% set v100 = states('sensor.gps_course_id_100') %}
{% set v1 = states('sensor.gps_course_id_1') %}
{% if v100 not in ['unavailable', 'unknown'] %}
{{ v100 }}
{% elif v1 not in ['unavailable', 'unknown'] %}
{{ v1 }}
{% else %}
{{ v100 }}
{% endif %}
- name: GPS Speed
unique_id: gps_speed
unit_of_measurement: "m/s"
state_class: measurement
state: >-
{% set v100 = states('sensor.gps_speed_id_100') %}
{% set v1 = states('sensor.gps_speed_id_1') %}
{% if v100 not in ['unavailable', 'unknown'] %}
{{ v100 }}
{% elif v1 not in ['unavailable', 'unknown'] %}
{{ v1 }}
{% else %}
{{ v100 }}
{% endif %}
- name: GPS Fix
unique_id: gps_fix
state_class: measurement
state: >-
{% set v100 = states('sensor.gps_fix_id_100') %}
{% set v1 = states('sensor.gps_fix_id_1') %}
{% if v100 not in ['unavailable', 'unknown'] and v100 | int(0) > 0 %}
{{ v100 }}
{% elif v1 not in ['unavailable', 'unknown'] and v1 | int(0) > 0 %}
{{ v1 }}
{% else %}
{{ v100 }}
{% endif %}
- name: GPS Satellites
unique_id: gps_satellites
state_class: measurement
state: >-
{% set v100 = states('sensor.gps_satellites_id_100') %}
{% set v1 = states('sensor.gps_satellites_id_1') %}
{% if v100 not in ['unavailable', 'unknown'] and v100 | int(0) > 0 %}
{{ v100 }}
{% elif v1 not in ['unavailable', 'unknown'] and v1 | int(0) > 0 %}
{{ v1 }}
{% else %}
{{ v100 }}
{% endif %}
- name: GPS Altitude
unique_id: gps_altitude
unit_of_measurement: "m"
state_class: measurement
state: >-
{% set v100 = states('sensor.gps_altitude_id_100') %}
{% set v1 = states('sensor.gps_altitude_id_1') %}
{% if v100 not in ['unavailable', 'unknown'] %}
{{ v100 }}
{% elif v1 not in ['unavailable', 'unknown'] %}
{{ v1 }}
{% else %}
{{ v100 }}
{% endif %}
- name: Charger On Off - name: Charger On Off
state: >- state: >-
{% set v = states('sensor.charger_on_off_raw')|int %} {% set v = states('sensor.charger_on_off_raw')|int %}

View File

@@ -8,18 +8,13 @@
8069 8069
]; ];
systemd.services.contact-api = { systemd.services.contact-api = {
description = "Contact Database API with Frontend"; description = "Contact Database API";
after = [ after = [
"postgresql.service" "postgresql.service"
"network.target" "network.target"
]; ];
requires = [ "postgresql.service" ]; requires = [ "postgresql.service" ];
wantedBy = [ "multi-user.target" ]; wantedBy = [ "multi-user.target" ];
path = [
pkgs.nodejs
pkgs.coreutils
pkgs.bash
];
environment = { environment = {
PYTHONPATH = "${inputs.self}"; PYTHONPATH = "${inputs.self}";
@@ -27,18 +22,15 @@
POSTGRES_HOST = "/run/postgresql"; POSTGRES_HOST = "/run/postgresql";
POSTGRES_USER = "richie"; POSTGRES_USER = "richie";
POSTGRES_PORT = "5432"; POSTGRES_PORT = "5432";
HOME = "/var/lib/contact-api";
}; };
serviceConfig = { serviceConfig = {
Type = "simple"; Type = "simple";
ExecStart = "${pkgs.my_python}/bin/python -m python.api.main --host 192.168.90.40 --port 8069 --frontend-dir ${inputs.self}/frontend"; ExecStart = "${pkgs.my_python}/bin/python -m python.api.main --host 192.168.90.40 --port 8069";
StateDirectory = "contact-api";
Restart = "on-failure"; Restart = "on-failure";
RestartSec = "5s"; RestartSec = "5s";
StandardOutput = "journal"; StandardOutput = "journal";
StandardError = "journal"; StandardError = "journal";
# Security hardening
NoNewPrivileges = true; NoNewPrivileges = true;
ProtectSystem = "strict"; ProtectSystem = "strict";
ProtectHome = "read-only"; ProtectHome = "read-only";

View File

@@ -3,10 +3,17 @@ let
in in
{ {
services.apache-kafka = { services.apache-kafka = {
enable = false; enable = true;
settings = { settings = {
listeners = [ "PLAINTEXT://localhost:9092" ]; listeners = [ "PLAINTEXT://localhost:9092" ];
"log.dirs" = [ vars.kafka ]; "log.dirs" = [ vars.kafka ];
"num.partitions" = 6;
"default.replication.factor" = 1;
"log.retention.hours" = 168;
"log.retention.bytes" = 10737418240;
"log.segment.bytes" = 1073741824;
"log.cleanup.policy" = "delete";
"auto.create.topics.enable" = false;
}; };
}; };
} }

View File

@@ -31,7 +31,7 @@ in
local gitea gitea trust local gitea gitea trust
# signalbot # signalbot
local richie signalbot trust local signalbot signalbot trust
# math # math
local postgres math trust local postgres math trust
@@ -103,6 +103,7 @@ in
} }
{ {
name = "signalbot"; name = "signalbot";
ensureDBOwnership = true;
ensureClauses = { ensureClauses = {
login = true; login = true;
}; };
@@ -114,6 +115,7 @@ in
"math" "math"
"n8n" "n8n"
"richie" "richie"
"signalbot"
]; ];
# Thank you NotAShelf # Thank you NotAShelf
# https://github.com/NotAShelf/nyx/blob/d407b4d6e5ab7f60350af61a3d73a62a5e9ac660/modules/core/roles/server/system/services/databases/postgresql.nix#L74 # https://github.com/NotAShelf/nyx/blob/d407b4d6e5ab7f60350af61a3d73a62a5e9ac660/modules/core/roles/server/system/services/databases/postgresql.nix#L74

View File

@@ -26,7 +26,7 @@ in
environment = { environment = {
PYTHONPATH = "${inputs.self}"; PYTHONPATH = "${inputs.self}";
SIGNALBOT_DB = "richie"; SIGNALBOT_DB = "signalbot";
SIGNALBOT_USER = "signalbot"; SIGNALBOT_USER = "signalbot";
SIGNALBOT_HOST = "/run/postgresql"; SIGNALBOT_HOST = "/run/postgresql";
SIGNALBOT_PORT = "5432"; SIGNALBOT_PORT = "5432";
@@ -34,6 +34,7 @@ in
serviceConfig = { serviceConfig = {
Type = "simple"; Type = "simple";
WorkingDirectory = "${inputs.self}";
User = "signalbot"; User = "signalbot";
Group = "signalbot"; Group = "signalbot";
EnvironmentFile = "${vars.secrets}/services/signal-bot"; EnvironmentFile = "${vars.secrets}/services/signal-bot";

View File

@@ -5,7 +5,7 @@ in
{ {
services.transmission = { services.transmission = {
enable = true; enable = true;
package = pkgs.transmission_4; package = pkgs.stable.transmission_4;
webHome = pkgs.flood-for-transmission; webHome = pkgs.flood-for-transmission;
home = "${vars.services}/transmission"; home = "${vars.services}/transmission";
openPeerPorts = true; openPeerPorts = true;

View File

@@ -9,6 +9,7 @@
"${inputs.self}/common/optional/systemd-boot.nix" "${inputs.self}/common/optional/systemd-boot.nix"
"${inputs.self}/common/optional/update.nix" "${inputs.self}/common/optional/update.nix"
"${inputs.self}/common/optional/zerotier.nix" "${inputs.self}/common/optional/zerotier.nix"
"${inputs.self}/common/optional/brain_substituter.nix"
./hardware.nix ./hardware.nix
inputs.nixos-hardware.nixosModules.framework-13-7040-amd inputs.nixos-hardware.nixosModules.framework-13-7040-amd
]; ];

40
tests/conftest.py Normal file
View File

@@ -0,0 +1,40 @@
"""Shared test fixtures."""
from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
from sqlalchemy import create_engine, event
from python.orm.signal_bot.base import SignalBotBase
if TYPE_CHECKING:
from collections.abc import Generator
from sqlalchemy.engine import Engine
@pytest.fixture(scope="session")
def sqlite_engine() -> Generator[Engine]:
"""Create an in-memory SQLite engine for testing."""
engine = create_engine("sqlite:///:memory:")
@event.listens_for(engine, "connect")
def _set_sqlite_pragma(dbapi_connection, _connection_record):
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
SignalBotBase.metadata.create_all(engine)
yield engine
engine.dispose()
@pytest.fixture
def engine(sqlite_engine: Engine) -> Generator[Engine]:
"""Yield the shared engine after cleaning all tables between tests."""
yield sqlite_engine
with sqlite_engine.begin() as connection:
for table in reversed(SignalBotBase.metadata.sorted_tables):
connection.execute(table.delete())

View File

@@ -1,294 +0,0 @@
import zipfile
from unittest.mock import patch
import httpx
import pytest
from typer.testing import CliRunner
from python.sheet_music_ocr.audiveris import AudiverisError, find_audiveris, run_audiveris
from python.sheet_music_ocr.main import SUPPORTED_EXTENSIONS, app, extract_mxml_from_mxl
from python.sheet_music_ocr.review import LLMProvider, ReviewError, review_mxml
runner = CliRunner()
def make_mxl(path, xml_content=b"<score-partwise/>"):
"""Create a minimal .mxl (ZIP) file with a MusicXML inside."""
with zipfile.ZipFile(path, "w") as zf:
zf.writestr("score.xml", xml_content)
class TestExtractMxmlFromMxl:
def test_extracts_xml(self, tmp_path):
mxl = tmp_path / "test.mxl"
output = tmp_path / "output.mxml"
content = b"<score-partwise>hello</score-partwise>"
make_mxl(mxl, content)
result = extract_mxml_from_mxl(mxl, output)
assert result == output
assert output.read_bytes() == content
def test_skips_meta_inf(self, tmp_path):
mxl = tmp_path / "test.mxl"
output = tmp_path / "output.mxml"
with zipfile.ZipFile(mxl, "w") as zf:
zf.writestr("META-INF/container.xml", "<container/>")
zf.writestr("score.xml", b"<score/>")
extract_mxml_from_mxl(mxl, output)
assert output.read_bytes() == b"<score/>"
def test_raises_when_no_xml(self, tmp_path):
mxl = tmp_path / "test.mxl"
output = tmp_path / "output.mxml"
with zipfile.ZipFile(mxl, "w") as zf:
zf.writestr("readme.txt", "no xml here")
with pytest.raises(FileNotFoundError, match="No MusicXML"):
extract_mxml_from_mxl(mxl, output)
class TestFindAudiveris:
def test_raises_when_not_found(self):
with (
patch("python.sheet_music_ocr.audiveris.shutil.which", return_value=None),
pytest.raises(AudiverisError, match="not found"),
):
find_audiveris()
def test_returns_path_when_found(self):
with patch("python.sheet_music_ocr.audiveris.shutil.which", return_value="/usr/bin/audiveris"):
assert find_audiveris() == "/usr/bin/audiveris"
class TestRunAudiveris:
def test_raises_on_nonzero_exit(self, tmp_path):
with (
patch("python.sheet_music_ocr.audiveris.find_audiveris", return_value="audiveris"),
patch("python.sheet_music_ocr.audiveris.subprocess.run") as mock_run,
):
mock_run.return_value.returncode = 1
mock_run.return_value.stderr = "something went wrong"
with pytest.raises(AudiverisError, match="failed"):
run_audiveris(tmp_path / "input.pdf", tmp_path / "output")
def test_raises_when_no_mxl_produced(self, tmp_path):
output_dir = tmp_path / "output"
output_dir.mkdir()
with (
patch("python.sheet_music_ocr.audiveris.find_audiveris", return_value="audiveris"),
patch("python.sheet_music_ocr.audiveris.subprocess.run") as mock_run,
):
mock_run.return_value.returncode = 0
with pytest.raises(AudiverisError, match=r"no \.mxl output"):
run_audiveris(tmp_path / "input.pdf", output_dir)
def test_returns_mxl_path(self, tmp_path):
output_dir = tmp_path / "output"
output_dir.mkdir()
mxl = output_dir / "score.mxl"
make_mxl(mxl)
with (
patch("python.sheet_music_ocr.audiveris.find_audiveris", return_value="audiveris"),
patch("python.sheet_music_ocr.audiveris.subprocess.run") as mock_run,
):
mock_run.return_value.returncode = 0
result = run_audiveris(tmp_path / "input.pdf", output_dir)
assert result == mxl
class TestCli:
def test_missing_input_file(self, tmp_path):
result = runner.invoke(app, ["convert", str(tmp_path / "nonexistent.pdf")])
assert result.exit_code == 1
assert "does not exist" in result.output
def test_unsupported_format(self, tmp_path):
bad_file = tmp_path / "music.bmp"
bad_file.touch()
result = runner.invoke(app, ["convert", str(bad_file)])
assert result.exit_code == 1
assert "Unsupported format" in result.output
def test_supported_extensions_complete(self):
assert ".pdf" in SUPPORTED_EXTENSIONS
assert ".png" in SUPPORTED_EXTENSIONS
assert ".jpg" in SUPPORTED_EXTENSIONS
assert ".jpeg" in SUPPORTED_EXTENSIONS
assert ".tiff" in SUPPORTED_EXTENSIONS
def test_successful_conversion(self, tmp_path):
input_file = tmp_path / "score.pdf"
input_file.touch()
output_file = tmp_path / "score.mxml"
mxl_path = tmp_path / "tmp_mxl" / "score.mxl"
mxl_path.parent.mkdir()
make_mxl(mxl_path, b"<score-partwise/>")
with patch("python.sheet_music_ocr.main.run_audiveris", return_value=mxl_path):
result = runner.invoke(app, ["convert", str(input_file), "-o", str(output_file)])
assert result.exit_code == 0
assert output_file.exists()
assert "Written" in result.output
def test_default_output_path(self, tmp_path):
input_file = tmp_path / "score.png"
input_file.touch()
mxl_path = tmp_path / "tmp_mxl" / "score.mxl"
mxl_path.parent.mkdir()
make_mxl(mxl_path)
with patch("python.sheet_music_ocr.main.run_audiveris", return_value=mxl_path):
result = runner.invoke(app, ["convert", str(input_file)])
assert result.exit_code == 0
assert (tmp_path / "score.mxml").exists()
class TestReviewMxml:
def test_raises_when_no_api_key(self, tmp_path, monkeypatch):
mxml = tmp_path / "score.mxml"
mxml.write_text("<score-partwise/>")
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
with pytest.raises(ReviewError, match="ANTHROPIC_API_KEY"):
review_mxml(mxml, LLMProvider.CLAUDE)
def test_raises_when_no_openai_key(self, tmp_path, monkeypatch):
mxml = tmp_path / "score.mxml"
mxml.write_text("<score-partwise/>")
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
with pytest.raises(ReviewError, match="OPENAI_API_KEY"):
review_mxml(mxml, LLMProvider.OPENAI)
def test_claude_success(self, tmp_path, monkeypatch):
mxml = tmp_path / "score.mxml"
mxml.write_text("<score-partwise/>")
monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key")
corrected = "<score-partwise><part/></score-partwise>"
mock_response = httpx.Response(
200,
json={"content": [{"text": corrected}]},
request=httpx.Request("POST", "https://api.anthropic.com/v1/messages"),
)
with patch("python.sheet_music_ocr.review.httpx.post", return_value=mock_response):
result = review_mxml(mxml, LLMProvider.CLAUDE)
assert result == corrected
def test_openai_success(self, tmp_path, monkeypatch):
mxml = tmp_path / "score.mxml"
mxml.write_text("<score-partwise/>")
monkeypatch.setenv("OPENAI_API_KEY", "test-key")
corrected = "<score-partwise><part/></score-partwise>"
mock_response = httpx.Response(
200,
json={"choices": [{"message": {"content": corrected}}]},
request=httpx.Request("POST", "https://api.openai.com/v1/chat/completions"),
)
with patch("python.sheet_music_ocr.review.httpx.post", return_value=mock_response):
result = review_mxml(mxml, LLMProvider.OPENAI)
assert result == corrected
def test_claude_api_error(self, tmp_path, monkeypatch):
mxml = tmp_path / "score.mxml"
mxml.write_text("<score-partwise/>")
monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key")
mock_response = httpx.Response(
500,
text="Internal Server Error",
request=httpx.Request("POST", "https://api.anthropic.com/v1/messages"),
)
with (
patch("python.sheet_music_ocr.review.httpx.post", return_value=mock_response),
pytest.raises(ReviewError, match="Claude API error"),
):
review_mxml(mxml, LLMProvider.CLAUDE)
def test_openai_api_error(self, tmp_path, monkeypatch):
mxml = tmp_path / "score.mxml"
mxml.write_text("<score-partwise/>")
monkeypatch.setenv("OPENAI_API_KEY", "test-key")
mock_response = httpx.Response(
429,
text="Rate limited",
request=httpx.Request("POST", "https://api.openai.com/v1/chat/completions"),
)
with (
patch("python.sheet_music_ocr.review.httpx.post", return_value=mock_response),
pytest.raises(ReviewError, match="OpenAI API error"),
):
review_mxml(mxml, LLMProvider.OPENAI)
class TestReviewCli:
def test_missing_input_file(self, tmp_path):
result = runner.invoke(app, ["review", str(tmp_path / "nonexistent.mxml")])
assert result.exit_code == 1
assert "does not exist" in result.output
def test_wrong_extension(self, tmp_path):
bad_file = tmp_path / "score.pdf"
bad_file.touch()
result = runner.invoke(app, ["review", str(bad_file)])
assert result.exit_code == 1
assert ".mxml" in result.output
def test_successful_review(self, tmp_path, monkeypatch):
mxml = tmp_path / "score.mxml"
mxml.write_text("<score-partwise/>")
output = tmp_path / "corrected.mxml"
monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key")
corrected = "<score-partwise><part/></score-partwise>"
mock_response = httpx.Response(
200,
json={"content": [{"text": corrected}]},
request=httpx.Request("POST", "https://api.anthropic.com/v1/messages"),
)
with patch("python.sheet_music_ocr.review.httpx.post", return_value=mock_response):
result = runner.invoke(app, ["review", str(mxml), "-o", str(output)])
assert result.exit_code == 0
assert "Reviewed" in result.output
assert output.read_text() == corrected
def test_overwrites_input_by_default(self, tmp_path, monkeypatch):
mxml = tmp_path / "score.mxml"
mxml.write_text("<score-partwise/>")
monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key")
corrected = "<score-partwise><part/></score-partwise>"
mock_response = httpx.Response(
200,
json={"content": [{"text": corrected}]},
request=httpx.Request("POST", "https://api.anthropic.com/v1/messages"),
)
with patch("python.sheet_music_ocr.review.httpx.post", return_value=mock_response):
result = runner.invoke(app, ["review", str(mxml)])
assert result.exit_code == 0
assert mxml.read_text() == corrected

View File

@@ -7,16 +7,15 @@ from datetime import timedelta
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import pytest import pytest
from sqlalchemy import create_engine
from python.orm.richie.base import RichieBase
from python.signal_bot.commands.inventory import ( from python.signal_bot.commands.inventory import (
_format_summary, _format_summary,
parse_llm_response, parse_llm_response,
) )
from python.signal_bot.commands.location import _format_location, handle_location_request
from python.signal_bot.device_registry import _BLOCKED_TTL, _DEFAULT_TTL, DeviceRegistry, _CacheEntry from python.signal_bot.device_registry import _BLOCKED_TTL, _DEFAULT_TTL, DeviceRegistry, _CacheEntry
from python.signal_bot.llm_client import LLMClient from python.signal_bot.llm_client import LLMClient
from python.signal_bot.main import dispatch from python.signal_bot.main import Bot
from python.signal_bot.models import ( from python.signal_bot.models import (
BotConfig, BotConfig,
InventoryItem, InventoryItem,
@@ -77,12 +76,6 @@ class TestDeviceRegistry:
def signal_mock(self): def signal_mock(self):
return MagicMock(spec=SignalClient) return MagicMock(spec=SignalClient)
@pytest.fixture
def engine(self):
engine = create_engine("sqlite://")
RichieBase.metadata.create_all(engine)
return engine
@pytest.fixture @pytest.fixture
def registry(self, signal_mock, engine): def registry(self, signal_mock, engine):
return DeviceRegistry(signal_mock, engine) return DeviceRegistry(signal_mock, engine)
@@ -130,12 +123,6 @@ class TestContactCache:
def signal_mock(self): def signal_mock(self):
return MagicMock(spec=SignalClient) return MagicMock(spec=SignalClient)
@pytest.fixture
def engine(self):
engine = create_engine("sqlite://")
RichieBase.metadata.create_all(engine)
return engine
@pytest.fixture @pytest.fixture
def registry(self, signal_mock, engine): def registry(self, signal_mock, engine):
return DeviceRegistry(signal_mock, engine) return DeviceRegistry(signal_mock, engine)
@@ -214,6 +201,7 @@ class TestContactCache:
trust_level=old.trust_level, trust_level=old.trust_level,
has_safety_number=old.has_safety_number, has_safety_number=old.has_safety_number,
safety_number=old.safety_number, safety_number=old.safety_number,
roles=old.roles,
) )
with patch("python.signal_bot.device_registry.Session") as mock_session_cls: with patch("python.signal_bot.device_registry.Session") as mock_session_cls:
@@ -227,6 +215,20 @@ class TestContactCache:
mock_session.execute.assert_called_once() mock_session.execute.assert_called_once()
class TestLocationCommand:
def test_format_location(self):
response = _format_location("12.34", "56.78")
assert "12.34, 56.78" in response
assert "maps.google.com" in response
def test_handle_location_request_without_config(self):
signal = MagicMock(spec=SignalClient)
message = SignalMessage(source="+1234", timestamp=0, message="location")
handle_location_request(message, signal, None, None)
signal.reply.assert_called_once()
assert "not configured" in signal.reply.call_args[0][1]
class TestDispatch: class TestDispatch:
@pytest.fixture @pytest.fixture
def signal_mock(self): def signal_mock(self):
@@ -241,11 +243,12 @@ class TestDispatch:
mock = MagicMock(spec=DeviceRegistry) mock = MagicMock(spec=DeviceRegistry)
mock.is_verified.return_value = True mock.is_verified.return_value = True
mock.has_safety_number.return_value = True mock.has_safety_number.return_value = True
mock.has_role.return_value = False
mock.get_roles.return_value = []
return mock return mock
@pytest.fixture @pytest.fixture
def config(self): def config(self, engine):
engine = create_engine("sqlite://")
return BotConfig( return BotConfig(
signal_api_url="http://localhost:8080", signal_api_url="http://localhost:8080",
phone_number="+1234567890", phone_number="+1234567890",
@@ -253,33 +256,66 @@ class TestDispatch:
engine=engine, engine=engine,
) )
def test_unverified_device_ignored(self, signal_mock, llm_mock, registry_mock, config): @pytest.fixture
def bot(self, signal_mock, llm_mock, registry_mock, config):
return Bot(signal_mock, llm_mock, registry_mock, config)
def test_unverified_device_ignored(self, bot, signal_mock, registry_mock):
registry_mock.is_verified.return_value = False registry_mock.is_verified.return_value = False
msg = SignalMessage(source="+1234", timestamp=0, message="help") msg = SignalMessage(source="+1234", timestamp=0, message="help")
dispatch(msg, signal_mock, llm_mock, registry_mock, config) bot.dispatch(msg)
signal_mock.reply.assert_not_called() signal_mock.reply.assert_not_called()
def test_help_command(self, signal_mock, llm_mock, registry_mock, config): def test_admin_without_safety_number_ignored(self, bot, signal_mock, registry_mock):
registry_mock.has_safety_number.return_value = False
registry_mock.has_role.return_value = True
msg = SignalMessage(source="+1234", timestamp=0, message="help") msg = SignalMessage(source="+1234", timestamp=0, message="help")
dispatch(msg, signal_mock, llm_mock, registry_mock, config) bot.dispatch(msg)
signal_mock.reply.assert_not_called()
def test_non_admin_without_safety_number_allowed(self, bot, signal_mock, registry_mock):
registry_mock.has_safety_number.return_value = False
registry_mock.has_role.return_value = False
registry_mock.get_roles.return_value = []
msg = SignalMessage(source="+1234", timestamp=0, message="help")
bot.dispatch(msg)
signal_mock.reply.assert_called_once()
def test_help_command(self, bot, signal_mock, registry_mock):
msg = SignalMessage(source="+1234", timestamp=0, message="help")
bot.dispatch(msg)
signal_mock.reply.assert_called_once() signal_mock.reply.assert_called_once()
assert "Available commands" in signal_mock.reply.call_args[0][1] assert "Available commands" in signal_mock.reply.call_args[0][1]
def test_unknown_command_ignored(self, signal_mock, llm_mock, registry_mock, config): def test_unknown_command_ignored(self, bot, signal_mock):
msg = SignalMessage(source="+1234", timestamp=0, message="foobar") msg = SignalMessage(source="+1234", timestamp=0, message="foobar")
dispatch(msg, signal_mock, llm_mock, registry_mock, config) bot.dispatch(msg)
signal_mock.reply.assert_not_called() signal_mock.reply.assert_not_called()
def test_non_command_message_ignored(self, signal_mock, llm_mock, registry_mock, config): def test_non_command_message_ignored(self, bot, signal_mock):
msg = SignalMessage(source="+1234", timestamp=0, message="hello there") msg = SignalMessage(source="+1234", timestamp=0, message="hello there")
dispatch(msg, signal_mock, llm_mock, registry_mock, config) bot.dispatch(msg)
signal_mock.reply.assert_not_called() signal_mock.reply.assert_not_called()
def test_status_command(self, signal_mock, llm_mock, registry_mock, config): def test_status_command(self, bot, signal_mock, llm_mock, registry_mock):
llm_mock.list_models.return_value = ["model1", "model2"] llm_mock.list_models.return_value = ["model1", "model2"]
llm_mock.model = "test:7b" llm_mock.model = "test:7b"
registry_mock.list_devices.return_value = [] registry_mock.list_devices.return_value = []
registry_mock.has_role.return_value = True
msg = SignalMessage(source="+1234", timestamp=0, message="status") msg = SignalMessage(source="+1234", timestamp=0, message="status")
dispatch(msg, signal_mock, llm_mock, registry_mock, config) bot.dispatch(msg)
signal_mock.reply.assert_called_once() signal_mock.reply.assert_called_once()
assert "Bot online" in signal_mock.reply.call_args[0][1] assert "Bot online" in signal_mock.reply.call_args[0][1]
def test_location_command(self, bot, signal_mock, registry_mock, config):
registry_mock.has_role.return_value = True
msg = SignalMessage(source="+1234", timestamp=0, message="location")
with patch("python.signal_bot.main.handle_location_request") as mock_location:
bot.dispatch(msg)
mock_location.assert_called_once_with(
msg,
signal_mock,
config.ha_url,
config.ha_token,
)

86
tools/line_counter.py Normal file
View File

@@ -0,0 +1,86 @@
"""Count lines of code in the repository, grouped by file type."""
from __future__ import annotations
import subprocess
from collections import defaultdict
from pathlib import Path
def get_tracked_files() -> list[str]:
"""Get all git-tracked files."""
result = subprocess.run(
["git", "ls-files"], # noqa: S603, S607
capture_output=True,
text=True,
check=True,
)
return [f for f in result.stdout.strip().splitlines() if f]
def count_lines(filepath: str) -> int:
"""Count lines in a file, returning 0 for binary files."""
try:
return len(Path(filepath).read_text(encoding="utf-8").splitlines())
except (UnicodeDecodeError, OSError):
return 0
def count_lines_by_type() -> dict[str, int]:
"""Count lines grouped by file extension."""
lines_by_type: dict[str, int] = defaultdict(int)
for filepath in get_tracked_files():
ext = Path(filepath).suffix.lstrip(".")
if not ext:
ext = Path(filepath).name
lines_by_type[ext] += count_lines(filepath)
# Exclude binary/non-code files
for key in ("png", "lock"):
lines_by_type.pop(key, None)
return dict(sorted(lines_by_type.items(), key=lambda x: x[1], reverse=True))
def format_report() -> str:
"""Generate a formatted line count report."""
lines_by_type = count_lines_by_type()
total = sum(lines_by_type.values())
lines = [
f"This repo has **{total:,}** lines of technical debt.",
"",
"| File Type | Lines | Percentage |",
"|-----------|------:|-----------:|",
]
for ext, count in lines_by_type.items():
if count > 0:
pct = count / total * 100
prefix = "." if not ext.startswith(".") else ""
lines.append(f"| {prefix}{ext} | {count:,} | {pct:.1f}% |")
return "\n".join(lines)
def update_readme() -> None:
"""Update README.md with the line count report."""
readme_path = Path("README.md")
report = format_report()
start_marker = "<!-- LINE-COUNT-START -->"
end_marker = "<!-- LINE-COUNT-END -->"
content = readme_path.read_text(encoding="utf-8")
section = f"{start_marker}\n{report}\n{end_marker}"
if start_marker in content:
start = content.index(start_marker)
end = content.index(end_marker) + len(end_marker)
content = content[:start] + section + content[end:]
else:
content = content.rstrip() + "\n\n" + section + "\n"
readme_path.write_text(content, encoding="utf-8")
if __name__ == "__main__":
update_readme()

View File

@@ -22,7 +22,7 @@ in
shell = pkgs.zsh; shell = pkgs.zsh;
group = "math"; group = "math";
openssh.authorizedKeys.keys = [ openssh.authorizedKeys.keys = [
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEa0Jjp5qEvrYXQbN2EV+1+9ZwtXQfIpjklsceZRZJdl matthew.michal11@gmail.com" # cspell:disable-line "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJH03VzDbUhzfhvwD+OsYh6GobODYaI9jdNdzWQoqFsp matth@Jove" # cspell:disable-line
]; ];
extraGroups = [ extraGroups = [
"audio" "audio"

View File

@@ -24,7 +24,6 @@
claude-code claude-code
gparted gparted
jetbrains.datagrip jetbrains.datagrip
antigravity-fhs
proxychains proxychains
opencode opencode
]; ];

View File

@@ -50,6 +50,7 @@
"network.http.max-connections" = 1800; "network.http.max-connections" = 1800;
"network.http.max-persistent-connections-per-server" = 10; "network.http.max-persistent-connections-per-server" = 10;
"network.http.max-urgent-start-excessive-connections-per-host" = 5; "network.http.max-urgent-start-excessive-connections-per-host" = 5;
"network.http.accept-encoding" = "gzip, deflate, br, zstd";
"network.http.pacing.requests.enabled" = false; "network.http.pacing.requests.enabled" = false;
"network.dnsCacheExpiration" = 3600; "network.dnsCacheExpiration" = 3600;
"network.dns.max_high_priority_threads" = 8; "network.dns.max_high_priority_threads" = 8;

View File

@@ -16,5 +16,14 @@
"key": "ctrl+shift+`", "key": "ctrl+shift+`",
"command": "-workbench.action.terminal.new", "command": "-workbench.action.terminal.new",
"when": "terminalProcessSupported || terminalWebExtensionContributedProfile" "when": "terminalProcessSupported || terminalWebExtensionContributedProfile"
},
{
"key": "ctrl+shift+g r",
"command": "gitlens.git.rebase"
},
{
"key": "ctrl+shift+g c",
"command": "-gitlens.showQuickCommitFileDetails",
"when": "editorTextFocus && !gitlens:disabled && config.gitlens.keymap == 'chorded'"
} }
] ]