Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bfad5d7df7 | ||
|
|
cf62f38a3d | ||
|
|
d619bd8f16 | ||
|
|
aa7dfa4ea4 | ||
|
|
b995e9b6cb |
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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": {
|
||||||
|
|||||||
@@ -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; };
|
||||||
|
};
|
||||||
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+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
|
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)
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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,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
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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]
|
||||||
|
|||||||
@@ -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
|
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__)
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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" ];
|
||||||
|
|||||||
@@ -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" ];
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 __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")
|
||||||
|
|||||||
@@ -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
|
nix-tree
|
||||||
nixfmt
|
nixfmt
|
||||||
treefmt
|
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]
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user