Compare commits

..
Author SHA1 Message Date
Richie bfad5d7df7 feat(installer): enhance NixOS installer with SSH access and encryption password input 2026-07-02 13:47:43 -04:00
Richie cf62f38a3d feat(systems): add tortoise NixOS host configuration
Registers the tortoise machine in flake.nix and adds its system
configuration with a placeholder hardware config (ZFS root pool,
x86_64) to be replaced by the installer-generated hardware-configuration.nix.
2026-07-01 23:06:34 -04:00
Richie d619bd8f16 feat(installer): add one-file installer build and custom install ISO
Replace old_installer.py with a curses TUI installer packaged as a
one-file PyInstaller binary (python/installer/build.py, wrapped by
python/installer/package.nix). The default .#installer is patched to run
on foreign Linux live media; the new .#installer-nixos variant keeps its
Nix store interpreter so it runs on NixOS.

Add systems/iso, a minimal NixOS install CD with kernel 6.18 and ZFS 2.4
matching the deployed systems and the installer on PATH; build it with
nix build .#iso. Shared logging and subprocess helpers move out of
common.py into python/logging_config.py and python/process.py.
2026-07-01 19:14:34 -04:00
Richie aa7dfa4ea4 fix(ebook): enhance EPUB ingestion with error handling and incrmental commits 2026-06-29 01:01:13 -04:00
Richie b995e9b6cb feat(ebook): add phrase metadata tables for protected phrase matching
Introduce four ORM models and their Alembic migration to support
phrase-based query matching in the ebook RAG engine:

- EbookCandidatePhrase: high-recall phrase candidates extracted per book,
  with source flags (ngram/yake/spacy/capitalized/metadata), scoring, and
  LLM judge results.
- EbookProtectedPhrase: phrases accepted by the LLM judge, with canonical
  id, importance, and nesting controls.
- EbookPhraseAlias: normalized aliases mapping to protected phrases.
- EbookChunkPhraseMention: precomputed phrase occurrences within chunks.

