taskai-cli 1.7.1__tar.gz → 1.8.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {taskai_cli-1.7.1 → taskai_cli-1.8.0}/PKG-INFO +1 -1
- {taskai_cli-1.7.1 → taskai_cli-1.8.0}/pyproject.toml +1 -1
- {taskai_cli-1.7.1 → taskai_cli-1.8.0}/taskai/browser.py +51 -0
- {taskai_cli-1.7.1 → taskai_cli-1.8.0}/taskai/cli.py +38 -3
- taskai_cli-1.8.0/taskai/filters.py +150 -0
- {taskai_cli-1.7.1 → taskai_cli-1.8.0}/taskai/help_menu.py +4 -0
- {taskai_cli-1.7.1 → taskai_cli-1.8.0}/taskai/static/console.js +9 -0
- {taskai_cli-1.7.1 → taskai_cli-1.8.0}/taskai/views.py +15 -5
- {taskai_cli-1.7.1 → taskai_cli-1.8.0}/.github/workflows/publish-to-pypi.yml +0 -0
- {taskai_cli-1.7.1 → taskai_cli-1.8.0}/.gitignore +0 -0
- {taskai_cli-1.7.1 → taskai_cli-1.8.0}/.readthedocs.yaml +0 -0
- {taskai_cli-1.7.1 → taskai_cli-1.8.0}/DEVLOG.md +0 -0
- {taskai_cli-1.7.1 → taskai_cli-1.8.0}/DEVPLAN.md +0 -0
- {taskai_cli-1.7.1 → taskai_cli-1.8.0}/README.md +0 -0
- {taskai_cli-1.7.1 → taskai_cli-1.8.0}/TRIAGE.md +0 -0
- {taskai_cli-1.7.1 → taskai_cli-1.8.0}/docs/.gitignore +0 -0
- {taskai_cli-1.7.1 → taskai_cli-1.8.0}/docs/_static/.gitkeep +0 -0
- {taskai_cli-1.7.1 → taskai_cli-1.8.0}/docs/ai-integration.md +0 -0
- {taskai_cli-1.7.1 → taskai_cli-1.8.0}/docs/browser-mode.md +0 -0
- {taskai_cli-1.7.1 → taskai_cli-1.8.0}/docs/commands.md +0 -0
- {taskai_cli-1.7.1 → taskai_cli-1.8.0}/docs/conf.py +0 -0
- {taskai_cli-1.7.1 → taskai_cli-1.8.0}/docs/configuration.md +0 -0
- {taskai_cli-1.7.1 → taskai_cli-1.8.0}/docs/getting-started.md +0 -0
- {taskai_cli-1.7.1 → taskai_cli-1.8.0}/docs/index.md +0 -0
- {taskai_cli-1.7.1 → taskai_cli-1.8.0}/docs/interactive-mode.md +0 -0
- {taskai_cli-1.7.1 → taskai_cli-1.8.0}/docs/item-data.md +0 -0
- {taskai_cli-1.7.1 → taskai_cli-1.8.0}/docs/managing-the-tree.md +0 -0
- {taskai_cli-1.7.1 → taskai_cli-1.8.0}/docs/requirements.txt +0 -0
- {taskai_cli-1.7.1 → taskai_cli-1.8.0}/scripts/bump_version.py +0 -0
- {taskai_cli-1.7.1 → taskai_cli-1.8.0}/taskai/config.py +0 -0
- {taskai_cli-1.7.1 → taskai_cli-1.8.0}/taskai/errors.py +0 -0
- {taskai_cli-1.7.1 → taskai_cli-1.8.0}/taskai/json_dir_database.py +0 -0
- {taskai_cli-1.7.1 → taskai_cli-1.8.0}/taskai/llm_models.py +0 -0
- {taskai_cli-1.7.1 → taskai_cli-1.8.0}/taskai/models.py +0 -0
- {taskai_cli-1.7.1 → taskai_cli-1.8.0}/taskai/services/ai.py +0 -0
- {taskai_cli-1.7.1 → taskai_cli-1.8.0}/taskai/services/pomodoro.py +0 -0
- {taskai_cli-1.7.1 → taskai_cli-1.8.0}/taskai/services/repair_database.py +0 -0
- {taskai_cli-1.7.1 → taskai_cli-1.8.0}/taskai/services/user_setup.py +0 -0
- {taskai_cli-1.7.1 → taskai_cli-1.8.0}/taskai/static/canvas.js +0 -0
- {taskai_cli-1.7.1 → taskai_cli-1.8.0}/taskai/static/editpanel.js +0 -0
- {taskai_cli-1.7.1 → taskai_cli-1.8.0}/taskai/static/index.html +0 -0
- {taskai_cli-1.7.1 → taskai_cli-1.8.0}/taskai/static/shortcuts.js +0 -0
- {taskai_cli-1.7.1 → taskai_cli-1.8.0}/taskai/static/style.css +0 -0
- {taskai_cli-1.7.1 → taskai_cli-1.8.0}/test/test_cli.py +0 -0
- {taskai_cli-1.7.1 → taskai_cli-1.8.0}/test/test_execution.py +0 -0
- {taskai_cli-1.7.1 → taskai_cli-1.8.0}/test/test_json_dir_database.py +0 -0
- {taskai_cli-1.7.1 → taskai_cli-1.8.0}/test/test_view.py +0 -0
|
@@ -9,6 +9,13 @@ from pydantic import BaseModel
|
|
|
9
9
|
|
|
10
10
|
from taskai.cli import Controller, _parse_arg_string, _parse_remaining, db, execute_commands
|
|
11
11
|
from taskai.errors import TaskCLIError
|
|
12
|
+
from taskai.filters import (
|
|
13
|
+
FilterError,
|
|
14
|
+
looks_like_filters,
|
|
15
|
+
match_items,
|
|
16
|
+
parse_filters,
|
|
17
|
+
with_ancestors,
|
|
18
|
+
)
|
|
12
19
|
|
|
13
20
|
|
|
14
21
|
app = FastAPI()
|
|
@@ -49,6 +56,23 @@ def _full_tree():
|
|
|
49
56
|
return tree
|
|
50
57
|
|
|
51
58
|
|
|
59
|
+
def _filtered_tree(keep):
|
|
60
|
+
"""Same shape as _full_tree(), but only the ids in `keep`, with
|
|
61
|
+
child_ids / linked_ids narrowed to `keep` too so the frontend never
|
|
62
|
+
walks into a pruned-out node."""
|
|
63
|
+
tree = {}
|
|
64
|
+
for item_id in db.get_item_ids():
|
|
65
|
+
if item_id not in keep:
|
|
66
|
+
continue
|
|
67
|
+
item = db.get_item(item_id)
|
|
68
|
+
dump = item.model_dump(mode="json")
|
|
69
|
+
dump["child_ids"] = [c for c in dump["child_ids"] if c in keep]
|
|
70
|
+
dump["linked_ids"] = [l for l in dump["linked_ids"] if l in keep]
|
|
71
|
+
dump["comments"] = _resolve_comments(item)
|
|
72
|
+
tree[item_id] = dump
|
|
73
|
+
return tree
|
|
74
|
+
|
|
75
|
+
|
|
52
76
|
@app.get("/api/tree")
|
|
53
77
|
def get_tree():
|
|
54
78
|
db.flush() # reload from disk in case another process (e.g. the CLI) wrote since we last connected
|
|
@@ -83,6 +107,11 @@ def run_command(request: CommandRequest):
|
|
|
83
107
|
def _run_show(args):
|
|
84
108
|
"""`show` is read-only from the browser's perspective: it never mutates
|
|
85
109
|
the db, it just tells the frontend which node to focus/center on."""
|
|
110
|
+
show_args = list(args[1:])
|
|
111
|
+
|
|
112
|
+
if looks_like_filters(show_args):
|
|
113
|
+
return _run_filter(show_args)
|
|
114
|
+
|
|
86
115
|
target = args[1] if len(args) > 1 else "all"
|
|
87
116
|
|
|
88
117
|
if target == "all":
|
|
@@ -100,3 +129,25 @@ def _run_show(args):
|
|
|
100
129
|
}
|
|
101
130
|
|
|
102
131
|
return {"output": "", "tree": _full_tree(), "focus": str(item.id)}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _run_filter(show_args):
|
|
135
|
+
"""`show attr<op>value ...` — return a pruned tree (matches + their parent
|
|
136
|
+
chains) plus a `filtered` flag so the frontend fits the view to it."""
|
|
137
|
+
try:
|
|
138
|
+
exprs = parse_filters(show_args)
|
|
139
|
+
except FilterError as e:
|
|
140
|
+
return {"output": str(e), "tree": _full_tree(), "focus": None}
|
|
141
|
+
|
|
142
|
+
matched = match_items(db, exprs)
|
|
143
|
+
if not matched:
|
|
144
|
+
return {"output": "No items match those filters", "tree": _full_tree(), "focus": None}
|
|
145
|
+
|
|
146
|
+
keep = with_ancestors(db, matched)
|
|
147
|
+
plural = "s" if len(matched) != 1 else ""
|
|
148
|
+
return {
|
|
149
|
+
"output": f"{len(matched)} item{plural} matched",
|
|
150
|
+
"tree": _filtered_tree(keep),
|
|
151
|
+
"focus": None,
|
|
152
|
+
"filtered": True,
|
|
153
|
+
}
|
|
@@ -11,6 +11,13 @@ import getpass
|
|
|
11
11
|
# local
|
|
12
12
|
from taskai.json_dir_database import JsonDirectoryDatabase
|
|
13
13
|
from taskai.views import view_lists, view_item
|
|
14
|
+
from taskai.filters import (
|
|
15
|
+
FilterError,
|
|
16
|
+
looks_like_filters,
|
|
17
|
+
match_items,
|
|
18
|
+
parse_filters,
|
|
19
|
+
with_ancestors,
|
|
20
|
+
)
|
|
14
21
|
from taskai.models import TodoItem, CLIConfig
|
|
15
22
|
from taskai.services.ai import ai_headstart_service, ai_natural_language_service
|
|
16
23
|
from taskai.services.user_setup import user_setup_service
|
|
@@ -135,6 +142,29 @@ class Controller:
|
|
|
135
142
|
return
|
|
136
143
|
view_item(db, item.id, **kwargs)
|
|
137
144
|
|
|
145
|
+
def show_filtered(tokens: list[str], **kwargs):
|
|
146
|
+
"""`task show attr<op>value ...` - render only the items matching all
|
|
147
|
+
the given filters, each with its parent chain for context."""
|
|
148
|
+
try:
|
|
149
|
+
exprs = parse_filters(tokens)
|
|
150
|
+
except FilterError as e:
|
|
151
|
+
Controller.throw_error(str(e))
|
|
152
|
+
return
|
|
153
|
+
|
|
154
|
+
matched = match_items(db, exprs)
|
|
155
|
+
if not matched:
|
|
156
|
+
print("No items match those filters")
|
|
157
|
+
return
|
|
158
|
+
|
|
159
|
+
display = with_ancestors(db, matched)
|
|
160
|
+
roots = [r for r in Controller._get_root_ids() if r in display]
|
|
161
|
+
view_lists(
|
|
162
|
+
db, roots,
|
|
163
|
+
show_done=kwargs.get("show_done", True),
|
|
164
|
+
only_ids=display,
|
|
165
|
+
)
|
|
166
|
+
print(f"\n{len(matched)} item{'s' if len(matched) != 1 else ''} matched")
|
|
167
|
+
|
|
138
168
|
def show_examples():
|
|
139
169
|
print(help_menu["examples"])
|
|
140
170
|
|
|
@@ -411,9 +441,14 @@ def execute_commands(*args, **kwargs) -> int:
|
|
|
411
441
|
Controller.run_setup_service()
|
|
412
442
|
|
|
413
443
|
case "show":
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
444
|
+
show_args = list(args[1:])
|
|
445
|
+
if looks_like_filters(show_args):
|
|
446
|
+
Controller.show_filtered(show_args, **kwargs)
|
|
447
|
+
else:
|
|
448
|
+
target = args[1] if len(args) > 1 else "all"
|
|
449
|
+
match target:
|
|
450
|
+
case "all": Controller.show_all(*args[2:], **kwargs)
|
|
451
|
+
case _: Controller.show_item(target, **kwargs)
|
|
417
452
|
|
|
418
453
|
case "create":
|
|
419
454
|
Controller.create_item(args[1], **kwargs)
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Attribute filters for `task show`.
|
|
3
|
+
|
|
4
|
+
task show status="IN PROGRESS" priority>=2 due_by<12-31-2026
|
|
5
|
+
|
|
6
|
+
Each argument is one filter, `attr<op>value`, with no spaces around the
|
|
7
|
+
operator (a quoted value with spaces still arrives as a single token). Every
|
|
8
|
+
filter must pass for an item to match - the list is AND-ed. Text matches (`=`
|
|
9
|
+
on a string attribute) use fnmatch, so `name=Write*` works; the ordering
|
|
10
|
+
operators compare numbers, dates, or strings as appropriate.
|
|
11
|
+
|
|
12
|
+
`match_items` returns the ids that match; `with_ancestors` pads that set out
|
|
13
|
+
with every matched item's parent chain, so the result renders as a coherent
|
|
14
|
+
tree rather than a scatter of disconnected nodes.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import fnmatch
|
|
18
|
+
import re
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from datetime import datetime
|
|
21
|
+
|
|
22
|
+
from taskai.json_dir_database import DatabaseError
|
|
23
|
+
|
|
24
|
+
# attr -> how both sides of the comparison should be read
|
|
25
|
+
_INT_ATTRS = {"id", "parent_id", "priority"}
|
|
26
|
+
_DATE_ATTRS = {"due_by", "created_on"}
|
|
27
|
+
_BOOL_ATTRS = {"completed"}
|
|
28
|
+
_STR_ATTRS = {"name", "description", "status"}
|
|
29
|
+
|
|
30
|
+
FILTERABLE_ATTRS = _INT_ATTRS | _DATE_ATTRS | _BOOL_ATTRS | _STR_ATTRS
|
|
31
|
+
|
|
32
|
+
_DATE_FORMAT = "%m-%d-%Y" # matches the CLI's --due_by format
|
|
33
|
+
|
|
34
|
+
# two-char operators first so `>=` isn't read as `>` then `=`
|
|
35
|
+
_FILTER_RE = re.compile(
|
|
36
|
+
r"^(?P<attr>[A-Za-z_][A-Za-z0-9_]*)(?P<op>>=|<=|=|>|<)(?P<value>.*)$",
|
|
37
|
+
re.DOTALL,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class FilterError(ValueError):
|
|
42
|
+
"""A filter argument that doesn't parse, or whose value is unusable."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _split_token(token: str):
|
|
46
|
+
"""(attr, op, raw_value) if `token` is structurally `attr<op>...` with a
|
|
47
|
+
known attribute, else None. Purely structural - no value coercion."""
|
|
48
|
+
match = _FILTER_RE.match(token)
|
|
49
|
+
if match is None or match.group("attr") not in FILTERABLE_ATTRS:
|
|
50
|
+
return None
|
|
51
|
+
return match.group("attr"), match.group("op"), match.group("value")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _coerce(attr: str, raw: str):
|
|
55
|
+
if attr in _INT_ATTRS:
|
|
56
|
+
return int(raw)
|
|
57
|
+
if attr in _BOOL_ATTRS:
|
|
58
|
+
return str(raw).strip().lower() in ("true", "1", "yes")
|
|
59
|
+
if attr in _DATE_ATTRS:
|
|
60
|
+
return datetime.strptime(raw.strip(), _DATE_FORMAT).date()
|
|
61
|
+
return raw # string: compared with fnmatch (=) or lexically (< >)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class FilterExpr:
|
|
66
|
+
attr: str
|
|
67
|
+
op: str
|
|
68
|
+
value: object
|
|
69
|
+
|
|
70
|
+
def matches(self, item) -> bool:
|
|
71
|
+
return _compare(self.op, _item_value(item, self.attr), self.value)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _item_value(item, attr):
|
|
75
|
+
value = getattr(item, attr, None)
|
|
76
|
+
if attr in _DATE_ATTRS and value is not None:
|
|
77
|
+
return value.date() # drop the time component for date comparisons
|
|
78
|
+
return value
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _compare(op: str, actual, expected) -> bool:
|
|
82
|
+
if op == "=":
|
|
83
|
+
if isinstance(expected, str):
|
|
84
|
+
return fnmatch.fnmatch("" if actual is None else str(actual), expected)
|
|
85
|
+
return actual == expected
|
|
86
|
+
if actual is None:
|
|
87
|
+
return False # None has no ordering
|
|
88
|
+
try:
|
|
89
|
+
if op == ">":
|
|
90
|
+
return actual > expected
|
|
91
|
+
if op == "<":
|
|
92
|
+
return actual < expected
|
|
93
|
+
if op == ">=":
|
|
94
|
+
return actual >= expected
|
|
95
|
+
if op == "<=":
|
|
96
|
+
return actual <= expected
|
|
97
|
+
except TypeError:
|
|
98
|
+
return False
|
|
99
|
+
return False
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def looks_like_filters(tokens) -> bool:
|
|
103
|
+
"""True when `show`'s arguments should be treated as a filter query rather
|
|
104
|
+
than a single id / name target."""
|
|
105
|
+
return bool(tokens) and any(_split_token(t) is not None for t in tokens)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def parse_filters(tokens) -> list[FilterExpr]:
|
|
109
|
+
"""All tokens -> FilterExprs. Raises FilterError on the first token that
|
|
110
|
+
isn't a valid `attr<op>value`."""
|
|
111
|
+
exprs = []
|
|
112
|
+
for token in tokens:
|
|
113
|
+
parts = _split_token(token)
|
|
114
|
+
if parts is None:
|
|
115
|
+
raise FilterError(
|
|
116
|
+
f"'{token}' is not a valid filter - expected attr=value, "
|
|
117
|
+
f"attr>value, etc. (attributes: {', '.join(sorted(FILTERABLE_ATTRS))})"
|
|
118
|
+
)
|
|
119
|
+
attr, op, raw = parts
|
|
120
|
+
try:
|
|
121
|
+
value = _coerce(attr, raw)
|
|
122
|
+
except (ValueError, TypeError):
|
|
123
|
+
raise FilterError(f"'{raw}' is not a valid value for '{attr}'")
|
|
124
|
+
exprs.append(FilterExpr(attr, op, value))
|
|
125
|
+
return exprs
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def match_items(db, exprs) -> set:
|
|
129
|
+
"""Ids of the items that satisfy every filter."""
|
|
130
|
+
matched = set()
|
|
131
|
+
for item_id in db.get_item_ids():
|
|
132
|
+
item = db.get_item(item_id)
|
|
133
|
+
if all(expr.matches(item) for expr in exprs):
|
|
134
|
+
matched.add(item_id)
|
|
135
|
+
return matched
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def with_ancestors(db, ids) -> set:
|
|
139
|
+
"""`ids` plus every ancestor of every id, so the selection is a set of
|
|
140
|
+
complete root-to-node paths."""
|
|
141
|
+
keep = set()
|
|
142
|
+
for id_ in ids:
|
|
143
|
+
cursor = id_
|
|
144
|
+
while cursor is not None and cursor not in keep:
|
|
145
|
+
keep.add(cursor)
|
|
146
|
+
try:
|
|
147
|
+
cursor = db.get_item_attr(cursor, "parent_id")
|
|
148
|
+
except DatabaseError:
|
|
149
|
+
break
|
|
150
|
+
return keep
|
|
@@ -12,6 +12,10 @@ Viewing:
|
|
|
12
12
|
'task show all' --> show every item, with ids prepended, as a tree
|
|
13
13
|
'task show {id}' --> show full details for the item with that id
|
|
14
14
|
'task show {name|fnmatch pattern}' --> match an item by name (or glob pattern) and show it
|
|
15
|
+
'task show {attr}{op}{value} ...' --> show only items matching every filter, each with its parent chain for context
|
|
16
|
+
ops: = > < >= <= ; text uses = with fnmatch (name=Write*), the rest compare numbers/dates
|
|
17
|
+
no spaces around the operator; quote each expression so your shell doesn't treat > < as redirects
|
|
18
|
+
e.g. task show 'status=IN PROGRESS' 'priority>=2' 'due_by<12-31-2026'
|
|
15
19
|
|
|
16
20
|
Creating:
|
|
17
21
|
'task create {name} {--field value ...}' --> create a new top-level (root) item - use this when there's no existing parent to attach to
|
|
@@ -151,6 +151,15 @@ consoleInput.addEventListener("keydown", async (e) => {
|
|
|
151
151
|
|
|
152
152
|
applyTree(data.tree);
|
|
153
153
|
|
|
154
|
+
// `show <attr><op><value> ...` — server returns a pruned tree + a flag
|
|
155
|
+
if (data.filtered) {
|
|
156
|
+
selectedNode = rootNode;
|
|
157
|
+
if (typeof onNodeSelected === "function") onNodeSelected(null);
|
|
158
|
+
fitAll();
|
|
159
|
+
if (data.output) appendLine(data.output);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
154
163
|
if (data.focus) {
|
|
155
164
|
const node = nodes.find(n => n.id === String(data.focus));
|
|
156
165
|
if (node) {
|
|
@@ -35,14 +35,20 @@ VALID_ATTRS = {
|
|
|
35
35
|
@config("DISPLAY_STRING", "display_str")
|
|
36
36
|
@config("DISPLAY_COLORS", "display_colors")
|
|
37
37
|
def view_lists(
|
|
38
|
-
db: JsonDirectoryDatabase,
|
|
39
|
-
roots: list[int],
|
|
38
|
+
db: JsonDirectoryDatabase,
|
|
39
|
+
roots: list[int],
|
|
40
40
|
show_done=True,
|
|
41
41
|
max_level=1000,
|
|
42
|
-
|
|
42
|
+
only_ids: set[int] | None = None,
|
|
43
|
+
display_str: str = "id name status",
|
|
43
44
|
display_colors: str = None
|
|
44
45
|
):
|
|
45
|
-
"""Shows all the lists
|
|
46
|
+
"""Shows all the lists.
|
|
47
|
+
|
|
48
|
+
`only_ids`, when given, prunes the walk to just those ids - used by
|
|
49
|
+
`task show <filters>` to render matched items with their parent chain
|
|
50
|
+
and nothing else.
|
|
51
|
+
"""
|
|
46
52
|
|
|
47
53
|
attrs = display_str.lower().split(" ")
|
|
48
54
|
|
|
@@ -77,16 +83,20 @@ def view_lists(
|
|
|
77
83
|
def _recursive_print(item_id: int, level: int):
|
|
78
84
|
if level >= max_level:
|
|
79
85
|
return
|
|
86
|
+
if only_ids is not None and item_id not in only_ids:
|
|
87
|
+
return
|
|
80
88
|
|
|
81
89
|
try:
|
|
82
90
|
item = db.get_item(item_id)
|
|
83
91
|
except DatabaseError:
|
|
84
92
|
return
|
|
85
|
-
|
|
93
|
+
|
|
86
94
|
_print_item(item, level)
|
|
87
95
|
|
|
88
96
|
|
|
89
97
|
for linked_id in item.linked_ids:
|
|
98
|
+
if only_ids is not None and linked_id not in only_ids:
|
|
99
|
+
continue
|
|
90
100
|
try:
|
|
91
101
|
linked_item = db.get_item(linked_id)
|
|
92
102
|
except DatabaseError:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|