Skip to content

Commit b783a14

Browse files
authored
Merge pull request #40 from gcomneno/feature/default-inbox-fallback
Default fallback_folder to documents/inbox
2 parents 0500b0d + 7fb0f83 commit b783a14

12 files changed

Lines changed: 67 additions & 42 deletions

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ Example output:
6464

6565
~~~text
6666
photo.jpg -> organized/images/photo.jpg
67-
notes.txt -> organized/documents/notes.txt
67+
notes.txt -> organized/documents/inbox/notes.txt
6868
script.py -> organized/code/script.py
6969
~~~
7070

@@ -215,7 +215,7 @@ Example JSON output:
215215
},
216216
{
217217
"source": "notes.txt",
218-
"destination": "organized/documents/notes.txt",
218+
"destination": "organized/documents/inbox/notes.txt",
219219
"category": "documents"
220220
}
221221
]
@@ -256,7 +256,7 @@ Configured rules **extend** the built-in semantic rules; they do not replace the
256256

257257
When a keyword from either built-in or configured rules matches the file path or inspected document text, the matching folder is used as the destination subfolder.
258258

259-
Use `fallback_folder` to send unmatched documents to a dedicated inbox instead of the generic `documents/` folder:
259+
Use `fallback_folder` to send unmatched documents to a dedicated inbox. It defaults to `documents/inbox`:
260260