Export the new models from python.orm.richie and add a JSON_DOCUMENT
helper (JSON with JSONB postgres variant) for storing sample contexts.
2026-06-29 00:56:58 -04:00
50 changed files with 1496 additions and 740 deletions
+1
View File
@@ -17,6 +17,7 @@ jobs:
- "bob" - "bob"
- "brain" - "brain"
- "jeeves" - "jeeves"
- "leviathan"
- "rhapsody-in-green" - "rhapsody-in-green"
continue-on-error: true continue-on-error: true
steps: steps:
+2
View File
@@ -7,6 +7,7 @@ keys:
- &system_bob age1q47vup0tjhulkg7d6xwmdsgrw64h4ax3la3evzqpxyy4adsmk9fs56qz3y # cspell:disable-line - &system_bob age1q47vup0tjhulkg7d6xwmdsgrw64h4ax3la3evzqpxyy4adsmk9fs56qz3y # cspell:disable-line
- &system_brain age1jhf7vm0005j60mjq63696frrmjhpy8kpc2d66mw044lqap5mjv4snmwvwm # cspell:disable-line - &system_brain age1jhf7vm0005j60mjq63696frrmjhpy8kpc2d66mw044lqap5mjv4snmwvwm # cspell:disable-line
- &system_jeeves age13lmqgc3jvkyah5e3vcwmj4s5wsc2akctcga0lpc0x8v8du3fxprqp4ldkv # cspell:disable-line - &system_jeeves age13lmqgc3jvkyah5e3vcwmj4s5wsc2akctcga0lpc0x8v8du3fxprqp4ldkv # cspell:disable-line
- &system_leviathan age1l272y8udvg60z7edgje42fu49uwt4x2gxn5zvywssnv9h2krms8s094m4k # cspell:disable-line
- &system_rhapsody age1ufnewppysaq2wwcl4ugngjz8pfzc5a35yg7luq0qmuqvctajcycs5lf6k4 # cspell:disable-line - &system_rhapsody age1ufnewppysaq2wwcl4ugngjz8pfzc5a35yg7luq0qmuqvctajcycs5lf6k4 # cspell:disable-line
creation_rules: creation_rules:
@@ -17,4 +18,5 @@ creation_rules:
- *system_bob - *system_bob
- *system_brain - *system_brain
- *system_jeeves - *system_jeeves
- *system_leviathan
- *system_rhapsody - *system_rhapsody
Generated
+18 -18
View File
@@ -8,11 +8,11 @@
}, },
"locked": { "locked": {
"dir": "pkgs/firefox-addons", "dir": "pkgs/firefox-addons",
"lastModified": 1782964936, "lastModified": 1781928171,
"narHash": "sha256-wXEBDr7/dFQYhVpDwCKc9fkrYQQE4x0bdirX1bsLBGA=", "narHash": "sha256-2IIxdVe7afJ8HyTeR/MD9Qw5iIJ68o1iRYkiXn7LDag=",
"owner": "rycee", "owner": "rycee",
"repo": "nur-expressions", "repo": "nur-expressions",
"rev": "64feee871e0373dd6121e412c3fb12e372d1bfb5", "rev": "1777920f3688105a47b95b229dcefc85ae1bb42e",
"type": "gitlab" "type": "gitlab"
}, },
"original": { "original": {
@@ -29,11 +29,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1783005591, "lastModified": 1781989573,
"narHash": "sha256-NcLHV5uBAeggDUE2wPbKszjfyaSLsoqaYt7izOphkZw=", "narHash": "sha256-npfH7Zv7t1akX/ArqCNro4zU4ViPlghLaPnbEfHbCxk=",
"owner": "nix-community", "owner": "nix-community",
"repo": "home-manager", "repo": "home-manager",
"rev": "f469c79b955609d6a8fdd9e689be76a93b1621d7", "rev": "78e7d8b13ecd7f5256a5c11ce216876164099d9f",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -47,11 +47,11 @@
"nixpkgs": "nixpkgs" "nixpkgs": "nixpkgs"
}, },
"locked": { "locked": {
"lastModified": 1782562157, "lastModified": 1781622756,
"narHash": "sha256-a7+T6QSeowynwZ1ZJJbP8T8ntAytvrui8kFGJmIZt2c=", "narHash": "sha256-JrPh4M6S7aPsEE9tOENuZrxC6o2szSLlK+t4+nLke9s=",
"owner": "nixos", "owner": "nixos",
"repo": "nixos-hardware", "repo": "nixos-hardware",
"rev": "a9cf7546a938c737b079e738de73934a13de9784", "rev": "08018c72174a4df5657f8d94178ac69fb9c243e5",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -76,11 +76,11 @@
}, },
"nixpkgs-master": { "nixpkgs-master": {
"locked": { "locked": {
"lastModified": 1783021952, "lastModified": 1782009160,
"narHash": "sha256-8PghAtSGGZ0umfVI8Qbd7ZbFrfZPiH1UwtVbgLeikDA=", "narHash": "sha256-BRto9JC5S8uaapULr/n+tLbZf896K4kbtVZ0PnFra0U=",
"owner": "nixos", "owner": "nixos",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "f136374c679c54171a3ace589d15e9e79a8bd086", "rev": "a7ef34422d8e3cd1f9ffaec949735adb983d4ffc",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -108,11 +108,11 @@
}, },
"nixpkgs_2": { "nixpkgs_2": {
"locked": { "locked": {
"lastModified": 1782723713, "lastModified": 1781577229,
"narHash": "sha256-oPXCU/SSUokcGaJREHibG1CBX3+s/W7orDWQOZDsEeQ=", "narHash": "sha256-lrp67w8AulE9Ks53n27I45ADSzbOCn4H+CNW1Ck8B+8=",
"owner": "nixos", "owner": "nixos",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "b5aa0fbd538984f6e3d201be0005b4463d8b09f8", "rev": "567a49d1913ce81ac6e9582e3553dd90a955875f",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -141,11 +141,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1782165805, "lastModified": 1781943681,
"narHash": "sha256-478kKQBvK6SYTOdN2h9jhKJv94nbXRbFMfuL1WshErg=", "narHash": "sha256-NFHmA7H47adqiyp+0iEOyZOQhmigDqA/NBAlf4imB6U=",
"owner": "Mic92", "owner": "Mic92",
"repo": "sops-nix", "repo": "sops-nix",
"rev": "56b24064fdcaedca53553b1a6d607fd23b613a24", "rev": "420f8d2e9882911f65cfac15cc706f639ba96cca",
"type": "github" "type": "github"
}, },
"original": { "original": {
+44 -13
View File
@@ -95,18 +95,49 @@
} }
); );
nixosConfigurations = nixosConfigurations = {
let bob = lib.nixosSystem {
hosts = builtins.attrNames ( modules = [
lib.filterAttrs (_: type: type == "directory") (builtins.readDir ./systems) ./systems/bob
); ];
mkHost = specialArgs = { inherit inputs outputs; };
name: };
lib.nixosSystem { brain = lib.nixosSystem {
modules = [ ./systems/${name} ]; modules = [
specialArgs = { inherit inputs outputs; }; ./systems/brain
}; ];
in specialArgs = { inherit inputs outputs; };
lib.genAttrs hosts mkHost; };
jeeves = lib.nixosSystem {
modules = [
./systems/jeeves
];
specialArgs = { inherit inputs outputs; };
};
rhapsody-in-green = lib.nixosSystem {
modules = [
./systems/rhapsody-in-green
];
specialArgs = { inherit inputs outputs; };
};
leviathan = lib.nixosSystem {
modules = [
./systems/leviathan
];
specialArgs = { inherit inputs outputs; };
};
tortoise = lib.nixosSystem {
modules = [
./systems/tortoise
];
specialArgs = { inherit inputs outputs; };
};
iso = lib.nixosSystem {
modules = [
./systems/iso
];
specialArgs = { inherit inputs outputs; };
};
};
}; };
} }
@@ -0,0 +1,206 @@
"""adding Phrase metadata tables.
Revision ID: dddee09eddcc
Revises: 96d72c748c24
Create Date: 2026-06-29 00:49:07.344159
"""
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 = "dddee09eddcc"
down_revision: str | None = "96d72c748c24"
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(
"candidate_phrases",
sa.Column("book_id", sa.Integer(), nullable=False),
sa.Column("series_id", sa.Integer(), nullable=True),
sa.Column("phrase_text", sa.Text(), nullable=False),
sa.Column("phrase_norm", sa.Text(), nullable=False),
sa.Column("token_count", sa.Integer(), nullable=False),
sa.Column("source_raw_ngram", sa.Boolean(), nullable=False),
sa.Column("source_yake", sa.Boolean(), nullable=False),
sa.Column("source_spacy_ner", sa.Boolean(), nullable=False),
sa.Column("source_spacy_noun_chunk", sa.Boolean(), nullable=False),
sa.Column("source_capitalized", sa.Boolean(), nullable=False),
sa.Column("source_metadata", sa.Boolean(), nullable=False),
sa.Column("spacy_label", sa.String(), nullable=True),
sa.Column("raw_count", sa.Integer(), nullable=False),
sa.Column("chapter_count", sa.Integer(), nullable=False),
sa.Column("yake_score", sa.Float(), nullable=True),
sa.Column("candidate_score", sa.Float(), nullable=False),
sa.Column(
"sample_contexts",
sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), "postgresql"),
nullable=True,
),
sa.Column("llm_judged", sa.Boolean(), nullable=False),
sa.Column("llm_keep", sa.Boolean(), nullable=True),
sa.Column("llm_confidence", sa.Float(), nullable=True),
sa.Column("llm_category", sa.String(), nullable=True),
sa.Column("llm_reason", sa.Text(), nullable=True),
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(
["book_id"],
[f"{schema}.ebook_source.id"],
name=op.f("fk_candidate_phrases_book_id_ebook_source"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_candidate_phrases")),
sa.UniqueConstraint("book_id", "phrase_norm", name="uq_candidate_phrases_book_id_phrase_norm"),
schema=schema,
)
op.create_index(
"candidate_phrases_book_norm_idx", "candidate_phrases", ["book_id", "phrase_norm"], unique=False, schema=schema
)
op.create_index(
"candidate_phrases_book_score_idx",
"candidate_phrases",
["book_id", "candidate_score"],
unique=False,
schema=schema,
)
op.create_table(
"protected_phrases",
sa.Column("book_id", sa.Integer(), nullable=True),
sa.Column("series_id", sa.Integer(), nullable=True),
sa.Column("phrase_text", sa.Text(), nullable=False),
sa.Column("phrase_norm", sa.Text(), nullable=False),
sa.Column("canonical_id", sa.String(), nullable=False),
sa.Column("phrase_type", sa.String(), nullable=True),
sa.Column("token_count", sa.Integer(), nullable=False),
sa.Column("confidence", sa.Float(), nullable=False),
sa.Column("importance", sa.Float(), nullable=False),
sa.Column("allow_nested", sa.Boolean(), nullable=False),
sa.Column("suppress_children", sa.Boolean(), nullable=False),
sa.Column("source_candidate_id", sa.Integer(), nullable=True),
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(
["book_id"],
[f"{schema}.ebook_source.id"],
name=op.f("fk_protected_phrases_book_id_ebook_source"),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["source_candidate_id"],
[f"{schema}.candidate_phrases.id"],
name=op.f("fk_protected_phrases_source_candidate_id_candidate_phrases"),
ondelete="SET NULL",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_protected_phrases")),
sa.UniqueConstraint("book_id", "phrase_norm", name="uq_protected_phrases_book_id_phrase_norm"),
schema=schema,
)
op.create_index(
"protected_phrases_book_norm_idx", "protected_phrases", ["book_id", "phrase_norm"], unique=False, schema=schema
)
op.create_index("protected_phrases_norm_idx", "protected_phrases", ["phrase_norm"], unique=False, schema=schema)
op.create_index(
"protected_phrases_series_norm_idx",
"protected_phrases",
["series_id", "phrase_norm"],
unique=False,
schema=schema,
)
op.create_table(
"chunk_phrase_mentions",
sa.Column("chunk_id", sa.BigInteger(), nullable=False),
sa.Column("phrase_id", sa.Integer(), nullable=False),
sa.Column("book_id", sa.Integer(), nullable=True),
sa.Column("series_id", sa.Integer(), nullable=True),
sa.Column("start_char", sa.Integer(), nullable=False),
sa.Column("end_char", sa.Integer(), nullable=True),
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(
["book_id"],
[f"{schema}.ebook_source.id"],
name=op.f("fk_chunk_phrase_mentions_book_id_ebook_source"),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["chunk_id"],
[f"{schema}.ebook_chunk.id"],
name=op.f("fk_chunk_phrase_mentions_chunk_id_ebook_chunk"),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["phrase_id"],
[f"{schema}.protected_phrases.id"],
name=op.f("fk_chunk_phrase_mentions_phrase_id_protected_phrases"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_chunk_phrase_mentions")),
sa.UniqueConstraint("chunk_id", "phrase_id", "start_char", name="uq_chunk_phrase_mentions_chunk_phrase_start"),
schema=schema,
)
op.create_index(
"chunk_phrase_mentions_chunk_idx", "chunk_phrase_mentions", ["chunk_id"], unique=False, schema=schema
)
op.create_index(
"chunk_phrase_mentions_phrase_idx", "chunk_phrase_mentions", ["phrase_id"], unique=False, schema=schema
)
op.create_table(
"phrase_aliases",
sa.Column("phrase_id", sa.Integer(), nullable=False),
sa.Column("alias_text", sa.Text(), nullable=False),
sa.Column("alias_norm", sa.Text(), nullable=False),
sa.Column("confidence", sa.Float(), 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(
["phrase_id"],
[f"{schema}.protected_phrases.id"],
name=op.f("fk_phrase_aliases_phrase_id_protected_phrases"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_phrase_aliases")),
sa.UniqueConstraint("phrase_id", "alias_norm", name="uq_phrase_aliases_phrase_id_alias_norm"),
schema=schema,
)
op.create_index("phrase_aliases_norm_idx", "phrase_aliases", ["alias_norm"], unique=False, schema=schema)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index("phrase_aliases_norm_idx", table_name="phrase_aliases", schema=schema)
op.drop_table("phrase_aliases", schema=schema)
op.drop_index("chunk_phrase_mentions_phrase_idx", table_name="chunk_phrase_mentions", schema=schema)
op.drop_index("chunk_phrase_mentions_chunk_idx", table_name="chunk_phrase_mentions", schema=schema)
op.drop_table("chunk_phrase_mentions", schema=schema)
op.drop_index("protected_phrases_series_norm_idx", table_name="protected_phrases", schema=schema)
op.drop_index("protected_phrases_norm_idx", table_name="protected_phrases", schema=schema)
op.drop_index("protected_phrases_book_norm_idx", table_name="protected_phrases", schema=schema)
op.drop_table("protected_phrases", schema=schema)
op.drop_index("candidate_phrases_book_score_idx", table_name="candidate_phrases", schema=schema)
op.drop_index("candidate_phrases_book_norm_idx", table_name="candidate_phrases", schema=schema)
op.drop_table("candidate_phrases", schema=schema)
# ### end Alembic commands ###
+23
View File
@@ -4,8 +4,11 @@ from __future__ import annotations
import logging import logging
from datetime import UTC, datetime from datetime import UTC, datetime
from os import getenv
from subprocess import PIPE, Popen from subprocess import PIPE, Popen
from apprise import Apprise
from python.logging_config import configure_logger as _configure_logger from python.logging_config import configure_logger as _configure_logger
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -36,6 +39,26 @@ def bash_wrapper(command: str) -> tuple[str, int]:
return output.decode(), process.returncode return output.decode(), process.returncode
def signal_alert(body: str, title: str = "") -> None:
"""Send a signal alert.
Args:
body (str): The body of the alert.
title (str, optional): The title of the alert. Defaults to "".
"""
apprise_client = Apprise()
from_phone = getenv("SIGNAL_ALERT_FROM_PHONE")
to_phone = getenv("SIGNAL_ALERT_TO_PHONE")
if not from_phone or not to_phone:
logger.info("SIGNAL_ALERT_FROM_PHONE or SIGNAL_ALERT_TO_PHONE not set")
return
apprise_client.add(f"signal://localhost:8989/{from_phone}/{to_phone}")
apprise_client.notify(title=title, body=body)
def utcnow() -> datetime: def utcnow() -> datetime:
"""Get the current UTC time.""" """Get the current UTC time."""
return datetime.now(tz=UTC) return datetime.now(tz=UTC)
+51 -316
View File
@@ -1,208 +1,25 @@
:root {
--bg: #f4f5f7;
--surface: #ffffff;
--border: #e3e5ea;
--text: #1c1f24;
--muted: #6b7280;
--accent: #4f46e5;
--accent-soft: #eef0fe;
--danger: #b42318;
--warn-bg: #fff8eb;
--warn-border: #e0a92e;
--warn-text: #7a5008;
--radius: 12px;
--shadow: 0 1px 2px rgba(16, 24, 40, 0.04), 0 1px 3px rgba(16, 24, 40, 0.08);
}
html.theme-dark {
--bg: #0f1117;
--surface: #1a1d25;
--border: #2b303b;
--text: #e6e8ec;
--muted: #9aa1ad;
--accent: #818cf8;
--accent-soft: #262b45;
--danger: #f97066;
--warn-bg: #2a2410;
--warn-border: #b9881f;
--warn-text: #e8c97a;
--shadow: 0 1px 2px rgba(0, 0, 0, 0.3), 0 1px 3px rgba(0, 0, 0, 0.4);
color-scheme: dark;
}
* {
box-sizing: border-box;
}
body { body {
margin: 0; margin: 0;
background: var(--bg); background: #f7f7f4;
color: var(--text); color: #202124;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
line-height: 1.55;
} }
main { main {
max-width: 820px; max-width: 960px;
margin: 0 auto; margin: 0 auto;
padding: 32px 20px 64px; padding: 24px;
} }
/* Header / nav */ nav {
.site-header {
background: var(--surface);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 10;
}
.site-nav {
max-width: 820px;
margin: 0 auto;
padding: 12px 20px;
display: flex; display: flex;
align-items: center;
gap: 20px;
}
.brand {
font-weight: 700;
font-size: 1.05rem;
color: var(--text);
text-decoration: none;
}
.nav-links {
display: flex;
gap: 6px;
margin-right: auto;
}
.nav-links a {
padding: 6px 12px;
border-radius: 8px;
color: var(--muted);
text-decoration: none;
font-size: 0.94rem;
transition: background 0.15s, color 0.15s;
}
.nav-links a:hover {
background: var(--accent-soft);
color: var(--accent);
}
.dev-toggle {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.85rem;
color: var(--muted);
cursor: pointer;
user-select: none;
}
.theme-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
padding: 0;
font-size: 1rem;
line-height: 1;
color: var(--text);
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
cursor: pointer;
}
.theme-toggle:hover {
border-color: var(--accent);
filter: none;
}
h1 {
font-size: 1.6rem;
margin: 0 0 20px;
}
h2 {
font-size: 1.15rem;
margin: 0 0 8px;
}
/* Cards */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 20px;
}
/* Search form */
form {
margin: 0;
}
label {
font-weight: 600;
font-size: 0.92rem;
}
textarea {
display: block;
width: 100%;
margin: 8px 0 16px;
padding: 12px 14px;
font: inherit;
color: var(--text);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
resize: vertical;
transition: border-color 0.15s, box-shadow 0.15s;
}
textarea:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.form-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px; gap: 12px;
flex-wrap: wrap;
}
button {
padding: 10px 20px;
font: inherit;
font-weight: 600;
color: #fff;
background: var(--accent);
border: none;
border-radius: 10px;
cursor: pointer;
transition: filter 0.15s;
}
button:hover {
filter: brightness(1.08);
}
.check {
display: inline-flex;
gap: 8px;
align-items: center; align-items: center;
font-weight: 500; margin-bottom: 20px;
color: var(--muted); }
nav form {
margin: 0;
} }
.actions { .actions {
@@ -212,203 +29,121 @@ button:hover {
margin-bottom: 24px; margin-bottom: 24px;
} }
/* Answer + results */ textarea {
#results {
display: block; display: block;
margin-top: 28px; width: 100%;
margin: 8px 0 12px;
}
button {
padding: 8px 14px;
}
.check {
display: inline-flex;
gap: 8px;
align-items: center;
margin-right: 12px;
} }
.rank-label { .rank-label {
font-size: 0.82rem; margin-top: 24px;
font-weight: 600; font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--muted);
margin-bottom: 16px;
}
.answer {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 20px;
margin-bottom: 24px;
}
.answer p:last-child {
margin-bottom: 0;
} }
.results { .results {
list-style: none; padding-left: 24px;
padding: 0;
margin: 0;
display: grid;
gap: 16px;
} }
.results > li { .meta,
background: var(--surface); .scores,
border: 1px solid var(--border); .status {
border-radius: var(--radius); color: #626a73;
box-shadow: var(--shadow);
padding: 18px 20px;
}
.results h2 {
font-size: 1.05rem;
}
.results h2 a {
color: var(--text);
text-decoration: none;
}
.results h2 a:hover {
color: var(--accent);
}
.meta {
color: var(--muted);
font-size: 0.88rem;
margin: 0 0 10px;
} }
.scores { .scores {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 8px; gap: 8px;
margin: 14px 0 0; margin: 12px 0;
} }
.scores div { .scores div {
display: inline-flex; display: inline-flex;
gap: 6px; gap: 4px;
align-items: baseline; align-items: baseline;
padding: 3px 10px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 999px;
font-size: 0.78rem;
} }
.scores dt { .scores dt {
font-weight: 600; font-weight: 700;
color: var(--muted);
} }
.scores dd { .scores dd {
margin: 0; margin: 0;
font-variant-numeric: tabular-nums;
} }
/* Runtime — developer diagnostics, hidden unless dev mode is on */
.runtime { .runtime {
display: none; margin-top: 16px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 18px 20px;
margin-bottom: 24px;
}
html.dev .runtime {
display: block;
} }
.timing-chart { .timing-chart {
display: grid; display: grid;
gap: 8px; gap: 8px;
padding: 0; padding: 0;
margin: 12px 0 0;
list-style: none; list-style: none;
} }
.timing-chart li { .timing-chart li {
display: grid; display: grid;
grid-template-columns: minmax(150px, 1fr) minmax(160px, 2fr) auto auto; grid-template-columns: minmax(150px, 1fr) minmax(160px, 2fr) auto auto;
gap: 10px; gap: 8px;
align-items: center; align-items: center;
font-size: 0.85rem;
} }
.timing-bar { .timing-bar {
height: 8px; height: 10px;
overflow: hidden; overflow: hidden;
background: var(--bg); background: #e5e5df;
border-radius: 999px;
} }
.timing-bar span { .timing-bar span {
display: block; display: block;
height: 100%; height: 100%;
background: var(--accent); background: #3767c8;
border-radius: 999px;
} }
.timing-value, .timing-value,
.timing-remaining { .timing-remaining {
color: var(--muted); color: #626a73;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
text-align: right;
} }
/* Tables */
table { table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
} }
th, th,
td { td {
padding: 10px 14px; padding: 8px;
border-bottom: 1px solid var(--border); border-bottom: 1px solid #d8d8d2;
text-align: left; text-align: left;
font-size: 0.9rem;
} }
th { th {
font-weight: 600; font-weight: 700;
color: var(--muted);
background: var(--bg);
} }
tbody tr:last-child td {
border-bottom: none;
}
dl dt {
font-weight: 600;
color: var(--muted);
font-size: 0.85rem;
}
dl dd {
margin: 0 0 12px;
}
/* States */
.error { .error {
color: var(--danger); color: #9f1d20;
font-weight: 600; font-weight: 700;
} }
.notice { .notice {
margin: 12px 0; margin: 8px 0;
padding: 10px 14px; padding: 8px 12px;
border-left: 3px solid var(--warn-border); border-left: 4px solid #c8881d;
border-radius: 6px; background: #fcf3e2;
background: var(--warn-bg); color: #6b4a06;
color: var(--warn-text); font-weight: 600;
font-weight: 500;
}
.status {
color: var(--muted);
} }
+55 -43
View File
@@ -1,45 +1,57 @@
{% extends "base.html" %} <!DOCTYPE html>
<html lang="en">
{% block title %}EPUB Admin{% endblock %} <head>
{% block head %}<script src="https://unpkg.com/htmx.org@2.0.4"></script>{% endblock %} <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{% block content %} <title>EPUB Admin</title>
<h1>Admin</h1> <script src="https://unpkg.com/htmx.org@2.0.4"></script>
<section id="admin-status"></section> <link rel="stylesheet" href="/static/style.css">
<section class="actions"> </head>
<form hx-post="/admin/scan" hx-target="#admin-status" hx-swap="innerHTML"> <body>
<button type="submit">Scan</button> <main>
</form> <nav>
<form hx-post="/admin/embed-missing" hx-target="#admin-status" hx-swap="innerHTML"> <a href="/">Search</a>
<button type="submit">Embed</button> <a href="/books">Books</a>
</form> <a href="/admin">Admin</a>
<form hx-post="/admin/embed-all" hx-target="#admin-status" hx-swap="innerHTML"> </nav>
<button type="submit">Embed all</button> <h1>Admin</h1>
</form> <section id="admin-status"></section>
</section> <section class="actions">
<section> <form hx-post="/admin/scan" hx-target="#admin-status" hx-swap="innerHTML">
<h2>Embeddings</h2> <button type="submit">Scan</button>
<table> </form>
<thead> <form hx-post="/admin/embed-missing" hx-target="#admin-status" hx-swap="innerHTML">
<tr> <button type="submit">Embed</button>
<th>Model</th> </form>
<th>Dimensions</th> <form hx-post="/admin/embed-all" hx-target="#admin-status" hx-swap="innerHTML">
<th>Embedded</th> <button type="submit">Embed all</button>
<th>Missing</th> </form>
<th>Total chunks</th> </section>
</tr> <section>
</thead> <h2>Embeddings</h2>
<tbody> <table>
{% for item in stats %} <thead>
<tr> <tr>
<td>{{ item.model_name }}</td> <th>Model</th>
<td>{{ item.dimension }}</td> <th>Dimensions</th>
<td>{{ item.embedded_chunks }}</td> <th>Embedded</th>
<td>{{ item.missing_chunks }}</td> <th>Missing</th>
<td>{{ item.total_chunks }}</td> <th>Total chunks</th>
</tr> </tr>
{% endfor %} </thead>
</tbody> <tbody>
</table> {% for item in stats %}
</section> <tr>
{% endblock %} <td>{{ item.model_name }}</td>
<td>{{ item.dimension }}</td>
<td>{{ item.embedded_chunks }}</td>
<td>{{ item.missing_chunks }}</td>
<td>{{ item.total_chunks }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
</main>
</body>
</html>
@@ -1,71 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}EPUB Search{% endblock %}</title>
{% block head %}{% endblock %}
<link rel="stylesheet" href="/static/style.css?v={{ static_version('style.css') }}">
<script>
// Apply theme and dev mode before paint to avoid a flash of unstyled/wrong content.
(function () {
var stored = localStorage.getItem("ebook-theme");
var prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
var theme = stored || (prefersDark ? "dark" : "light");
document.documentElement.classList.add("theme-" + theme);
if (localStorage.getItem("ebook-dev-mode") === "on") {
document.documentElement.classList.add("dev");
}
})();
</script>
</head>
<body>
<header class="site-header">
<nav class="site-nav">
<a class="brand" href="/">EPUB Search</a>
<div class="nav-links">
<a href="/">Search</a>
<a href="/books">Books</a>
<a href="/admin">Admin</a>
</div>
<button type="button" id="theme-toggle" class="theme-toggle" title="Toggle light / dark theme" aria-label="Toggle theme"></button>
<label class="dev-toggle" title="Show developer diagnostics">
<input type="checkbox" id="dev-mode-toggle">
<span>Dev</span>
</label>
</nav>
</header>
<main>
{% block content %}{% endblock %}
</main>
<script>
(function () {
var toggle = document.getElementById("dev-mode-toggle");
if (toggle) {
toggle.checked = document.documentElement.classList.contains("dev");
toggle.addEventListener("change", function () {
document.documentElement.classList.toggle("dev", toggle.checked);
localStorage.setItem("ebook-dev-mode", toggle.checked ? "on" : "off");
});
}
var themeButton = document.getElementById("theme-toggle");
if (themeButton) {
var root = document.documentElement;
var sync = function () {
var isDark = root.classList.contains("theme-dark");
themeButton.textContent = isDark ? "☀️" : "🌙";
};
sync();
themeButton.addEventListener("click", function () {
var next = root.classList.contains("theme-dark") ? "light" : "dark";
root.classList.remove("theme-dark", "theme-light");
root.classList.add("theme-" + next);
localStorage.setItem("ebook-theme", next);
sync();
});
}
})();
</script>
</body>
</html>
@@ -1,20 +1,32 @@
{% extends "base.html" %} <!DOCTYPE html>
<html lang="en">
{% block title %}{% if source %}{{ source.title }}{% else %}Book not found{% endif %}{% endblock %} <head>
<meta charset="UTF-8">
{% block content %} <meta name="viewport" content="width=device-width, initial-scale=1.0">
{% if source %} <title>{% if source %}{{ source.title }}{% else %}Book not found{% endif %}</title>
<h1>{{ source.title }}</h1> <link rel="stylesheet" href="/static/style.css">
<p class="meta">{{ source.author or "Unknown author" }}</p> </head>
<dl class="card"> <body>
<dt>File</dt> <main>
<dd>{{ source.file_path }}</dd> <nav>
<dt>Chapters</dt> <a href="/">Search</a>
<dd>{{ chapter_count }}</dd> <a href="/books">Books</a>
<dt>Chunks</dt> <a href="/admin">Admin</a>
<dd>{{ chunk_count }}</dd> </nav>
</dl> {% if source %}
{% else %} <h1>{{ source.title }}</h1>
<h1>Book not found</h1> <p class="meta">{{ source.author or "Unknown author" }}</p>
{% endif %} <dl>
{% endblock %} <dt>File</dt>
<dd>{{ source.file_path }}</dd>
<dt>Chapters</dt>
<dd>{{ chapter_count }}</dd>
<dt>Chunks</dt>
<dd>{{ chunk_count }}</dd>
</dl>
{% else %}
<h1>Book not found</h1>
{% endif %}
</main>
</body>
</html>
+31 -19
View File
@@ -1,19 +1,31 @@
{% extends "base.html" %} <!DOCTYPE html>
<html lang="en">
{% block title %}EPUB Books{% endblock %} <head>
<meta charset="UTF-8">
{% block content %} <meta name="viewport" content="width=device-width, initial-scale=1.0">
<h1>Books</h1> <title>EPUB Books</title>
{% if sources %} <link rel="stylesheet" href="/static/style.css">
<ol class="results"> </head>
{% for source in sources %} <body>
<li> <main>
<h2><a href="/books/{{ source.id }}">{{ source.title }}</a></h2> <nav>
<p class="meta">{{ source.author or "Unknown author" }}</p> <a href="/">Search</a>
</li> <a href="/books">Books</a>
{% endfor %} <a href="/admin">Admin</a>
</ol> </nav>
{% else %} <h1>Books</h1>
<p>No EPUBs indexed.</p> {% if sources %}
{% endif %} <ol class="results">
{% endblock %} {% for source in sources %}
<li>
<h2><a href="/books/{{ source.id }}">{{ source.title }}</a></h2>
<p class="meta">{{ source.author or "Unknown author" }}</p>
</li>
{% endfor %}
</ol>
{% else %}
<p>No EPUBs indexed.</p>
{% endif %}
</main>
</body>
</html>
@@ -39,13 +39,7 @@
<ol class="results"> <ol class="results">
{% for result in response.results %} {% for result in response.results %}
<li> <li>
<h2> <h2>{{ result.source_title }}</h2>
{% if result.source_id %}
<a href="/books/{{ result.source_id }}">{{ result.source_title }}</a>
{% else %}
{{ result.source_title }}
{% endif %}
</h2>
<p class="meta"> <p class="meta">
{% if result.source_author %}{{ result.source_author }}{% endif %} {% if result.source_author %}{{ result.source_author }}{% endif %}
{% if result.chapter_title %} · {{ result.chapter_title }}{% endif %} {% if result.chapter_title %} · {{ result.chapter_title }}{% endif %}
+25 -15
View File
@@ -1,20 +1,30 @@
{% extends "base.html" %} <!DOCTYPE html>
<html lang="en">
{% block title %}EPUB Search{% endblock %} <head>
{% block head %}<script src="https://unpkg.com/htmx.org@2.0.4"></script>{% endblock %} <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{% block content %} <title>EPUB Search</title>
<h1>Search</h1> <script src="https://unpkg.com/htmx.org@2.0.4"></script>
<form class="card" hx-post="/search" hx-target="#results" hx-swap="innerHTML"> <link rel="stylesheet" href="/static/style.css">
<label for="query">What are you looking for?</label> </head>
<textarea id="query" name="query" rows="4" placeholder="Ask a question or paste a passage…" required></textarea> <body>
<div class="form-row"> <main>
<nav>
<a href="/">Search</a>
<a href="/books">Books</a>
<a href="/admin">Admin</a>
</nav>
<h1>EPUB Search</h1>
<form hx-post="/search" hx-target="#results" hx-swap="innerHTML">
<label for="query">Search</label>
<textarea id="query" name="query" rows="4" required></textarea>
<label class="check"> <label class="check">
<input type="checkbox" name="rerank" value="true" {% if config.rerank.enabled %}checked{% endif %}> <input type="checkbox" name="rerank" value="true" {% if config.rerank.enabled %}checked{% endif %}>
Rerank Rerank
</label> </label>
<button type="submit">Search</button> <button type="submit">Search</button>
</div> </form>
</form> <section id="results"></section>
<section id="results"></section> </main>
{% endblock %} </body>
</html>
-10
View File
@@ -10,14 +10,4 @@ PACKAGE_DIR = Path(__file__).resolve().parent
TEMPLATE_DIR = PACKAGE_DIR / "templates" TEMPLATE_DIR = PACKAGE_DIR / "templates"
STATIC_DIR = PACKAGE_DIR / "static" STATIC_DIR = PACKAGE_DIR / "static"
def static_version(filename: str) -> int:
"""Return a cache-busting token for a static file based on its modification time."""
try:
return int((STATIC_DIR / filename).stat().st_mtime)
except OSError:
return 0
templates = Jinja2Templates(directory=TEMPLATE_DIR) templates = Jinja2Templates(directory=TEMPLATE_DIR)
templates.env.globals["static_version"] = static_version
+55 -50
View File
@@ -91,60 +91,65 @@ def ingest_configured_paths(session: Session, config: EbookSearchConfig) -> int:
def ingest_file(session: Session, path: Path, config: EbookSearchConfig) -> bool: def ingest_file(session: Session, path: Path, config: EbookSearchConfig) -> bool:
"""Ingest one EPUB file. Return True when the database changed.""" """Ingest one EPUB file. Return True when the database changed."""
resolved_path = path.expanduser().resolve() try:
logger.info("ebook_ingest_file_start path=%s", resolved_path) resolved_path = path.expanduser().resolve()
file_hash = sha256_file(resolved_path) logger.info("ebook_ingest_file_start path=%s", resolved_path)
existing = find_existing_source(session, resolved_path, file_hash) file_hash = sha256_file(resolved_path)
if existing is not None and existing.file_sha256 == file_hash: existing = find_existing_source(session, resolved_path, file_hash)
if existing is not None and existing.file_sha256 == file_hash:
stat = resolved_path.stat()
existing.file_path = str(resolved_path)
existing.file_mtime = datetime.fromtimestamp(stat.st_mtime, tz=UTC)
existing.file_size = stat.st_size
session.flush()
logger.info("ebook_ingest_file_unchanged source_id=%s path=%s", existing.id, resolved_path)
return False
if existing is not None:
logger.info("ebook_ingest_file_replacing source_id=%s path=%s", existing.id, resolved_path)
session.delete(existing)
session.flush()
stat = resolved_path.stat() stat = resolved_path.stat()
existing.file_path = str(resolved_path) parsed = parse_epub(resolved_path)
existing.file_mtime = datetime.fromtimestamp(stat.st_mtime, tz=UTC) source = EbookSource(
existing.file_size = stat.st_size title=parsed.title,
session.flush() author=parsed.author,
logger.info("ebook_ingest_file_unchanged source_id=%s path=%s", existing.id, resolved_path) language=parsed.language,
return False publisher=parsed.publisher,
if existing is not None: identifier=parsed.identifier,
logger.info("ebook_ingest_file_replacing source_id=%s path=%s", existing.id, resolved_path) file_path=str(resolved_path),
session.delete(existing) file_sha256=file_hash,
session.flush() file_mtime=datetime.fromtimestamp(stat.st_mtime, tz=UTC),
file_size=stat.st_size,
stat = resolved_path.stat()
parsed = parse_epub(resolved_path)
source = EbookSource(
title=parsed.title,
author=parsed.author,
language=parsed.language,
publisher=parsed.publisher,
identifier=parsed.identifier,
file_path=str(resolved_path),
file_sha256=file_hash,
file_mtime=datetime.fromtimestamp(stat.st_mtime, tz=UTC),
file_size=stat.st_size,
)
session.add(source)
session.flush()
chunk_index = 0
for spine_index, parsed_chapter in enumerate(parsed.chapters):
chapter = EbookChapter(
source_id=source.id,
spine_index=spine_index,
title=parsed_chapter.title,
href=parsed_chapter.href,
) )
session.add(chapter) session.add(source)
session.flush() session.flush()
chunk_index = add_chapter_chunks(session, source, chapter, parsed_chapter, chunk_index, config)
session.flush() chunk_index = 0
logger.info( for spine_index, parsed_chapter in enumerate(parsed.chapters):
"ebook_ingest_file_complete source_id=%s path=%s chapters=%s chunks=%s", chapter = EbookChapter(
source.id, source_id=source.id,
resolved_path, spine_index=spine_index,
len(parsed.chapters), title=parsed_chapter.title,
chunk_index, href=parsed_chapter.href,
) )
return True session.add(chapter)
session.flush()
chunk_index = add_chapter_chunks(session, source, chapter, parsed_chapter, chunk_index, config)
session.commit()
logger.info(
"ebook_ingest_file_complete source_id=%s path=%s chapters=%s chunks=%s",
source.id,
resolved_path,
len(parsed.chapters),
chunk_index,
)
except Exception:
logger.exception(f"ebook_ingest_file_error path={path}")
return False
else:
return True
def find_existing_source(session: Session, path: Path, file_hash: str) -> EbookSource | None: def find_existing_source(session: Session, path: Path, file_hash: str) -> EbookSource | None:
+8
View File
@@ -12,12 +12,16 @@ from python.orm.richie.contact import (
RelationshipType, RelationshipType,
) )
from python.orm.richie.ebook import ( from python.orm.richie.ebook import (
EbookCandidatePhrase,
EbookChapter, EbookChapter,
EbookChunk, EbookChunk,
EbookChunkEmbedding1024, EbookChunkEmbedding1024,
EbookChunkEmbedding2560, EbookChunkEmbedding2560,
EbookChunkEmbedding4096, EbookChunkEmbedding4096,
EbookChunkPhraseMention,
EbookEmbeddingModel, EbookEmbeddingModel,
EbookPhraseAlias,
EbookProtectedPhrase,
EbookSource, EbookSource,
) )
@@ -28,12 +32,16 @@ __all__ = [
"Contact", "Contact",
"ContactNeed", "ContactNeed",
"ContactRelationship", "ContactRelationship",
"EbookCandidatePhrase",
"EbookChapter", "EbookChapter",
"EbookChunk", "EbookChunk",
"EbookChunkEmbedding1024", "EbookChunkEmbedding1024",
"EbookChunkEmbedding2560", "EbookChunkEmbedding2560",
"EbookChunkEmbedding4096", "EbookChunkEmbedding4096",
"EbookChunkPhraseMention",
"EbookEmbeddingModel", "EbookEmbeddingModel",
"EbookPhraseAlias",
"EbookProtectedPhrase",
"EbookSource", "EbookSource",
"Need", "Need",
"RelationshipType", "RelationshipType",
+108 -2
View File
@@ -5,11 +5,23 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
from pgvector.sqlalchemy import Vector from pgvector.sqlalchemy import Vector
from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, Index, String, UniqueConstraint from sqlalchemy import (
JSON,
BigInteger,
DateTime,
ForeignKey,
Index,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from python.orm.richie.base import TableBase, TableBaseBig from python.orm.richie.base import TableBase, TableBaseBig
JSON_DOCUMENT = JSON().with_variant(JSONB, "postgresql")
class EbookSource(TableBase): class EbookSource(TableBase):
"""One indexed EPUB file.""" """One indexed EPUB file."""
@@ -94,7 +106,7 @@ class EbookEmbeddingModel(TableBase):
name: Mapped[str] = mapped_column(String, unique=True) name: Mapped[str] = mapped_column(String, unique=True)
dimension: Mapped[int] dimension: Mapped[int]
is_default: Mapped[bool] = mapped_column(Boolean, default=False) is_default: Mapped[bool] = mapped_column(default=False)
class EbookChunkEmbedding1024(TableBaseBig): class EbookChunkEmbedding1024(TableBaseBig):
@@ -136,3 +148,97 @@ class EbookChunkEmbedding4096(TableBaseBig):
chunk_id: Mapped[int] = mapped_column(ForeignKey("main.ebook_chunk.id", ondelete="CASCADE")) chunk_id: Mapped[int] = mapped_column(ForeignKey("main.ebook_chunk.id", ondelete="CASCADE"))
model_id: Mapped[int] = mapped_column(ForeignKey("main.ebook_embedding_model.id", ondelete="CASCADE")) model_id: Mapped[int] = mapped_column(ForeignKey("main.ebook_embedding_model.id", ondelete="CASCADE"))
embedding: Mapped[list[float]] = mapped_column(Vector(4096)) embedding: Mapped[list[float]] = mapped_column(Vector(4096))
class EbookCandidatePhrase(TableBase):
"""A high-recall phrase candidate extracted from one book."""
__tablename__ = "candidate_phrases"
__table_args__ = (
UniqueConstraint("book_id", "phrase_norm", name="uq_candidate_phrases_book_id_phrase_norm"),
Index("candidate_phrases_book_score_idx", "book_id", "candidate_score"),
Index("candidate_phrases_book_norm_idx", "book_id", "phrase_norm"),
)
book_id: Mapped[int] = mapped_column(ForeignKey("main.ebook_source.id", ondelete="CASCADE"))
series_id: Mapped[int | None]
phrase_text: Mapped[str] = mapped_column(Text)
phrase_norm: Mapped[str] = mapped_column(Text)
token_count: Mapped[int]
source_raw_ngram: Mapped[bool] = mapped_column(default=False)
source_yake: Mapped[bool] = mapped_column(default=False)
source_spacy_ner: Mapped[bool] = mapped_column(default=False)
source_spacy_noun_chunk: Mapped[bool] = mapped_column(default=False)
source_capitalized: Mapped[bool] = mapped_column(default=False)
source_metadata: Mapped[bool] = mapped_column(default=False)
spacy_label: Mapped[str | None]
raw_count: Mapped[int] = mapped_column(default=0)
chapter_count: Mapped[int] = mapped_column(default=0)
yake_score: Mapped[float | None]
candidate_score: Mapped[float] = mapped_column(default=0.0)
sample_contexts: Mapped[list[str] | None] = mapped_column(JSON_DOCUMENT)
llm_judged: Mapped[bool] = mapped_column(default=False)
llm_keep: Mapped[bool | None]
llm_confidence: Mapped[float | None]
llm_category: Mapped[str | None]
llm_reason: Mapped[str | None] = mapped_column(Text)
class EbookProtectedPhrase(TableBase):
"""A phrase accepted by the LLM judge for protected query matching."""
__tablename__ = "protected_phrases"
__table_args__ = (
UniqueConstraint("book_id", "phrase_norm", name="uq_protected_phrases_book_id_phrase_norm"),
Index("protected_phrases_norm_idx", "phrase_norm"),
Index("protected_phrases_book_norm_idx", "book_id", "phrase_norm"),
Index("protected_phrases_series_norm_idx", "series_id", "phrase_norm"),
)
book_id: Mapped[int | None] = mapped_column(ForeignKey("main.ebook_source.id", ondelete="CASCADE"))
series_id: Mapped[int | None]
phrase_text: Mapped[str] = mapped_column(Text)
phrase_norm: Mapped[str] = mapped_column(Text)
canonical_id: Mapped[str]
phrase_type: Mapped[str | None]
token_count: Mapped[int]
confidence: Mapped[float]
importance: Mapped[float] = mapped_column(default=0.5)
allow_nested: Mapped[bool] = mapped_column(default=False)
suppress_children: Mapped[bool] = mapped_column(default=True)
source_candidate_id: Mapped[int | None] = mapped_column(
ForeignKey("main.candidate_phrases.id", ondelete="SET NULL")
)
class EbookPhraseAlias(TableBase):
"""A normalized alias that maps to a protected phrase."""
__tablename__ = "phrase_aliases"
__table_args__ = (
UniqueConstraint("phrase_id", "alias_norm", name="uq_phrase_aliases_phrase_id_alias_norm"),
Index("phrase_aliases_norm_idx", "alias_norm"),
)
phrase_id: Mapped[int] = mapped_column(ForeignKey("main.protected_phrases.id", ondelete="CASCADE"))
alias_text: Mapped[str] = mapped_column(Text)
alias_norm: Mapped[str] = mapped_column(Text)
confidence: Mapped[float] = mapped_column(default=1.0)
class EbookChunkPhraseMention(TableBase):
"""A precomputed occurrence of a protected phrase inside one chunk."""
__tablename__ = "chunk_phrase_mentions"
__table_args__ = (
UniqueConstraint("chunk_id", "phrase_id", "start_char", name="uq_chunk_phrase_mentions_chunk_phrase_start"),
Index("chunk_phrase_mentions_phrase_idx", "phrase_id"),
Index("chunk_phrase_mentions_chunk_idx", "chunk_id"),
)
chunk_id: Mapped[int] = mapped_column(ForeignKey("main.ebook_chunk.id", ondelete="CASCADE"))
phrase_id: Mapped[int] = mapped_column(ForeignKey("main.protected_phrases.id", ondelete="CASCADE"))
book_id: Mapped[int | None] = mapped_column(ForeignKey("main.ebook_source.id", ondelete="CASCADE"))
series_id: Mapped[int | None]
start_char: Mapped[int]
end_char: Mapped[int | None]
-30
View File
@@ -1,30 +0,0 @@
"""signal_alert."""
from __future__ import annotations
import logging
from os import getenv
from apprise import Apprise
logger = logging.getLogger(__name__)
def signal_alert(body: str, title: str = "") -> None:
"""Send a signal alert.
Args:
body (str): The body of the alert.
title (str, optional): The title of the alert. Defaults to "".
"""
apprise_client = Apprise()
from_phone = getenv("SIGNAL_ALERT_FROM_PHONE")
to_phone = getenv("SIGNAL_ALERT_TO_PHONE")
if not from_phone or not to_phone:
logger.info("SIGNAL_ALERT_FROM_PHONE or SIGNAL_ALERT_TO_PHONE not set")
return
apprise_client.add(f"signal://localhost:8989/{from_phone}/{to_phone}")
apprise_client.notify(title=title, body=body)
+1 -2
View File
@@ -9,8 +9,7 @@ from socket import gethostname
import typer import typer
from python.common import configure_logger from python.common import configure_logger, signal_alert
from python.signal_alert import signal_alert
from python.system_tests.components import systemd_tests, zpool_tests from python.system_tests.components import systemd_tests, zpool_tests
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
+1 -2
View File
@@ -12,8 +12,7 @@ from re import search
import typer import typer
from python.common import configure_logger, utcnow from python.common import configure_logger, signal_alert, utcnow
from python.signal_alert import signal_alert
from python.zfs import Dataset, get_datasets from python.zfs import Dataset, get_datasets
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -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 = {
@@ -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 %}
-19
View File
@@ -22,11 +22,6 @@
]; ];
}; };
useNetworkd = true; useNetworkd = true;
# Without this, unconfigured NICs get a catch-all DHCP network and
# join the LAN uninvited (how the dead atlantic card ended up
# ARP-poisoning 192.168.99.14). Every interface must be configured
# explicitly below.
useDHCP = false;
}; };
systemd.network = { systemd.network = {
@@ -51,20 +46,6 @@
}; };
}; };
networks = { networks = {
# Unused second port of the onboard i350; keep it down so the dead
# PCIe link (see dmesg "PCIe link lost") stops logging Tx errors.
"01-unused-igb-port" = {
matchConfig.Name = "enp97s0f0";
linkConfig.ActivationPolicy = "always-down";
};
# Aquantia 10G card (0000:21:00.0); loses PCIe link, and its
# unicast RX path is dead while TX still works, so when it's up it
# answers ARP for the host IPs and black-holes the replies. Keep
# it down until the card is fixed or replaced.
"01-unused-atlantic-port" = {
matchConfig.Name = "enp33s0";
linkConfig.ActivationPolicy = "always-down";
};
"10-Primary" = { "10-Primary" = {
matchConfig.Name = "enp97s0f1"; matchConfig.Name = "enp97s0f1";
address = [ "192.168.99.14/24" ]; address = [ "192.168.99.14/24" ];
-14
View File
@@ -16,20 +16,6 @@ in
ExecStart = "${pkgs.bash}/bin/bash ${../scripts/plex_permission.sh}"; ExecStart = "${pkgs.bash}/bin/bash ${../scripts/plex_permission.sh}";
}; };
}; };
# The unused onboard i350 port hangs ~90s after every boot ("PCIe
# link lost", MMIO reads return all-Fs) even with pcie_aspm=off.
# Remove it from the PCI bus so nothing (hwmon temperature polls,
# networkd) touches the dead hardware. Reversible via
# `echo 1 > /sys/bus/pci/rescan`.
remove_dead_igb_port = {
wantedBy = [ "multi-user.target" ];
description = "removes hung i350 port 0000:61:00.0 from the PCI bus";
unitConfig.ConditionPathExists = "/sys/bus/pci/devices/0000:61:00.0/remove";
serviceConfig = {
Type = "oneshot";
ExecStart = "${pkgs.bash}/bin/bash -c 'echo 1 > /sys/bus/pci/devices/0000:61:00.0/remove'";
};
};
startup_validation = { startup_validation = {
requires = [ "network-online.target" ]; requires = [ "network-online.target" ];
after = [ "network-online.target" ]; after = [ "network-online.target" ];
+32 -30
View File
@@ -37,23 +37,6 @@ frontend ContentSwitching
acl host_gitea hdr(host) -i gitea.tmmworkshop.com acl host_gitea hdr(host) -i gitea.tmmworkshop.com
acl host_norn_sight hdr(host) -i www.norn-sight.com acl host_norn_sight hdr(host) -i www.norn-sight.com
# --- Request logging ---
# Capture the Host header and User-Agent so the httplog shows who is
# requesting what. They appear in the log's {captured|headers} field,
# in this order: {host|user-agent}. Client IP is already logged by httplog.
# Kept above the rate limiting so denied (429) requests are captured too.
http-request capture req.hdr(Host) len 100
http-request capture req.hdr(User-Agent) len 128
# --- robots.txt ---
# Serve a single global robots.txt for every vhost (asks crawlers to wait
# 10s between requests via Crawl-delay). Returned for both HTTP and HTTPS.
# File is deployed to /etc/haproxy/robots.txt by haproxy.nix.
# Kept above the rate limiting so crawlers can always read it: `return` is a
# terminating action, so robots.txt requests are never tracked or denied.
acl is_robots path /robots.txt
http-request return status 200 content-type "text/plain" file /etc/haproxy/robots.txt if is_robots
# --- Rate limiting (Gitea only, per source IP) --- # --- Rate limiting (Gitea only, per source IP) ---
# Trusted devices exempt from rate limiting (add one line per IP/CIDR). # Trusted devices exempt from rate limiting (add one line per IP/CIDR).
# Internal / reserved-for-private-use ranges: # Internal / reserved-for-private-use ranges:
@@ -64,25 +47,42 @@ frontend ContentSwitching
# Add specific public devices below as needed: # Add specific public devices below as needed:
# acl rate_limit_allowlist src 192.0.2.50 # acl rate_limit_allowlist src 192.0.2.50
# Logged-in Gitea sessions bypass the rate limits. Gitea sets the
# `i_like_gitea` session cookie on login, and it is only sent to the Gitea
# vhost, so this only affects Gitea traffic. Note: this matches cookie
# PRESENCE, not validity, so it filters anonymous crawlers (which carry no
# cookie) rather than acting as a hard security boundary.
acl gitea_logged_in req.cook(i_like_gitea) -m found
# Track HTTP request rate per client IP over a 10s sliding window. Only Gitea # Track HTTP request rate per client IP over a 10s sliding window. Only Gitea
# is rate-limited; all other vhosts are left alone. # is rate-limited; all other vhosts are left alone.
# ipv6 table type also covers IPv4 (mapped), so it works for both binds. # ipv6 table type also covers IPv4 (mapped), so it works for both binds.
stick-table type ipv6 size 100k expire 30s store http_req_rate(10s) stick-table type ipv6 size 100k expire 30s store http_req_rate(10s)
http-request track-sc0 src if host_gitea !is_acme !rate_limit_allowlist http-request track-sc0 src if host_gitea !is_acme !rate_limit_allowlist !gitea_logged_in
# Threshold: deny (429) when a client exceeds this many requests per 10s. # Threshold: deny (429) when a client exceeds this many requests per 10s.
# Kept loose (50/10s) since logged-in users are rate-limited too; a page acl over_rate_limit sc_http_req_rate(0) gt 10
# load can burst a few dozen asset requests. http-request deny deny_status 429 if over_rate_limit host_gitea !is_acme !rate_limit_allowlist !gitea_logged_in
acl over_rate_limit sc_http_req_rate(0) gt 50
http-request deny deny_status 429 if over_rate_limit host_gitea !is_acme !rate_limit_allowlist # --- Request logging ---
# Capture the Host header and User-Agent so the httplog shows who is
# requesting what. They appear in the log's {captured|headers} field,
# in this order: {host|user-agent}. Client IP is already logged by httplog.
http-request capture req.hdr(Host) len 100
http-request capture req.hdr(User-Agent) len 128
# --- robots.txt ---
# Serve a single global robots.txt for every vhost (asks crawlers to wait
# 10s between requests via Crawl-delay). Returned for both HTTP and HTTPS.
# File is deployed to /etc/haproxy/robots.txt by haproxy.nix.
acl is_robots path /robots.txt
http-request return status 200 content-type "text/plain" file /etc/haproxy/robots.txt if is_robots
# --- Per-endpoint limit: Gitea compare/diff is expensive; cap at 1 req / 5 min / IP --- # --- Per-endpoint limit: Gitea compare/diff is expensive; cap at 1 req / 5 min / IP ---
# Tracked in a separate 5-minute table (st_compare) since a proxy has only one # Tracked in a separate 5-minute table (st_compare) since a proxy has only one
# inline stick-table. Allow-listed (internal) IPs are exempt. # inline stick-table. Allow-listed (internal) IPs are exempt.
# Matches /<owner>/<repo>/compare on every repo; -i because Gitea routes are acl is_gitea_compare path_beg /Richie/dotfiles/compare
# case-insensitive. http-request track-sc1 src table st_compare if host_gitea is_gitea_compare !rate_limit_allowlist !gitea_logged_in
acl is_gitea_compare path_reg -i ^/[^/]+/[^/]+/compare http-request deny deny_status 429 if host_gitea is_gitea_compare !rate_limit_allowlist !gitea_logged_in { sc_http_req_rate(1,st_compare) gt 1 }
http-request track-sc1 src table st_compare if host_gitea is_gitea_compare !rate_limit_allowlist
http-request deny deny_status 429 if host_gitea is_gitea_compare !rate_limit_allowlist { sc_http_req_rate(1,st_compare) gt 1 }
# Hosts allowed to serve plain HTTP (add entries to skip the HTTPS redirect) # Hosts allowed to serve plain HTTP (add entries to skip the HTTPS redirect)
acl allow_http hdr(host) -i __none__ acl allow_http hdr(host) -i __none__
@@ -117,9 +117,11 @@ backend cache_nodes
server server 127.0.0.1:5000 server server 127.0.0.1:5000
backend jellyfin backend jellyfin
mode http option httpchk
option forwardfor option forwardfor
server jellyfin 127.0.0.1:8096 http-check send meth GET uri /health
http-check expect string Healthy
server jellyfin 127.0.0.1:8096
backend share_nodes backend share_nodes
mode http mode http
+29
View File
@@ -0,0 +1,29 @@
{ inputs, ... }:
{
imports = [
"${inputs.self}/users/elise"
"${inputs.self}/users/richie"
"${inputs.self}/common/global"
"${inputs.self}/common/optional/desktop.nix"
"${inputs.self}/common/optional/steam.nix"
"${inputs.self}/common/optional/systemd-boot.nix"
"${inputs.self}/common/optional/update.nix"
"${inputs.self}/common/optional/zerotier.nix"
"${inputs.self}/common/optional/brain_substituter.nix"
./hardware.nix
inputs.nixos-hardware.nixosModules.framework-13-7040-amd
];
networking = {
hostName = "leviathan";
hostId = "cb9b64d8";
firewall.enable = true;
networkmanager.enable = true;
};
services = {
openssh.ports = [ 332 ];
};
system.stateVersion = "25.05";
}
+69
View File
@@ -0,0 +1,69 @@
{
config,
lib,
modulesPath,
...
}:
{
imports = [ (modulesPath + "/installer/scan/not-detected.nix") ];
boot = {
initrd = {
availableKernelModules = [
"ahci"
"ehci_pci"
"nvme"
"sd_mod"
"usb_storage"
"usbhid"
"xhci_pci"
];
kernelModules = [ ];
luks.devices."luks-root-pool-nvme-Samsung_SSD_970_EVO_Plus_1TB_S6S1NS0T617615W-part2" = {
device = "/dev/disk/by-id/nvme-Samsung_SSD_970_EVO_Plus_1TB_S6S1NS0T617615W-part2";
bypassWorkqueues = true;
allowDiscards = true;
};
};
kernelModules = [ "kvm-amd" ];
extraModulePackages = [ ];
};
fileSystems = {
"/" = lib.mkDefault {
device = "root_pool/root";
fsType = "zfs";
};
"/home" = {
device = "root_pool/home";
fsType = "zfs";
};
"/var" = {
device = "root_pool/var";
fsType = "zfs";
};
"/nix" = {
device = "root_pool/nix";
fsType = "zfs";
};
"/boot" = {
device = "/dev/disk/by-uuid/12CE-A600";
fsType = "vfat";
options = [
"fmask=0077"
"dmask=0077"
];
};
};
swapDevices = [ ];
networking.useDHCP = lib.mkDefault true;
nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux";
hardware.cpu.amd.updateMicrocode = lib.mkDefault config.hardware.enableRedistributableFirmware;
}
+37
View File
@@ -0,0 +1,37 @@
{ inputs, ... }:
{
imports = [
"${inputs.self}/users/richie"
"${inputs.self}/common/global"
"${inputs.self}/common/optional/monitoring-agent.nix"
"${inputs.self}/common/optional/ssh_decrypt.nix"
"${inputs.self}/common/optional/systemd-boot.nix"
"${inputs.self}/common/optional/update.nix"
"${inputs.self}/common/optional/zerotier.nix"
./hardware.nix
];
boot.kernelParams = [ "panic=10" ];
networking = {
hostName = "tortoise";
hostId = "161cbd68";
firewall.enable = true;
networkmanager.enable = true;
};
services = {
openssh.ports = [ 867 ];
};
systemd = {
enableEmergencyMode = false;
# Only takes effect if the board exposes a hardware watchdog device.
settings.Manager = {
RuntimeWatchdogSec = "30s";
RebootWatchdogSec = "2m";
};
};
system.stateVersion = "26.11";
}
+69
View File
@@ -0,0 +1,69 @@
{
config,
lib,
modulesPath,
...
}:
{
imports = [ (modulesPath + "/installer/scan/not-detected.nix") ];
boot = {
initrd = {
availableKernelModules = [
"ahci"
"ehci_pci"
"nvme"
"sd_mod"
"usb_storage"
"usbhid"
"xhci_pci"
];
kernelModules = [ ];
luks.devices."luks-root-pool-ata-Samsung_SSD_870_EVO_4TB_S757NL0Y315721H-part2" = {
device = "/dev/disk/by-id/ata-Samsung_SSD_870_EVO_4TB_S757NL0Y315721H-part2";
bypassWorkqueues = true;
allowDiscards = true;
};
};
kernelModules = [ "kvm-intel" ];
extraModulePackages = [ ];
};
fileSystems = {
"/" = lib.mkDefault {
device = "root_pool/root";
fsType = "zfs";
};
"/home" = {
device = "root_pool/home";
fsType = "zfs";
};
"/var" = {
device = "root_pool/var";
fsType = "zfs";
};
"/nix" = {
device = "root_pool/nix";
fsType = "zfs";
};
"/boot" = {
device = "/dev/disk/by-uuid/223B-9F08";
fsType = "vfat";
options = [
"fmask=0077"
"dmask=0077"
];
};
};
swapDevices = [ ];
networking.useDHCP = lib.mkDefault true;
nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux";
hardware.cpu.intel.updateMicrocode = lib.mkDefault config.hardware.enableRedistributableFirmware;
}
+37 -1
View File
@@ -2,7 +2,15 @@
from __future__ import annotations from __future__ import annotations
from python.common import bash_wrapper, utcnow from os import environ
from typing import TYPE_CHECKING
from apprise import Apprise
from python.common import bash_wrapper, signal_alert, utcnow
if TYPE_CHECKING:
from pytest_mock import MockerFixture
def test_utcnow() -> None: def test_utcnow() -> None:
@@ -10,6 +18,34 @@ def test_utcnow() -> None:
utcnow() utcnow()
def test_signal_alert(mocker: MockerFixture) -> None:
"""test_signal_alert."""
environ["SIGNAL_ALERT_FROM_PHONE"] = "1234567890"
environ["SIGNAL_ALERT_TO_PHONE"] = "0987654321"
mock_logger = mocker.patch("python.common.logger")
mock_apprise_client = mocker.MagicMock(spec=Apprise)
mocker.patch("python.common.Apprise", return_value=mock_apprise_client)
signal_alert("test")
mock_logger.info.assert_not_called()
mock_apprise_client.add.assert_called_once_with("signal://localhost:8989/1234567890/0987654321")
mock_apprise_client.notify.assert_called_once_with(title="", body="test")
def test_signal_alert_no_phones(mocker: MockerFixture) -> None:
"""test_signal_alert_no_phones."""
if "SIGNAL_ALERT_FROM_PHONE" in environ:
del environ["SIGNAL_ALERT_FROM_PHONE"]
if "SIGNAL_ALERT_TO_PHONE" in environ:
del environ["SIGNAL_ALERT_TO_PHONE"]
mock_logger = mocker.patch("python.common.logger")
signal_alert("test")
mock_logger.info.assert_called_once_with("SIGNAL_ALERT_FROM_PHONE or SIGNAL_ALERT_TO_PHONE not set")
def test_test_bash_wrapper() -> None: def test_test_bash_wrapper() -> None:
"""test_test_bash_wrapper.""" """test_test_bash_wrapper."""
stdout, returncode = bash_wrapper("echo test") stdout, returncode = bash_wrapper("echo test")
-41
View File
@@ -1,41 +0,0 @@
"""test_signal_alert."""
from __future__ import annotations
from os import environ
from typing import TYPE_CHECKING
from apprise import Apprise
from python.signal_alert import signal_alert
if TYPE_CHECKING:
from pytest_mock import MockerFixture
def test_signal_alert(mocker: MockerFixture) -> None:
"""test_signal_alert."""
environ["SIGNAL_ALERT_FROM_PHONE"] = "1234567890"
environ["SIGNAL_ALERT_TO_PHONE"] = "0987654321"
mock_logger = mocker.patch("python.signal_alert.logger")
mock_apprise_client = mocker.MagicMock(spec=Apprise)
mocker.patch("python.signal_alert.Apprise", return_value=mock_apprise_client)
signal_alert("test")
mock_logger.info.assert_not_called()
mock_apprise_client.add.assert_called_once_with("signal://localhost:8989/1234567890/0987654321")
mock_apprise_client.notify.assert_called_once_with(title="", body="test")
def test_signal_alert_no_phones(mocker: MockerFixture) -> None:
"""test_signal_alert_no_phones."""
if "SIGNAL_ALERT_FROM_PHONE" in environ:
del environ["SIGNAL_ALERT_FROM_PHONE"]
if "SIGNAL_ALERT_TO_PHONE" in environ:
del environ["SIGNAL_ALERT_TO_PHONE"]
mock_logger = mocker.patch("python.signal_alert.logger")
signal_alert("test")
mock_logger.info.assert_called_once_with("SIGNAL_ALERT_FROM_PHONE or SIGNAL_ALERT_TO_PHONE not set")
+45
View File
@@ -0,0 +1,45 @@
{
pkgs,
config,
...
}:
let
ifTheyExist = groups: builtins.filter (group: builtins.hasAttr group config.users.groups) groups;
in
{
sops.secrets.elise_password = {
sopsFile = ../secrets.yaml;
neededForUsers = true;
};
users = {
users.elise = {
isNormalUser = true;
hashedPasswordFile = "${config.sops.secrets.elise_password.path}";
shell = pkgs.zsh;
group = "elise";
openssh.authorizedKeys.keys = [
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJYZFsc9CSH03ZUP7y81AHwSyjLwFmcshVFCyxDcYhBT rhapsody-in-green" # cspell:disable-line
];
extraGroups = [
"audio"
"video"
"users"
]
++ ifTheyExist [
"dialout"
"networkmanager"
"plugdev"
"scanner"
];
uid = 1010;
};
groups.elise.gid = 1010;
};
home-manager.users.elise = import ./systems/${config.networking.hostName}.nix;
}
+9
View File
@@ -0,0 +1,9 @@
{
imports = [
./direnv.nix
./git.nix
./zsh.nix
];
programs.starship.enable = true;
}
+8
View File
@@ -0,0 +1,8 @@
{
programs.direnv = {
enable = true;
enableZshIntegration = true;
nix-direnv.enable = true;
};
}
+15
View File
@@ -0,0 +1,15 @@
{
programs.git = {
enable = true;
signing.format = null;
settings = {
user = {
email = "DumbPuppy208@gmail.com";
name = "Elise Corvidae";
};
pull.rebase = true;
color.ui = true;
};
lfs.enable = true;
};
}
+31
View File
@@ -0,0 +1,31 @@
{
programs.zsh = {
enable = true;
syntaxHighlighting.enable = true;
history.size = 10000;
oh-my-zsh = {
enable = true;
plugins = [
"git"
"docker"
"docker-compose"
"colored-man-pages"
"rust"
"systemd"
"tmux"
"ufw"
"z"
];
};
shellAliases = {
"lrt" = "eza --icons -lsnew";
"ls" = "eza";
"ll" = "eza --long --group";
"la" = "eza --all";
"rebuild" = "sudo nixos-rebuild switch --flake $HOME/dotfiles#$HOST";
"rebuild_backup" =
"sudo nixos-rebuild switch --flake $HOME/dotfiles#$HOST --option substituters 'https://nix-community.cachix.org' --option trusted-public-keys 'cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY='";
};
};
}
+21
View File
@@ -0,0 +1,21 @@
{ config, ... }:
{
imports = [
./cli
./programs.nix
];
programs = {
home-manager.enable = true;
git.enable = true;
};
home = {
username = "elise";
homeDirectory = "/home/${config.home.username}";
stateVersion = "24.05";
sessionVariables = {
FLAKE = "$HOME/dotfiles";
};
};
}
+26
View File
@@ -0,0 +1,26 @@
{ inputs, pkgs, ... }:
{
imports = [
"${inputs.self}/users/shared/comms.nix"
"${inputs.self}/users/shared/games.nix"
"${inputs.self}/users/shared/sweet.nix"
./kitty.nix
./vscode
];
home.packages = with pkgs; [
obs-studio
obsidian
vlc
qalculate-gtk
# graphics tools
gimp3
xcursorgen
# browser
chromium
firefox
# 3d modeling
blender
prusa-slicer
];
}
+13
View File
@@ -0,0 +1,13 @@
{ pkgs, ... }:
{
programs.kitty = {
enable = true;
font.name = "IntoneMono Nerd Font";
settings = {
allow_remote_control = "no";
shell = "${pkgs.zsh}/bin/zsh";
wayland_titlebar_color = "background";
};
themeFile = "VSCode_Dark";
};
}
+19
View File
@@ -0,0 +1,19 @@
{ config, pkgs, ... }:
let
vscode_dir = "/home/elise/.vscode";
in
{
# mutable symlinks to key binds and settings
xdg.configFile."Code/User/settings.json".source =
config.lib.file.mkOutOfStoreSymlink "${vscode_dir}/settings.json";
xdg.configFile."Code/User/keybindings.json".source =
config.lib.file.mkOutOfStoreSymlink "${vscode_dir}/keybindings.json";
home.packages = with pkgs; [ nil ];
programs.vscode = {
enable = true;
package = pkgs.vscode;
mutableExtensionsDir = true;
};
}
@@ -0,0 +1,68 @@
from subprocess import run
def get_installed_extensions():
process = run("code --list-extensions".split(), check=True, capture_output=True)
return set(process.stdout.decode("utf-8").strip().split("\n"))
def main():
print("starting vscode extension manager")
extensions = {
# vscode
"ms-azuretools.vscode-docker",
"ms-vscode-remote.remote-containers",
"ms-vscode-remote.remote-ssh-edit",
"ms-vscode-remote.remote-ssh",
"ms-vscode.hexeditor",
"ms-vscode.remote-explorer",
"ms-vsliveshare.vsliveshare",
"oderwat.indent-rainbow",
"usernamehw.errorlens",
# git
"codezombiech.gitignore",
"eamodio.gitlens",
"gitHub.vscode-github-actions",
# python
"charliermarsh.ruff",
"ms-python.python",
"ms-python.vscode-pylance",
"ms-python.debugpy",
# rust
"rust-lang.rust-analyzer",
# MD
"davidanson.vscode-markdownlint",
"yzhang.markdown-all-in-one",
# configs
"redhat.vscode-yaml",
"tamasfe.even-better-toml",
# shell
"timonwong.shellcheck",
"foxundermoon.shell-format",
# nix
"jnoortheen.nix-ide",
# database
"mtxr.sqltools-driver-pg",
"mtxr.sqltools",
# other
"esbenp.prettier-vscode",
"mechatroner.rainbow-csv",
"streetsidesoftware.code-spell-checker",
"supermaven.supermaven",
}
installed_extensions = get_installed_extensions()
missing_extensions = extensions.difference(installed_extensions)
for extension in missing_extensions:
run(f"code --install-extension {extension} --force".split(), check=True)
if extra_extensions := installed_extensions.difference(extensions):
print(f"Extra extensions installed: {extra_extensions}")
print("vscode extension manager finished")
if __name__ == "__main__":
main()
@@ -0,0 +1,20 @@
[
{
"key": "shift+alt+f",
"command": "editor.action.formatDocument",
"when": "editorHasDocumentFormattingProvider && editorTextFocus && !editorReadonly && !inCompositeEditor"
},
{
"key": "alt+a d",
"command": "cSpell.addWordToWorkspaceSettings"
},
{
"key": "ctrl+shift+`",
"command": "workbench.action.createTerminalEditor"
},
{
"key": "ctrl+shift+`",
"command": "-workbench.action.terminal.new",
"when": "terminalProcessSupported || terminalWebExtensionContributedProfile"
}
]
+63
View File
@@ -0,0 +1,63 @@
{
// vscode settings
"diffEditor.ignoreTrimWhitespace": false,
"editor.formatOnSave": true,
"editor.minimap.renderCharacters": false,
"editor.minimap.showSlider": "always",
"explorer.confirmDelete": false,
"explorer.confirmDragAndDrop": false,
"explorer.confirmPasteNative": false,
"files.autoSave": "afterDelay",
"git.autofetch": true,
"git.confirmSync": false,
"git.fetchOnPull": true,
"git.pruneOnFetch": true,
"terminal.integrated.scrollback": 10000,
"update.mode": "none",
"workbench.colorTheme": "Default Dark+",
"workbench.secondarySideBar.showLabels": false,
// turns off all sounds and announcements
"accessibility.signals.terminalCommandFailed": {
"sound": "off",
"announcement": "off"
},
"accessibility.signals.terminalQuickFix": {
"sound": "off",
"announcement": "off"
},
"accessibility.signals.terminalBell": {
"sound": "off",
"announcement": "off"
},
// formatters
"[html]": { "editor.defaultFormatter": "esbenp.prettier-vscode" },
"[jsonc]": { "editor.defaultFormatter": "esbenp.prettier-vscode" },
"[json]": { "editor.defaultFormatter": "esbenp.prettier-vscode" },
"[markdown]": { "editor.defaultFormatter": "esbenp.prettier-vscode" },
"[nix]": { "editor.defaultFormatter": "jnoortheen.nix-ide" },
"[python]": { "editor.defaultFormatter": "charliermarsh.ruff" },
"[yaml]": { "editor.defaultFormatter": "redhat.vscode-yaml" },
"[javascriptreact]": { "editor.defaultFormatter": "esbenp.prettier-vscode" },
// spell check
"cSpell.enabled": true,
"cSpell.language": "en,en-US",
"cSpell.enableFiletypes": ["bat", "csv", "nix", "toml"],
"cSpell.userWords": ["Cahill", "syncthing"],
// nix
"nix.enableLanguageServer": true,
"nix.serverPath": "nil",
// python tools
"mypy.runUsingActiveInterpreter": true,
// force the use of rust-analyzer from dev shell
"rust-analyzer.server.path": "rust-analyzer",
"redhat.telemetry.enabled": true,
"gitlens.plusFeatures.enabled": false,
// new
"hediet.vscode-drawio.resizeImages": null
}
+57
View File
@@ -0,0 +1,57 @@
{ pkgs, ... }:
{
home.packages = with pkgs; [
# cli
bat
btop
eza
fd
ffmpegthumbnailer
fzf
git
gnupg
imagemagick
jq
ncdu
ouch
p7zip
poppler
rar
ripgrep
starship
tmux
unzip
yazi
zoxide
# system info
hwloc
lynis
pciutils
smartmontools
usbutils
# networking
iperf3
nmap
wget
# python
poetry
ruff
uv
# nodejs
nodejs
# Rust packages
trunk
wasm-pack
cargo-watch
cargo-generate
cargo-audit
cargo-update
# nix
nix-init
nix-output-monitor
nix-prefetch
nix-tree
nixfmt
treefmt
];
}
+6
View File
@@ -0,0 +1,6 @@
{
imports = [
../home/global.nix
../home/gui
];
}
-1
View File
@@ -61,6 +61,5 @@
nix-tree nix-tree
nixfmt nixfmt
treefmt treefmt
codebase-memory-mcp
]; ];
} }
+6
View File
@@ -0,0 +1,6 @@
{
imports = [
../home/global.nix
../home/gui
];
}
+5
View File
@@ -0,0 +1,5 @@
{
imports = [
../home/global.nix
];
}
+19 -15
View File
@@ -1,8 +1,13 @@
richie_password: ENC[AES256_GCM,data:DMi3M8aqrQ60APIofr8wJMh+VZ14hLRxz6jWZgzswr0pV/QVSX53ShBFr90ruO3mucOLYv0l+bI31covfqMAhXWBJp9wUgtC2Q==,iv:qgtn30hZfIL4dBnQSLkjbo7zPJA4m9TR0f52sTFc0v4=,tag:ydLbcGyXjv0fE+4b5ECX5w==,type:str] richie_password: ENC[AES256_GCM,data:DMi3M8aqrQ60APIofr8wJMh+VZ14hLRxz6jWZgzswr0pV/QVSX53ShBFr90ruO3mucOLYv0l+bI31covfqMAhXWBJp9wUgtC2Q==,iv:qgtn30hZfIL4dBnQSLkjbo7zPJA4m9TR0f52sTFc0v4=,tag:ydLbcGyXjv0fE+4b5ECX5w==,type:str]
gaming_password: ENC[AES256_GCM,data:i692UsQaCOjE4V1y9d8yYDlK+TRMIprCHJkhl1UBZRMqe9a2LTUtmbbn/xlCYQd2tADJvn+dkx1jLfV4CqaqWOj5YSUFfpgsEw==,iv:3Y7hXQcmpzNN7hF+BDvO52uFB4o5D0dHvxemJ0ZoSIM=,tag:zzLGNDVAMCs2GPMqXp2BtQ==,type:str]
megan_password: ENC[AES256_GCM,data:Udrs9OWFI2TDM1yxRwfy7uiONh1G3Mr9HabwpmRykp1Xw9KK+q245nxN7QQbR0AiTCyyyivhn6GB2+DvBBY/6UrN5iGs+LaXgg==,iv:n02HzE8jvWM5xDfaPB9BHxtfoAZQ/Tk80XuySY2NyoU=,tag:L9wPVy7zt6mp09qWhzdLpg==,type:str]
gcw_password: ENC[AES256_GCM,data:T5CliWyyw4igunGRokOW7dNTOQ7DbOhM4gLa8YN4gbVLEVU7n3jxAVF9Uy9zM7LBBqdLvyXnqGzC1HBSBmE+pKBV7YIN3aQkng==,iv:SLq4aeLHdwfq0+A4N6UO4Dz7oBoC0ZDKBr74hheHQFw=,tag:4a71PZcyzoWjOmYEPx07ag==,type:str]
math_password: ENC[AES256_GCM,data:ykiSr3iBHrShJarEQSJ/zuXbCPcbW2oUpaAjblu1V15ufFKVSMZM94LlpMiCYtN9cYBLs98hcMeajJbvgbwT5emPHthy9+TJDw==,iv:1TJEUo0ishqFAZiUE1473yR3RT6Gbtqt4zM+C1a1KEk=,tag:pR6jyIj+bu3XaSx5yIHSmA==,type:str] math_password: ENC[AES256_GCM,data:ykiSr3iBHrShJarEQSJ/zuXbCPcbW2oUpaAjblu1V15ufFKVSMZM94LlpMiCYtN9cYBLs98hcMeajJbvgbwT5emPHthy9+TJDw==,iv:1TJEUo0ishqFAZiUE1473yR3RT6Gbtqt4zM+C1a1KEk=,tag:pR6jyIj+bu3XaSx5yIHSmA==,type:str]
elise_password: ENC[AES256_GCM,data:bhTYGphCy528LuTmwNzA8fxmVa9opA2UomtPwV6prfxtE5k154G/AcK+AomoXAk/Mur/CjlyTeT0ohRoe17/T1iGFZSpYzPMbQ==,iv:1H1MlUPsKOtKrtDpqOL50/1agGrMMbb/5l08vl4WDdE=,tag:kA03EciqKE+iy+N9C2ii+w==,type:str]
sops: sops:
age: age:
- enc: | - recipient: age1u8zj599elqqvcmhxn8zuwrufsz8w8w366d3ayrljjejljt2q45kq8mxw9c
enc: |
-----BEGIN AGE ENCRYPTED FILE----- -----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBObHhkaFlnaG4zaTZtbkIw YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBObHhkaFlnaG4zaTZtbkIw
TkxSQnMxbDNwUVo4R1VYRDNKVFRDUE9kb0ZjCnpWWElJUVNuNFBsMzZod1ZQY0Fa TkxSQnMxbDNwUVo4R1VYRDNKVFRDUE9kb0ZjCnpWWElJUVNuNFBsMzZod1ZQY0Fa
@@ -10,8 +15,8 @@ sops:
cXUzVmFxTUVIOWZVR2Fpa2crdWsrdlkKwdGLfbKWc25qfBKyd/cawiUWv9iepKHN cXUzVmFxTUVIOWZVR2Fpa2crdWsrdlkKwdGLfbKWc25qfBKyd/cawiUWv9iepKHN
EOp/LdH2GbCfnQSVbxi28ukLHxWqOLdqMm8xSni/Of2PXvMnpdyCyQ== EOp/LdH2GbCfnQSVbxi28ukLHxWqOLdqMm8xSni/Of2PXvMnpdyCyQ==
-----END AGE ENCRYPTED FILE----- -----END AGE ENCRYPTED FILE-----
recipient: age1u8zj599elqqvcmhxn8zuwrufsz8w8w366d3ayrljjejljt2q45kq8mxw9c - recipient: age1q47vup0tjhulkg7d6xwmdsgrw64h4ax3la3evzqpxyy4adsmk9fs56qz3y
- enc: | enc: |
-----BEGIN AGE ENCRYPTED FILE----- -----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB6R0djMTArVmFySE5DMnFr YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB6R0djMTArVmFySE5DMnFr
WFdBUERFbE1HRVVFd0oyaXJ2eU5HUStBUFN3CnR3ckZ2bkpGZFFScHQwTlBZYTMv WFdBUERFbE1HRVVFd0oyaXJ2eU5HUStBUFN3CnR3ckZ2bkpGZFFScHQwTlBZYTMv
@@ -19,8 +24,8 @@ sops:
eVlwQWgxSG5SdmFrWTlOcFo5eXZONWMKgx4huoSnbkRq0wQbsYgsWUKDTxDGNvYR eVlwQWgxSG5SdmFrWTlOcFo5eXZONWMKgx4huoSnbkRq0wQbsYgsWUKDTxDGNvYR
anVMQg+c7PwDlk1V4JQZ4WrYLx63Ep5qDjGlN/Ssf2Vo6rAuuKetcA== anVMQg+c7PwDlk1V4JQZ4WrYLx63Ep5qDjGlN/Ssf2Vo6rAuuKetcA==
-----END AGE ENCRYPTED FILE----- -----END AGE ENCRYPTED FILE-----
recipient: age1q47vup0tjhulkg7d6xwmdsgrw64h4ax3la3evzqpxyy4adsmk9fs56qz3y - recipient: age1jhf7vm0005j60mjq63696frrmjhpy8kpc2d66mw044lqap5mjv4snmwvwm
- enc: | enc: |
-----BEGIN AGE ENCRYPTED FILE----- -----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA2QjZYejFYbCswQjdmaDA0 YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA2QjZYejFYbCswQjdmaDA0
L2ZqUUhtYU12YlpISmxueHQzRG5YL0tQNXh3CndGamMwRzYvUzkvaE9DVnMwTkNC L2ZqUUhtYU12YlpISmxueHQzRG5YL0tQNXh3CndGamMwRzYvUzkvaE9DVnMwTkNC
@@ -28,8 +33,8 @@ sops:
VW5yeFlvWUZ5MVpNZHA5M1VXR1hxU1kKqii08/MB2aabgP4RQs1ry8AxmFqB8Mn+ VW5yeFlvWUZ5MVpNZHA5M1VXR1hxU1kKqii08/MB2aabgP4RQs1ry8AxmFqB8Mn+
m7B0u64aziKXLSl0u471wqgD+YGRwNcajXT2pHCy8QWLznzvIMSrxA== m7B0u64aziKXLSl0u471wqgD+YGRwNcajXT2pHCy8QWLznzvIMSrxA==
-----END AGE ENCRYPTED FILE----- -----END AGE ENCRYPTED FILE-----
recipient: age1jhf7vm0005j60mjq63696frrmjhpy8kpc2d66mw044lqap5mjv4snmwvwm - recipient: age13lmqgc3jvkyah5e3vcwmj4s5wsc2akctcga0lpc0x8v8du3fxprqp4ldkv
- enc: | enc: |
-----BEGIN AGE ENCRYPTED FILE----- -----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBrSUc3VnllVDFZTm1jRnlP YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBrSUc3VnllVDFZTm1jRnlP
ZDBqelhkeHliZ1VlcjVnblQyeFlWclZTWkZjCjhJQk5EWkVoQjdoMHg4Zko4OU1C ZDBqelhkeHliZ1VlcjVnblQyeFlWclZTWkZjCjhJQk5EWkVoQjdoMHg4Zko4OU1C
@@ -37,8 +42,8 @@ sops:
cGJ3NDBLem9FNUpnbStYRTlqQStHV2sKwxPe4nTULsU0mVeUh8mhr2KX9U0iT5dL cGJ3NDBLem9FNUpnbStYRTlqQStHV2sKwxPe4nTULsU0mVeUh8mhr2KX9U0iT5dL
zvHldoQG6mZHgtHK6XI5AQJYf+zUW66OKqNSxAnn+BM20QkAQVZNVw== zvHldoQG6mZHgtHK6XI5AQJYf+zUW66OKqNSxAnn+BM20QkAQVZNVw==
-----END AGE ENCRYPTED FILE----- -----END AGE ENCRYPTED FILE-----
recipient: age13lmqgc3jvkyah5e3vcwmj4s5wsc2akctcga0lpc0x8v8du3fxprqp4ldkv - recipient: age1l272y8udvg60z7edgje42fu49uwt4x2gxn5zvywssnv9h2krms8s094m4k
- enc: | enc: |
-----BEGIN AGE ENCRYPTED FILE----- -----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB0NU9ac2FuRHI1dkQycmc0 YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB0NU9ac2FuRHI1dkQycmc0
YlhGK29UeTdiZEZXcWtPUW4rMis4Z2NWYWpJCkVldEdMc3ZTaDFidHpaZk5mM283 YlhGK29UeTdiZEZXcWtPUW4rMis4Z2NWYWpJCkVldEdMc3ZTaDFidHpaZk5mM283
@@ -46,8 +51,8 @@ sops:
L2NObzZadlJ5d3MyeGRqKy95L3BOMFEKtoswi6r2TmCZzngUkiGQV5TTsuzisMFS L2NObzZadlJ5d3MyeGRqKy95L3BOMFEKtoswi6r2TmCZzngUkiGQV5TTsuzisMFS
5QI0aQZwhexqUMvbPuajYKvcPj+D6a2xaxbL3TBRLjOrFmcp5J7/YA== 5QI0aQZwhexqUMvbPuajYKvcPj+D6a2xaxbL3TBRLjOrFmcp5J7/YA==
-----END AGE ENCRYPTED FILE----- -----END AGE ENCRYPTED FILE-----
recipient: age1l272y8udvg60z7edgje42fu49uwt4x2gxn5zvywssnv9h2krms8s094m4k - recipient: age1ufnewppysaq2wwcl4ugngjz8pfzc5a35yg7luq0qmuqvctajcycs5lf6k4
- enc: | enc: |
-----BEGIN AGE ENCRYPTED FILE----- -----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSByUlVjM2xpc281bHZzOGVo YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSByUlVjM2xpc281bHZzOGVo
L3VEclhJZDd5SS9mazFiTk9DcmxMaWxPT213ClNPWERKQU03OWk0OEVIY05ib2VG L3VEclhJZDd5SS9mazFiTk9DcmxMaWxPT213ClNPWERKQU03OWk0OEVIY05ib2VG
@@ -55,8 +60,7 @@ sops:
TmMvWVpobnl0eXBIOGQwMW5BSlhJTUkKzua1artJWbZlKfzv27xfZJeBpntBYwUf TmMvWVpobnl0eXBIOGQwMW5BSlhJTUkKzua1artJWbZlKfzv27xfZJeBpntBYwUf
c8i1gNlvRwkhFAlrWcKR65vgyxsO3rbkLJRkcwG/q4hHj9zBeC/K2A== c8i1gNlvRwkhFAlrWcKR65vgyxsO3rbkLJRkcwG/q4hHj9zBeC/K2A==
-----END AGE ENCRYPTED FILE----- -----END AGE ENCRYPTED FILE-----
recipient: age1ufnewppysaq2wwcl4ugngjz8pfzc5a35yg7luq0qmuqvctajcycs5lf6k4 lastmodified: "2025-08-24T22:36:28Z"
lastmodified: "2026-06-29T20:19:44Z" mac: ENC[AES256_GCM,data:gtY2M4+BGBRJFzuRURjJypTTbjhn+pVJoKy2REa4a/hSpn7Rnp2Nk3t0/DNYKIquGS7gFxYpXQnUyhBHlAfXqnQWu5InE2b6iLG6INdzeyPI4dGfJaop8ZxXTCKNy3kLgW9kkjBbS1uQlHvy9y/2J+QjjjHPw7M4Fh+E9XKwDGk=,iv:OoCyzLvP6iVwrU2xmK/7ov4h0QqrWk+XbGDiUJ68kJo=,tag:iYVF12f7iHm/dkWFTIsO8Q==,type:str]
mac: ENC[AES256_GCM,data:GIvQxWt4tZGn0fyiXVtxGFQQoNcFUgilF+/PSz50exVrmzsS0XQUk/TIDFHaQR9jlJI50jqlyc1rBHgjnqC2oPHhPWaaVhgF18vQI55rGKdymNFjsHnaCkblFVdR1RJm0FSB2Ri6y5k8tfN3ywiwromJRz4NYzr1hbmr36azfg4=,iv:V3jspeYt/d2wy13gUrQmPGARm0hxwvSL/mocJAUofdw=,tag:vARoUzBWTJKkONDGoQdzNQ==,type:str]
unencrypted_suffix: _unencrypted unencrypted_suffix: _unencrypted
version: 3.13.1 version: 3.10.2
-1
View File
@@ -3,7 +3,6 @@
home.packages = with pkgs; [ home.packages = with pkgs; [
discord-canary discord-canary
signal-desktop signal-desktop
slack
zoom-us zoom-us
]; ];
} }