Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions nnote/commands/backup.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,24 @@
@click.command()
@click.argument("output_path", required=False, default=None)
@click.option("-d", "--directory", default=None, help="Subdirectory to back up")
@click.option("--include-config", is_flag=True, default=False, help="Include config file in backup")
@click.option("--dry-run", is_flag=True, default=False, help="List files without creating archive")
@click.option(
"--include-config",
is_flag=True,
default=False,
help="Include config file in backup",
)
@click.option(
"--dry-run", is_flag=True, default=False, help="List files without creating archive"
)
@click.option("--quiet", is_flag=True, default=False, help="Suppress output")
def backup(output_path, directory, include_config, dry_run, quiet):
"""Back up notes to a tar.gz archive."""
config = Config.load()

if config.notes_dir is None:
raise click.ClickException("Notes directory not configured. Run `nnote init` first.")
raise click.ClickException(
"Notes directory not configured. Run `nnote init` first."
)

root = config.notes_dir / directory if directory else config.notes_dir

Expand Down
4 changes: 3 additions & 1 deletion nnote/commands/drop.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ def drop(title, directory):
config = Config.load()

if config.notes_dir is None:
raise click.ClickException("Notes directory not configured. Run `nnote init` first.")
raise click.ClickException(
"Notes directory not configured. Run `nnote init` first."
)

if title is None and directory is None:
raise click.UsageError("Provide a note title, a directory (-d), or both.")
Expand Down
4 changes: 3 additions & 1 deletion nnote/commands/list.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ def list_notes(directory):
config = Config.load()

if config.notes_dir is None:
raise click.ClickException("Notes directory not configured. Run `nnote init` first.")
raise click.ClickException(
"Notes directory not configured. Run `nnote init` first."
)

root = config.notes_dir / directory if directory else config.notes_dir

Expand Down
12 changes: 9 additions & 3 deletions nnote/commands/move.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,23 @@ def move(title, dest_title, directory, dest_dir):
config = Config.load()

if config.notes_dir is None:
raise click.ClickException("Notes directory not configured. Run `nnote init` first.")
raise click.ClickException(
"Notes directory not configured. Run `nnote init` first."
)

if dest_title is None and dest_dir is None:
raise click.UsageError("Provide a new title and/or a destination directory (--dest-dir).")
raise click.UsageError(
"Provide a new title and/or a destination directory (--dest-dir)."
)

src = resolve_note_path(config, title, directory)
if not src.exists():
raise click.ClickException(f"Note not found: {src}")

new_title = dest_title if dest_title is not None else title
dst = resolve_note_path(config, new_title, dest_dir if dest_dir is not None else directory)
dst = resolve_note_path(
config, new_title, dest_dir if dest_dir is not None else directory
)

if dst == src:
raise click.ClickException("Source and destination are the same.")
Expand Down
4 changes: 3 additions & 1 deletion nnote/commands/new.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ def new(title, directory):
if directory is None:
raise click.UsageError("Provide a title or a directory with -d.")
if config.notes_dir is None:
raise click.ClickException("Notes directory not configured. Run `nnote init` first.")
raise click.ClickException(
"Notes directory not configured. Run `nnote init` first."
)
(config.notes_dir / directory).mkdir(parents=True, exist_ok=True)
return
note_path = resolve_note_path(config, title, directory)
Expand Down
8 changes: 6 additions & 2 deletions nnote/commands/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ def search(query, directory):
config = Config.load()

if config.notes_dir is None:
raise click.ClickException("Notes directory not configured. Run `nnote init` first.")
raise click.ClickException(
"Notes directory not configured. Run `nnote init` first."
)

root = config.notes_dir / directory if directory else config.notes_dir

Expand All @@ -29,4 +31,6 @@ def search(query, directory):
tag = click.style(" [title]", fg="green") if result.title_match else ""
click.echo(f"{label}{tag}")
for lineno, line in result.matching_lines:
click.echo(f" {click.style(str(lineno), dim=True)}: {highlight(line, query)}")
click.echo(
f" {click.style(str(lineno), dim=True)}: {highlight(line, query)}"
)
4 changes: 3 additions & 1 deletion nnote/notes.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@

def resolve_note_path(config: Config, title: str, directory: str | None) -> Path:
if config.notes_dir is None:
raise click.ClickException("Notes directory not configured. Run `nnote init` first.")
raise click.ClickException(
"Notes directory not configured. Run `nnote init` first."
)
base = config.notes_dir / directory if directory else config.notes_dir
return base / title

Expand Down
36 changes: 20 additions & 16 deletions nnote/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@
from pathlib import Path


_TITLE_EXACT = 100
_TITLE_PREFIX = 70
_TITLE_SUBSTR = 50
_FUZZY_THRESHOLD = 0.6
_FUZZY_WEIGHT = 40
_CONTENT_LINE_SCORE = 10
_CONTENT_MAX_LINES = 3
_TITLE_EXACT = 100
_TITLE_PREFIX = 70
_TITLE_SUBSTR = 50
_FUZZY_THRESHOLD = 0.6
_FUZZY_WEIGHT = 40
_CONTENT_LINE_SCORE = 10
_CONTENT_MAX_LINES = 3


@dataclass
Expand Down Expand Up @@ -47,7 +47,9 @@ def search_notes(root: Path, query: str) -> list[SearchResult]:

matching_lines: list[tuple[int, str]] = []
try:
for lineno, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
for lineno, line in enumerate(
path.read_text(encoding="utf-8").splitlines(), 1
):
if query.lower() in line.lower():
matching_lines.append((lineno, line.strip()))
except (UnicodeDecodeError, OSError):
Expand All @@ -60,12 +62,14 @@ def search_notes(root: Path, query: str) -> list[SearchResult]:
total = title_score + content_score

if total > 0:
results.append(SearchResult(
rel_path=rel,
score=total,
title_match=title_score > 0,
matching_lines=matching_lines[:_CONTENT_MAX_LINES],
))
results.append(
SearchResult(
rel_path=rel,
score=total,
title_match=title_score > 0,
matching_lines=matching_lines[:_CONTENT_MAX_LINES],
)
)

results.sort(key=lambda r: r.score, reverse=True)
return results
Expand All @@ -76,8 +80,8 @@ def highlight(text: str, query: str) -> str:
q = query.lower()
out, i = "", 0
while i < len(text):
if lower[i:i + len(q)] == q:
out += click.style(text[i:i + len(q)], bold=True, fg="yellow")
if lower[i : i + len(q)] == q:
out += click.style(text[i : i + len(q)], bold=True, fg="yellow")
i += len(q)
else:
out += text[i]
Expand Down
5 changes: 5 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,8 @@ docs = [

[tool.uv.build-backend]
module-root = ""

[dependency-groups]
dev = [
"ruff>=0.15.12",
]
20 changes: 16 additions & 4 deletions tests/test_backup.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ def invoke(cfg, *args):

# ── happy paths ────────────────────────────────────────────────────────────────


def test_backup_creates_archive(env, tmp_path):
cfg, notes_dir = env
(notes_dir / "note1").write_text("hello")
Expand Down Expand Up @@ -67,21 +68,27 @@ def test_backup_include_config(env, tmp_path):
fake_config.write_text("notes_dir: /tmp/notes\n")

out = tmp_path / "backup.tar.gz"
with patch("nnote.commands.backup.Config") as MockConfig, \
patch("nnote.commands.backup.Path") as MockPath:
with (
patch("nnote.commands.backup.Config") as MockConfig,
patch("nnote.commands.backup.Path") as MockPath,
):
MockConfig.load.return_value = cfg
# Patch the config path construction inside backup
real_path = __import__("pathlib").Path

def path_side_effect(*args):
if args == ("~/.config/nnote/config.yaml",):
p = real_path(fake_config)
p_expanded = real_path(fake_config)

# return a mock that .expanduser() returns real fake_config path
class FakeConfigPath:
def expanduser(self):
return real_path(fake_config)

return FakeConfigPath()
return real_path(*args)

MockPath.side_effect = path_side_effect
MockPath.cwd = real_path.cwd

Expand Down Expand Up @@ -127,17 +134,21 @@ def test_backup_default_filename_uses_today(env, tmp_path):
today = date.today().isoformat()
expected_name = f"nnote-backup-{today}.tar.gz"

with patch("nnote.commands.backup.Config") as MockConfig, \
patch("nnote.commands.backup.Path") as MockPath:
with (
patch("nnote.commands.backup.Config") as MockConfig,
patch("nnote.commands.backup.Path") as MockPath,
):
MockConfig.load.return_value = cfg
real_path = __import__("pathlib").Path
created_paths = []

def path_side_effect(*args):
if args == ("~/.config/nnote/config.yaml",):

class FakeConfigPath:
def expanduser(self):
return real_path("/nonexistent/config.yaml")

return FakeConfigPath()
p = real_path(*args)
created_paths.append(p)
Expand Down Expand Up @@ -166,6 +177,7 @@ def test_backup_output_message(env, tmp_path):

# ── error paths ────────────────────────────────────────────────────────────────


def test_error_notes_dir_not_configured(tmp_path):
cfg = Config(tmp_path / "config.yaml", {})
r = invoke(cfg, str(tmp_path / "out.tar.gz"))
Expand Down
2 changes: 2 additions & 0 deletions tests/test_move.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ def invoke(cfg, *args):

# ── happy paths ────────────────────────────────────────────────────────────────


def test_rename_note(env):
cfg, notes_dir = env
(notes_dir / "old").write_text("content")
Expand Down Expand Up @@ -76,6 +77,7 @@ def test_dest_dir_created_if_missing(env):

# ── error paths ────────────────────────────────────────────────────────────────


def test_error_source_not_found(env):
cfg, _ = env
r = invoke(cfg, "ghost", "new")
Expand Down
3 changes: 3 additions & 0 deletions tests/test_search.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

# --- scoring unit tests ---


def test_score_exact_match():
assert _score_title("todo", "todo") == 100

Expand Down Expand Up @@ -32,6 +33,7 @@ def test_score_case_insensitive():

# --- search_notes integration tests ---


def test_finds_exact_title_match(tmp_path):
(tmp_path / "todo").write_text("buy milk")
results = search_notes(tmp_path, "todo")
Expand Down Expand Up @@ -85,6 +87,7 @@ def test_skips_unreadable_files(tmp_path):

# --- highlight tests ---


def test_highlight_wraps_match():
result = highlight("hello world", "world")
assert "world" in result
Expand Down
35 changes: 34 additions & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.