Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bfad5d7df7 | ||
|
|
cf62f38a3d | ||
|
|
d619bd8f16 | ||
|
|
aa7dfa4ea4 | ||
|
|
b995e9b6cb |
@@ -17,6 +17,7 @@ jobs:
|
||||
- "bob"
|
||||
- "brain"
|
||||
- "jeeves"
|
||||
- "leviathan"
|
||||
- "rhapsody-in-green"
|
||||
continue-on-error: true
|
||||
steps:
|
||||
|
||||
@@ -7,6 +7,7 @@ keys:
|
||||
- &system_bob age1q47vup0tjhulkg7d6xwmdsgrw64h4ax3la3evzqpxyy4adsmk9fs56qz3y # cspell:disable-line
|
||||
- &system_brain age1jhf7vm0005j60mjq63696frrmjhpy8kpc2d66mw044lqap5mjv4snmwvwm # cspell:disable-line
|
||||
- &system_jeeves age13lmqgc3jvkyah5e3vcwmj4s5wsc2akctcga0lpc0x8v8du3fxprqp4ldkv # cspell:disable-line
|
||||
- &system_leviathan age1l272y8udvg60z7edgje42fu49uwt4x2gxn5zvywssnv9h2krms8s094m4k # cspell:disable-line
|
||||
- &system_rhapsody age1ufnewppysaq2wwcl4ugngjz8pfzc5a35yg7luq0qmuqvctajcycs5lf6k4 # cspell:disable-line
|
||||
|
||||
creation_rules:
|
||||
@@ -17,4 +18,5 @@ creation_rules:
|
||||
- *system_bob
|
||||
- *system_brain
|
||||
- *system_jeeves
|
||||
- *system_leviathan
|
||||
- *system_rhapsody
|
||||
|
||||
Generated
+18
-18
@@ -8,11 +8,11 @@
|
||||
},
|
||||
"locked": {
|
||||
"dir": "pkgs/firefox-addons",
|
||||
"lastModified": 1782964936,
|
||||
"narHash": "sha256-wXEBDr7/dFQYhVpDwCKc9fkrYQQE4x0bdirX1bsLBGA=",
|
||||
"lastModified": 1781928171,
|
||||
"narHash": "sha256-2IIxdVe7afJ8HyTeR/MD9Qw5iIJ68o1iRYkiXn7LDag=",
|
||||
"owner": "rycee",
|
||||
"repo": "nur-expressions",
|
||||
"rev": "64feee871e0373dd6121e412c3fb12e372d1bfb5",
|
||||
"rev": "1777920f3688105a47b95b229dcefc85ae1bb42e",
|
||||
"type": "gitlab"
|
||||
},
|
||||
"original": {
|
||||
@@ -29,11 +29,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1783005591,
|
||||
"narHash": "sha256-NcLHV5uBAeggDUE2wPbKszjfyaSLsoqaYt7izOphkZw=",
|
||||
"lastModified": 1781989573,
|
||||
"narHash": "sha256-npfH7Zv7t1akX/ArqCNro4zU4ViPlghLaPnbEfHbCxk=",
|
||||
"owner": "nix-community",
|
||||
"repo": "home-manager",
|
||||
"rev": "f469c79b955609d6a8fdd9e689be76a93b1621d7",
|
||||
"rev": "78e7d8b13ecd7f5256a5c11ce216876164099d9f",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -47,11 +47,11 @@
|
||||
"nixpkgs": "nixpkgs"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1782562157,
|
||||
"narHash": "sha256-a7+T6QSeowynwZ1ZJJbP8T8ntAytvrui8kFGJmIZt2c=",
|
||||
"lastModified": 1781622756,
|
||||
"narHash": "sha256-JrPh4M6S7aPsEE9tOENuZrxC6o2szSLlK+t4+nLke9s=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixos-hardware",
|
||||
"rev": "a9cf7546a938c737b079e738de73934a13de9784",
|
||||
"rev": "08018c72174a4df5657f8d94178ac69fb9c243e5",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -76,11 +76,11 @@
|
||||
},
|
||||
"nixpkgs-master": {
|
||||
"locked": {
|
||||
"lastModified": 1783021952,
|
||||
"narHash": "sha256-8PghAtSGGZ0umfVI8Qbd7ZbFrfZPiH1UwtVbgLeikDA=",
|
||||
"lastModified": 1782009160,
|
||||
"narHash": "sha256-BRto9JC5S8uaapULr/n+tLbZf896K4kbtVZ0PnFra0U=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "f136374c679c54171a3ace589d15e9e79a8bd086",
|
||||
"rev": "a7ef34422d8e3cd1f9ffaec949735adb983d4ffc",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -108,11 +108,11 @@
|
||||
},
|
||||
"nixpkgs_2": {
|
||||
"locked": {
|
||||
"lastModified": 1782723713,
|
||||
"narHash": "sha256-oPXCU/SSUokcGaJREHibG1CBX3+s/W7orDWQOZDsEeQ=",
|
||||
"lastModified": 1781577229,
|
||||
"narHash": "sha256-lrp67w8AulE9Ks53n27I45ADSzbOCn4H+CNW1Ck8B+8=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "b5aa0fbd538984f6e3d201be0005b4463d8b09f8",
|
||||
"rev": "567a49d1913ce81ac6e9582e3553dd90a955875f",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -141,11 +141,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1782165805,
|
||||
"narHash": "sha256-478kKQBvK6SYTOdN2h9jhKJv94nbXRbFMfuL1WshErg=",
|
||||
"lastModified": 1781943681,
|
||||
"narHash": "sha256-NFHmA7H47adqiyp+0iEOyZOQhmigDqA/NBAlf4imB6U=",
|
||||
"owner": "Mic92",
|
||||
"repo": "sops-nix",
|
||||
"rev": "56b24064fdcaedca53553b1a6d607fd23b613a24",
|
||||
"rev": "420f8d2e9882911f65cfac15cc706f639ba96cca",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -95,18 +95,49 @@
|
||||
}
|
||||
);
|
||||
|
||||
nixosConfigurations =
|
||||
let
|
||||
hosts = builtins.attrNames (
|
||||
lib.filterAttrs (_: type: type == "directory") (builtins.readDir ./systems)
|
||||
);
|
||||
mkHost =
|
||||
name:
|
||||
lib.nixosSystem {
|
||||
modules = [ ./systems/${name} ];
|
||||
specialArgs = { inherit inputs outputs; };
|
||||
};
|
||||
in
|
||||
lib.genAttrs hosts mkHost;
|
||||
nixosConfigurations = {
|
||||
bob = lib.nixosSystem {
|
||||
modules = [
|
||||
./systems/bob
|
||||
];
|
||||
specialArgs = { inherit inputs outputs; };
|
||||
};
|
||||
brain = lib.nixosSystem {
|
||||
modules = [
|
||||
./systems/brain
|
||||
];
|
||||
specialArgs = { inherit inputs outputs; };
|
||||
};
|
||||
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; };
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
+206
@@ -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 ###
|
||||
@@ -4,8 +4,11 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from os import getenv
|
||||
from subprocess import PIPE, Popen
|
||||
|
||||
from apprise import Apprise
|
||||
|
||||
from python.logging_config import configure_logger as _configure_logger
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -36,6 +39,26 @@ def bash_wrapper(command: str) -> tuple[str, int]:
|
||||
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:
|
||||
"""Get the current UTC time."""
|
||||
return datetime.now(tz=UTC)
|
||||
|
||||
@@ -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 {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
background: #f7f7f4;
|
||||
color: #202124;
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
main {
|
||||
max-width: 820px;
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 20px 64px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
/* Header / 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;
|
||||
nav {
|
||||
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;
|
||||
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;
|
||||
font-weight: 500;
|
||||
color: var(--muted);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
nav form {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.actions {
|
||||
@@ -212,203 +29,121 @@ button:hover {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
/* Answer + results */
|
||||
#results {
|
||||
textarea {
|
||||
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 {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
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;
|
||||
margin-top: 24px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.results {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
.results > li {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
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;
|
||||
.meta,
|
||||
.scores,
|
||||
.status {
|
||||
color: #626a73;
|
||||
}
|
||||
|
||||
.scores {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin: 14px 0 0;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.scores div {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
gap: 4px;
|
||||
align-items: baseline;
|
||||
padding: 3px 10px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.scores dt {
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.scores dd {
|
||||
margin: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Runtime — developer diagnostics, hidden unless dev mode is on */
|
||||
.runtime {
|
||||
display: none;
|
||||
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;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.timing-chart {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 0;
|
||||
margin: 12px 0 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.timing-chart li {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(150px, 1fr) minmax(160px, 2fr) auto auto;
|
||||
gap: 10px;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.timing-bar {
|
||||
height: 8px;
|
||||
height: 10px;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
border-radius: 999px;
|
||||
background: #e5e5df;
|
||||
}
|
||||
|
||||
.timing-bar span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
border-radius: 999px;
|
||||
background: #3767c8;
|
||||
}
|
||||
|
||||
.timing-value,
|
||||
.timing-remaining {
|
||||
color: var(--muted);
|
||||
color: #626a73;
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid #d8d8d2;
|
||||
text-align: left;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
th {
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
background: var(--bg);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
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 {
|
||||
color: var(--danger);
|
||||
font-weight: 600;
|
||||
color: #9f1d20;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.notice {
|
||||
margin: 12px 0;
|
||||
padding: 10px 14px;
|
||||
border-left: 3px solid var(--warn-border);
|
||||
border-radius: 6px;
|
||||
background: var(--warn-bg);
|
||||
color: var(--warn-text);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status {
|
||||
color: var(--muted);
|
||||
margin: 8px 0;
|
||||
padding: 8px 12px;
|
||||
border-left: 4px solid #c8881d;
|
||||
background: #fcf3e2;
|
||||
color: #6b4a06;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -1,45 +1,57 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}EPUB Admin{% endblock %}
|
||||
{% block head %}<script src="https://unpkg.com/htmx.org@2.0.4"></script>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Admin</h1>
|
||||
<section id="admin-status"></section>
|
||||
<section class="actions">
|
||||
<form hx-post="/admin/scan" hx-target="#admin-status" hx-swap="innerHTML">
|
||||
<button type="submit">Scan</button>
|
||||
</form>
|
||||
<form hx-post="/admin/embed-missing" hx-target="#admin-status" hx-swap="innerHTML">
|
||||
<button type="submit">Embed</button>
|
||||
</form>
|
||||
<form hx-post="/admin/embed-all" hx-target="#admin-status" hx-swap="innerHTML">
|
||||
<button type="submit">Embed all</button>
|
||||
</form>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Embeddings</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Model</th>
|
||||
<th>Dimensions</th>
|
||||
<th>Embedded</th>
|
||||
<th>Missing</th>
|
||||
<th>Total chunks</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in stats %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>EPUB Admin</title>
|
||||
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<nav>
|
||||
<a href="/">Search</a>
|
||||
<a href="/books">Books</a>
|
||||
<a href="/admin">Admin</a>
|
||||
</nav>
|
||||
<h1>Admin</h1>
|
||||
<section id="admin-status"></section>
|
||||
<section class="actions">
|
||||
<form hx-post="/admin/scan" hx-target="#admin-status" hx-swap="innerHTML">
|
||||
<button type="submit">Scan</button>
|
||||
</form>
|
||||
<form hx-post="/admin/embed-missing" hx-target="#admin-status" hx-swap="innerHTML">
|
||||
<button type="submit">Embed</button>
|
||||
</form>
|
||||
<form hx-post="/admin/embed-all" hx-target="#admin-status" hx-swap="innerHTML">
|
||||
<button type="submit">Embed all</button>
|
||||
</form>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Embeddings</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<td>{{ item.model_name }}</td>
|
||||
<td>{{ item.dimension }}</td>
|
||||
<td>{{ item.embedded_chunks }}</td>
|
||||
<td>{{ item.missing_chunks }}</td>
|
||||
<td>{{ item.total_chunks }}</td>
|
||||
<th>Model</th>
|
||||
<th>Dimensions</th>
|
||||
<th>Embedded</th>
|
||||
<th>Missing</th>
|
||||
<th>Total chunks</th>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{% endblock %}
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in stats %}
|
||||
<tr>
|
||||
<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" %}
|
||||
|
||||
{% block title %}{% if source %}{{ source.title }}{% else %}Book not found{% endif %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if source %}
|
||||
<h1>{{ source.title }}</h1>
|
||||
<p class="meta">{{ source.author or "Unknown author" }}</p>
|
||||
<dl class="card">
|
||||
<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 %}
|
||||
{% endblock %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% if source %}{{ source.title }}{% else %}Book not found{% endif %}</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<nav>
|
||||
<a href="/">Search</a>
|
||||
<a href="/books">Books</a>
|
||||
<a href="/admin">Admin</a>
|
||||
</nav>
|
||||
{% if source %}
|
||||
<h1>{{ source.title }}</h1>
|
||||
<p class="meta">{{ source.author or "Unknown author" }}</p>
|
||||
<dl>
|
||||
<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>
|
||||
|
||||
@@ -1,19 +1,31 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}EPUB Books{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Books</h1>
|
||||
{% if sources %}
|
||||
<ol class="results">
|
||||
{% 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 %}
|
||||
{% endblock %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>EPUB Books</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<nav>
|
||||
<a href="/">Search</a>
|
||||
<a href="/books">Books</a>
|
||||
<a href="/admin">Admin</a>
|
||||
</nav>
|
||||
<h1>Books</h1>
|
||||
{% if sources %}
|
||||
<ol class="results">
|
||||
{% 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">
|
||||
{% for result in response.results %}
|
||||
<li>
|
||||
<h2>
|
||||
{% if result.source_id %}
|
||||
<a href="/books/{{ result.source_id }}">{{ result.source_title }}</a>
|
||||
{% else %}
|
||||
{{ result.source_title }}
|
||||
{% endif %}
|
||||
</h2>
|
||||
<h2>{{ result.source_title }}</h2>
|
||||
<p class="meta">
|
||||
{% if result.source_author %}{{ result.source_author }}{% endif %}
|
||||
{% if result.chapter_title %} · {{ result.chapter_title }}{% endif %}
|
||||
|
||||
@@ -1,20 +1,30 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}EPUB Search{% endblock %}
|
||||
{% block head %}<script src="https://unpkg.com/htmx.org@2.0.4"></script>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Search</h1>
|
||||
<form class="card" hx-post="/search" hx-target="#results" hx-swap="innerHTML">
|
||||
<label for="query">What are you looking for?</label>
|
||||
<textarea id="query" name="query" rows="4" placeholder="Ask a question or paste a passage…" required></textarea>
|
||||
<div class="form-row">
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>EPUB Search</title>
|
||||
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<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">
|
||||
<input type="checkbox" name="rerank" value="true" {% if config.rerank.enabled %}checked{% endif %}>
|
||||
Rerank
|
||||
</label>
|
||||
<button type="submit">Search</button>
|
||||
</div>
|
||||
</form>
|
||||
<section id="results"></section>
|
||||
{% endblock %}
|
||||
</form>
|
||||
<section id="results"></section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -10,14 +10,4 @@ PACKAGE_DIR = Path(__file__).resolve().parent
|
||||
TEMPLATE_DIR = PACKAGE_DIR / "templates"
|
||||
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.env.globals["static_version"] = static_version
|
||||
|
||||
@@ -91,60 +91,65 @@ def ingest_configured_paths(session: Session, config: EbookSearchConfig) -> int:
|
||||
|
||||
def ingest_file(session: Session, path: Path, config: EbookSearchConfig) -> bool:
|
||||
"""Ingest one EPUB file. Return True when the database changed."""
|
||||
resolved_path = path.expanduser().resolve()
|
||||
logger.info("ebook_ingest_file_start path=%s", resolved_path)
|
||||
file_hash = sha256_file(resolved_path)
|
||||
existing = find_existing_source(session, resolved_path, file_hash)
|
||||
if existing is not None and existing.file_sha256 == file_hash:
|
||||
try:
|
||||
resolved_path = path.expanduser().resolve()
|
||||
logger.info("ebook_ingest_file_start path=%s", resolved_path)
|
||||
file_hash = sha256_file(resolved_path)
|
||||
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()
|
||||
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()
|
||||
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,
|
||||
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(chapter)
|
||||
session.add(source)
|
||||
session.flush()
|
||||
chunk_index = add_chapter_chunks(session, source, chapter, parsed_chapter, chunk_index, config)
|
||||
|
||||
session.flush()
|
||||
logger.info(
|
||||
"ebook_ingest_file_complete source_id=%s path=%s chapters=%s chunks=%s",
|
||||
source.id,
|
||||
resolved_path,
|
||||
len(parsed.chapters),
|
||||
chunk_index,
|
||||
)
|
||||
return True
|
||||
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.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:
|
||||
|
||||
@@ -12,12 +12,16 @@ from python.orm.richie.contact import (
|
||||
RelationshipType,
|
||||
)
|
||||
from python.orm.richie.ebook import (
|
||||
EbookCandidatePhrase,
|
||||
EbookChapter,
|
||||
EbookChunk,
|
||||
EbookChunkEmbedding1024,
|
||||
EbookChunkEmbedding2560,
|
||||
EbookChunkEmbedding4096,
|
||||
EbookChunkPhraseMention,
|
||||
EbookEmbeddingModel,
|
||||
EbookPhraseAlias,
|
||||
EbookProtectedPhrase,
|
||||
EbookSource,
|
||||
)
|
||||
|
||||
@@ -28,12 +32,16 @@ __all__ = [
|
||||
"Contact",
|
||||
"ContactNeed",
|
||||
"ContactRelationship",
|
||||
"EbookCandidatePhrase",
|
||||
"EbookChapter",
|
||||
"EbookChunk",
|
||||
"EbookChunkEmbedding1024",
|
||||
"EbookChunkEmbedding2560",
|
||||
"EbookChunkEmbedding4096",
|
||||
"EbookChunkPhraseMention",
|
||||
"EbookEmbeddingModel",
|
||||
"EbookPhraseAlias",
|
||||
"EbookProtectedPhrase",
|
||||
"EbookSource",
|
||||
"Need",
|
||||
"RelationshipType",
|
||||
|
||||
+108
-2
@@ -5,11 +5,23 @@ from __future__ import annotations
|
||||
from datetime import datetime
|
||||
|
||||
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 python.orm.richie.base import TableBase, TableBaseBig
|
||||
|
||||
JSON_DOCUMENT = JSON().with_variant(JSONB, "postgresql")
|
||||
|
||||
|
||||
class EbookSource(TableBase):
|
||||
"""One indexed EPUB file."""
|
||||
@@ -94,7 +106,7 @@ class EbookEmbeddingModel(TableBase):
|
||||
|
||||
name: Mapped[str] = mapped_column(String, unique=True)
|
||||
dimension: Mapped[int]
|
||||
is_default: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
is_default: Mapped[bool] = mapped_column(default=False)
|
||||
|
||||
|
||||
class EbookChunkEmbedding1024(TableBaseBig):
|
||||
@@ -136,3 +148,97 @@ class EbookChunkEmbedding4096(TableBaseBig):
|
||||
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"))
|
||||
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]
|
||||
|
||||
@@ -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)
|
||||
@@ -9,8 +9,7 @@ from socket import gethostname
|
||||
|
||||
import typer
|
||||
|
||||
from python.common import configure_logger
|
||||
from python.signal_alert import signal_alert
|
||||
from python.common import configure_logger, signal_alert
|
||||
from python.system_tests.components import systemd_tests, zpool_tests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -12,8 +12,7 @@ from re import search
|
||||
|
||||
import typer
|
||||
|
||||
from python.common import configure_logger, utcnow
|
||||
from python.signal_alert import signal_alert
|
||||
from python.common import configure_logger, signal_alert, utcnow
|
||||
from python.zfs import Dataset, get_datasets
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
gps_location = "!include ${./home_assistant/gps_location.yaml}";
|
||||
heater = "!include ${./home_assistant/heater.yaml}";
|
||||
van_weather = "!include ${./home_assistant/van_weather_template.yaml}";
|
||||
status_indicator = "!include ${./home_assistant/status_indicator.yaml}";
|
||||
};
|
||||
};
|
||||
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 %}
|
||||
@@ -22,11 +22,6 @@
|
||||
];
|
||||
};
|
||||
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 = {
|
||||
@@ -51,20 +46,6 @@
|
||||
};
|
||||
};
|
||||
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" = {
|
||||
matchConfig.Name = "enp97s0f1";
|
||||
address = [ "192.168.99.14/24" ];
|
||||
|
||||
@@ -16,20 +16,6 @@ in
|
||||
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 = {
|
||||
requires = [ "network-online.target" ];
|
||||
after = [ "network-online.target" ];
|
||||
|
||||
@@ -37,23 +37,6 @@ frontend ContentSwitching
|
||||
acl host_gitea hdr(host) -i gitea.tmmworkshop.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) ---
|
||||
# Trusted devices exempt from rate limiting (add one line per IP/CIDR).
|
||||
# Internal / reserved-for-private-use ranges:
|
||||
@@ -64,25 +47,42 @@ frontend ContentSwitching
|
||||
# Add specific public devices below as needed:
|
||||
# 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
|
||||
# is rate-limited; all other vhosts are left alone.
|
||||
# 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)
|
||||
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.
|
||||
# Kept loose (50/10s) since logged-in users are rate-limited too; a page
|
||||
# load can burst a few dozen asset requests.
|
||||
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
|
||||
acl over_rate_limit sc_http_req_rate(0) gt 10
|
||||
http-request deny deny_status 429 if over_rate_limit host_gitea !is_acme !rate_limit_allowlist !gitea_logged_in
|
||||
|
||||
# --- 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 ---
|
||||
# Tracked in a separate 5-minute table (st_compare) since a proxy has only one
|
||||
# inline stick-table. Allow-listed (internal) IPs are exempt.
|
||||
# Matches /<owner>/<repo>/compare on every repo; -i because Gitea routes are
|
||||
# case-insensitive.
|
||||
acl is_gitea_compare path_reg -i ^/[^/]+/[^/]+/compare
|
||||
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 }
|
||||
acl is_gitea_compare path_beg /Richie/dotfiles/compare
|
||||
http-request track-sc1 src table st_compare if host_gitea is_gitea_compare !rate_limit_allowlist !gitea_logged_in
|
||||
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 }
|
||||
|
||||
# Hosts allowed to serve plain HTTP (add entries to skip the HTTPS redirect)
|
||||
acl allow_http hdr(host) -i __none__
|
||||
@@ -117,9 +117,11 @@ backend cache_nodes
|
||||
server server 127.0.0.1:5000
|
||||
|
||||
backend jellyfin
|
||||
mode http
|
||||
option forwardfor
|
||||
server jellyfin 127.0.0.1:8096
|
||||
option httpchk
|
||||
option forwardfor
|
||||
http-check send meth GET uri /health
|
||||
http-check expect string Healthy
|
||||
server jellyfin 127.0.0.1:8096
|
||||
|
||||
backend share_nodes
|
||||
mode http
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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
@@ -2,7 +2,15 @@
|
||||
|
||||
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:
|
||||
@@ -10,6 +18,34 @@ def test_utcnow() -> None:
|
||||
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:
|
||||
"""test_test_bash_wrapper."""
|
||||
stdout, returncode = bash_wrapper("echo test")
|
||||
|
||||
@@ -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")
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
imports = [
|
||||
./direnv.nix
|
||||
./git.nix
|
||||
./zsh.nix
|
||||
];
|
||||
|
||||
programs.starship.enable = true;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
|
||||
programs.direnv = {
|
||||
enable = true;
|
||||
enableZshIntegration = true;
|
||||
nix-direnv.enable = true;
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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='";
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
];
|
||||
}
|
||||
@@ -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";
|
||||
};
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
imports = [
|
||||
../home/global.nix
|
||||
../home/gui
|
||||
];
|
||||
}
|
||||
@@ -61,6 +61,5 @@
|
||||
nix-tree
|
||||
nixfmt
|
||||
treefmt
|
||||
codebase-memory-mcp
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
imports = [
|
||||
../home/global.nix
|
||||
../home/gui
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
imports = [
|
||||
../home/global.nix
|
||||
];
|
||||
}
|
||||
+19
-15
@@ -1,8 +1,13 @@
|
||||
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]
|
||||
elise_password: ENC[AES256_GCM,data:bhTYGphCy528LuTmwNzA8fxmVa9opA2UomtPwV6prfxtE5k154G/AcK+AomoXAk/Mur/CjlyTeT0ohRoe17/T1iGFZSpYzPMbQ==,iv:1H1MlUPsKOtKrtDpqOL50/1agGrMMbb/5l08vl4WDdE=,tag:kA03EciqKE+iy+N9C2ii+w==,type:str]
|
||||
sops:
|
||||
age:
|
||||
- enc: |
|
||||
- recipient: age1u8zj599elqqvcmhxn8zuwrufsz8w8w366d3ayrljjejljt2q45kq8mxw9c
|
||||
enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBObHhkaFlnaG4zaTZtbkIw
|
||||
TkxSQnMxbDNwUVo4R1VYRDNKVFRDUE9kb0ZjCnpWWElJUVNuNFBsMzZod1ZQY0Fa
|
||||
@@ -10,8 +15,8 @@ sops:
|
||||
cXUzVmFxTUVIOWZVR2Fpa2crdWsrdlkKwdGLfbKWc25qfBKyd/cawiUWv9iepKHN
|
||||
EOp/LdH2GbCfnQSVbxi28ukLHxWqOLdqMm8xSni/Of2PXvMnpdyCyQ==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age1u8zj599elqqvcmhxn8zuwrufsz8w8w366d3ayrljjejljt2q45kq8mxw9c
|
||||
- enc: |
|
||||
- recipient: age1q47vup0tjhulkg7d6xwmdsgrw64h4ax3la3evzqpxyy4adsmk9fs56qz3y
|
||||
enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB6R0djMTArVmFySE5DMnFr
|
||||
WFdBUERFbE1HRVVFd0oyaXJ2eU5HUStBUFN3CnR3ckZ2bkpGZFFScHQwTlBZYTMv
|
||||
@@ -19,8 +24,8 @@ sops:
|
||||
eVlwQWgxSG5SdmFrWTlOcFo5eXZONWMKgx4huoSnbkRq0wQbsYgsWUKDTxDGNvYR
|
||||
anVMQg+c7PwDlk1V4JQZ4WrYLx63Ep5qDjGlN/Ssf2Vo6rAuuKetcA==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age1q47vup0tjhulkg7d6xwmdsgrw64h4ax3la3evzqpxyy4adsmk9fs56qz3y
|
||||
- enc: |
|
||||
- recipient: age1jhf7vm0005j60mjq63696frrmjhpy8kpc2d66mw044lqap5mjv4snmwvwm
|
||||
enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA2QjZYejFYbCswQjdmaDA0
|
||||
L2ZqUUhtYU12YlpISmxueHQzRG5YL0tQNXh3CndGamMwRzYvUzkvaE9DVnMwTkNC
|
||||
@@ -28,8 +33,8 @@ sops:
|
||||
VW5yeFlvWUZ5MVpNZHA5M1VXR1hxU1kKqii08/MB2aabgP4RQs1ry8AxmFqB8Mn+
|
||||
m7B0u64aziKXLSl0u471wqgD+YGRwNcajXT2pHCy8QWLznzvIMSrxA==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age1jhf7vm0005j60mjq63696frrmjhpy8kpc2d66mw044lqap5mjv4snmwvwm
|
||||
- enc: |
|
||||
- recipient: age13lmqgc3jvkyah5e3vcwmj4s5wsc2akctcga0lpc0x8v8du3fxprqp4ldkv
|
||||
enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBrSUc3VnllVDFZTm1jRnlP
|
||||
ZDBqelhkeHliZ1VlcjVnblQyeFlWclZTWkZjCjhJQk5EWkVoQjdoMHg4Zko4OU1C
|
||||
@@ -37,8 +42,8 @@ sops:
|
||||
cGJ3NDBLem9FNUpnbStYRTlqQStHV2sKwxPe4nTULsU0mVeUh8mhr2KX9U0iT5dL
|
||||
zvHldoQG6mZHgtHK6XI5AQJYf+zUW66OKqNSxAnn+BM20QkAQVZNVw==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age13lmqgc3jvkyah5e3vcwmj4s5wsc2akctcga0lpc0x8v8du3fxprqp4ldkv
|
||||
- enc: |
|
||||
- recipient: age1l272y8udvg60z7edgje42fu49uwt4x2gxn5zvywssnv9h2krms8s094m4k
|
||||
enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB0NU9ac2FuRHI1dkQycmc0
|
||||
YlhGK29UeTdiZEZXcWtPUW4rMis4Z2NWYWpJCkVldEdMc3ZTaDFidHpaZk5mM283
|
||||
@@ -46,8 +51,8 @@ sops:
|
||||
L2NObzZadlJ5d3MyeGRqKy95L3BOMFEKtoswi6r2TmCZzngUkiGQV5TTsuzisMFS
|
||||
5QI0aQZwhexqUMvbPuajYKvcPj+D6a2xaxbL3TBRLjOrFmcp5J7/YA==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age1l272y8udvg60z7edgje42fu49uwt4x2gxn5zvywssnv9h2krms8s094m4k
|
||||
- enc: |
|
||||
- recipient: age1ufnewppysaq2wwcl4ugngjz8pfzc5a35yg7luq0qmuqvctajcycs5lf6k4
|
||||
enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSByUlVjM2xpc281bHZzOGVo
|
||||
L3VEclhJZDd5SS9mazFiTk9DcmxMaWxPT213ClNPWERKQU03OWk0OEVIY05ib2VG
|
||||
@@ -55,8 +60,7 @@ sops:
|
||||
TmMvWVpobnl0eXBIOGQwMW5BSlhJTUkKzua1artJWbZlKfzv27xfZJeBpntBYwUf
|
||||
c8i1gNlvRwkhFAlrWcKR65vgyxsO3rbkLJRkcwG/q4hHj9zBeC/K2A==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age1ufnewppysaq2wwcl4ugngjz8pfzc5a35yg7luq0qmuqvctajcycs5lf6k4
|
||||
lastmodified: "2026-06-29T20:19:44Z"
|
||||
mac: ENC[AES256_GCM,data:GIvQxWt4tZGn0fyiXVtxGFQQoNcFUgilF+/PSz50exVrmzsS0XQUk/TIDFHaQR9jlJI50jqlyc1rBHgjnqC2oPHhPWaaVhgF18vQI55rGKdymNFjsHnaCkblFVdR1RJm0FSB2Ri6y5k8tfN3ywiwromJRz4NYzr1hbmr36azfg4=,iv:V3jspeYt/d2wy13gUrQmPGARm0hxwvSL/mocJAUofdw=,tag:vARoUzBWTJKkONDGoQdzNQ==,type:str]
|
||||
lastmodified: "2025-08-24T22:36:28Z"
|
||||
mac: ENC[AES256_GCM,data:gtY2M4+BGBRJFzuRURjJypTTbjhn+pVJoKy2REa4a/hSpn7Rnp2Nk3t0/DNYKIquGS7gFxYpXQnUyhBHlAfXqnQWu5InE2b6iLG6INdzeyPI4dGfJaop8ZxXTCKNy3kLgW9kkjBbS1uQlHvy9y/2J+QjjjHPw7M4Fh+E9XKwDGk=,iv:OoCyzLvP6iVwrU2xmK/7ov4h0QqrWk+XbGDiUJ68kJo=,tag:iYVF12f7iHm/dkWFTIsO8Q==,type:str]
|
||||
unencrypted_suffix: _unencrypted
|
||||
version: 3.13.1
|
||||
version: 3.10.2
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
home.packages = with pkgs; [
|
||||
discord-canary
|
||||
signal-desktop
|
||||
slack
|
||||
zoom-us
|
||||
];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user