261261
~~~toml
262262
fallback_folder = "documents/inbox"

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "smart-file-organizer"
3-
version = "0.3.2"
3+
version = "0.3.3"
44
description = "A safe Python CLI for planning and applying file organization workflows."
55
readme = "README.md"
66
authors = [

smart-file-organizer.example.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
#
33
# Keep real/private rules outside Git. Use this file only as a public template.
44

5-
# Optional inbox for documents that do not match any semantic rule.
6-
# fallback_folder = "documents/inbox"
5+
# Inbox for documents that do not match any semantic rule.
6+
fallback_folder = "documents/inbox"
77

88
[[semantic_rules]]
99
folder = "documents/demo-utility"

src/smart_file_organizer/cli.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@
77
from pathlib import Path
88

99
from smart_file_organizer.app_logging import configure_logging
10-
from smart_file_organizer.config import OrganizerConfig, load_config
10+
from smart_file_organizer.config import (
11+
DEFAULT_FALLBACK_FOLDER,
12+
OrganizerConfig,
13+
load_config,
14+
)
1115
from smart_file_organizer.content_planning import (
1216
build_organization_plan_inspecting_content,
1317
)
@@ -218,7 +222,9 @@ def main(argv: Sequence[str] | None = None) -> None:
218222
parser.error(str(error))
219223

220224
semantic_rules = semantic_rules_from_config(config)
221-
fallback_folder = config.fallback_folder if config is not None else None
225+
fallback_folder = (
226+
config.fallback_folder if config is not None else DEFAULT_FALLBACK_FOLDER
227+
)
222228

223229
if args.inspect_content:
224230
plan = build_organization_plan_inspecting_content(

src/smart_file_organizer/config.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@
1010
from smart_file_organizer.errors import ConfigError
1111

1212

13+
DEFAULT_FALLBACK_FOLDER = "documents/inbox"
14+
15+
1316
@dataclass(frozen=True)
1417
class SemanticRule:
1518
"""A configurable semantic destination rule."""
@@ -24,7 +27,7 @@ class OrganizerConfig:
2427
"""Application configuration loaded from a TOML file."""
2528

2629
semantic_rules: tuple[SemanticRule, ...] = ()
27-
fallback_folder: str | None = None
30+
fallback_folder: str = DEFAULT_FALLBACK_FOLDER
2831

2932

3033
def load_config(path: Path) -> OrganizerConfig:
@@ -47,12 +50,9 @@ def parse_config(data: Mapping[str, Any]) -> OrganizerConfig:
4750
)
4851

4952

50-
def _parse_fallback_folder(data: Mapping[str, Any]) -> str | None:
51-
"""Parse optional fallback folder from raw TOML data."""
52-
fallback_folder = data.get("fallback_folder")
53-
54-
if fallback_folder is None:
55-
return None
53+
def _parse_fallback_folder(data: Mapping[str, Any]) -> str:
54+
"""Parse fallback folder from raw TOML data."""
55+
fallback_folder = data.get("fallback_folder", DEFAULT_FALLBACK_FOLDER)
5656

5757
if not isinstance(fallback_folder, str) or not fallback_folder.strip():
5858
raise ConfigError("fallback_folder must be a non-empty string")

src/smart_file_organizer/content_planning.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from collections.abc import Callable, Iterable
44
from pathlib import Path
55

6+
from smart_file_organizer.config import DEFAULT_FALLBACK_FOLDER
67
from smart_file_organizer.document_text import extract_document_text
78
from smart_file_organizer.core import (
89
PlannedMove,
@@ -19,7 +20,7 @@ def build_organization_plan_with_extracted_text(
1920
*,
2021
extract_text: DocumentTextExtractor,
2122
semantic_rules: Iterable[SemanticFolderRule] | None = None,
22-
fallback_folder: str | None = None,
23+
fallback_folder: str | None = DEFAULT_FALLBACK_FOLDER,
2324
) -> list[PlannedMove]:
2425
"""Build move plans using document text returned by an injected extractor."""
2526
source_list = list(sources)
@@ -39,7 +40,7 @@ def build_organization_plan_inspecting_content(
3940
target_root: Path,
4041
*,
4142
semantic_rules: Iterable[SemanticFolderRule] | None = None,
42-
fallback_folder: str | None = None,
43+
fallback_folder: str | None = DEFAULT_FALLBACK_FOLDER,
4344
verbose: bool = False,
4445
) -> list[PlannedMove]:
4546
"""Build move plans using the default document text extractor."""

src/smart_file_organizer/planning.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
DestinationExistsError,
1111
SourceMissingError,
1212
)
13+
from smart_file_organizer.config import DEFAULT_FALLBACK_FOLDER
1314
from smart_file_organizer.models import PlannedMove, SemanticFolderRule
1415
from smart_file_organizer.semantic_rules import infer_destination_folder
1516

@@ -19,7 +20,7 @@ def plan_file(
1920
target_root: Path,
2021
*,
2122
semantic_rules: Iterable[SemanticFolderRule] | None = None,
22-
fallback_folder: str | None = None,
23+
fallback_folder: str | None = DEFAULT_FALLBACK_FOLDER,
2324
) -> PlannedMove:
2425
"""Build a move plan for a single file without touching the filesystem."""
2526
return plan_file_with_document_text(
@@ -37,7 +38,7 @@ def plan_file_with_document_text(
3738
document_text: str,
3839
*,
3940
semantic_rules: Iterable[SemanticFolderRule] | None = None,
40-
fallback_folder: str | None = None,
41+
fallback_folder: str | None = DEFAULT_FALLBACK_FOLDER,
4142
) -> PlannedMove:
4243
"""Build a move plan using caller-provided document text."""
4344
category = classify_path(source)
@@ -64,7 +65,7 @@ def build_organization_plan(
6465
target_root: Path,
6566
*,
6667
semantic_rules: Iterable[SemanticFolderRule] | None = None,
67-
fallback_folder: str | None = None,
68+
fallback_folder: str | None = DEFAULT_FALLBACK_FOLDER,
6869
) -> list[PlannedMove]:
6970
"""Build move plans for multiple files without touching the filesystem."""
7071
return [
@@ -84,7 +85,7 @@ def build_organization_plan_with_document_texts(
8485
document_texts: Mapping[Path, str],
8586
*,
8687
semantic_rules: Iterable[SemanticFolderRule] | None = None,
87-
fallback_folder: str | None = None,
88+
fallback_folder: str | None = DEFAULT_FALLBACK_FOLDER,
8889
) -> list[PlannedMove]:
8990
"""Build move plans using caller-provided document text."""
9091
return [

src/smart_file_organizer/semantic_rules.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from typing import Literal
88

99
from smart_file_organizer.classification import classify_path
10+
from smart_file_organizer.config import DEFAULT_FALLBACK_FOLDER
1011
from smart_file_organizer.models import (
1112
FileCategory,
1213
SemanticFolderRule,
@@ -334,10 +335,10 @@ def _match_semantic_folder(
334335
def _default_category_folder(
335336
category: FileCategory,
336337
*,
337-
fallback_folder: str | None = None,
338+
fallback_folder: str | None = DEFAULT_FALLBACK_FOLDER,
338339
) -> Path:
339340
"""Return the default folder when no semantic rule matches."""
340-
if category == FileCategory.DOCUMENTS and fallback_folder:
341+
if category == FileCategory.DOCUMENTS and fallback_folder is not None:
341342
return Path(fallback_folder)
342343

343344
return Path(category.value)
@@ -348,7 +349,7 @@ def infer_destination_folder(
348349
*,
349350
document_text: str = "",
350351
semantic_rules: Iterable[SemanticFolderRule] | None = None,
351-
fallback_folder: str | None = None,
352+
fallback_folder: str | None = DEFAULT_FALLBACK_FOLDER,
352353
) -> Path:
353354
"""Infer a semantic destination folder for a path."""
354355
rules = _normalize_semantic_rules(semantic_rules)

tests/test_cli.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ def test_main_prints_organization_plan_from_explicit_sources(capsys) -> None:
101101

102102
assert captured.out == (
103103
"photo.jpg -> organized/images/photo.jpg\n"
104-
"notes.txt -> organized/documents/notes.txt\n"
104+
"notes.txt -> organized/documents/inbox/notes.txt\n"
105105
"script.py -> organized/code/script.py\n"
106106
)
107107

@@ -129,7 +129,7 @@ def test_main_prints_organization_plan_as_json(capsys) -> None:
129129
" },\n"
130130
" {\n"
131131
' "source": "notes.txt",\n'
132-
' "destination": "organized/documents/notes.txt",\n'
132+
' "destination": "organized/documents/inbox/notes.txt",\n'
133133
' "category": "documents"\n'
134134
" }\n"
135135
"]\n"
@@ -188,7 +188,7 @@ def test_main_prints_organization_plan_from_directory(
188188
captured = capsys.readouterr()
189189

190190
assert captured.out == (
191-
f"{notes} -> organized/documents/notes.txt\n"
191+
f"{notes} -> organized/documents/inbox/notes.txt\n"
192192
f"{photo} -> organized/images/photo.jpg\n"
193193
)
194194

@@ -220,8 +220,8 @@ def test_main_recursive_scan_includes_nested_files(
220220
captured = capsys.readouterr()
221221

222222
assert captured.out == (
223-
f"{nested_file} -> organized/documents/ignored.txt\n"
224-
f"{notes} -> organized/documents/notes.txt\n"
223+
f"{nested_file} -> organized/documents/inbox/ignored.txt\n"
224+
f"{notes} -> organized/documents/inbox/notes.txt\n"
225225
f"{photo} -> organized/images/photo.jpg\n"
226226
)
227227

@@ -421,7 +421,7 @@ def test_main_applies_organization_plan(
421421
assert not photo.exists()
422422
assert not notes.exists()
423423
assert (target_root / "images" / "photo.jpg").read_text() == "fake image"
424-
assert (target_root / "documents" / "notes.txt").read_text() == "hello"
424+
assert (target_root / "documents" / "inbox" / "notes.txt").read_text() == "hello"
425425

426426

427427
def test_main_apply_reports_execution_errors(

tests/test_config.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from smart_file_organizer.errors import ConfigError
66
from smart_file_organizer.config import (
7+
DEFAULT_FALLBACK_FOLDER,
78
OrganizerConfig,
89
SemanticRule,
910
load_config,
@@ -12,7 +13,7 @@
1213

1314

1415
def test_parse_config_returns_empty_config_for_empty_data() -> None:
15-
assert parse_config({}) == OrganizerConfig()
16+
assert parse_config({}) == OrganizerConfig(fallback_folder="documents/inbox")
1617

1718

1819
def test_parse_config_reads_semantic_rules() -> None:
@@ -122,6 +123,21 @@ def test_parse_config_reads_fallback_folder() -> None:
122123
)
123124

124125

126+
def test_parse_config_uses_default_fallback_folder_without_explicit_value() -> None:
127+
config = parse_config(
128+
{
129+
"semantic_rules": [
130+
{
131+
"folder": "documents/demo-utility",
132+
"keywords": ["demo utility"],
133+
},
134+
],
135+
}
136+
)
137+
138+
assert config.fallback_folder == DEFAULT_FALLBACK_FOLDER
139+
140+
125141
def test_load_config_reads_fallback_folder_from_toml(tmp_path: Path) -> None:
126142
config_file = tmp_path / "smart-file-organizer.toml"
127143
config_file.write_text(

0 commit comments

Comments
 (0)