Actions
This page shows the specifics of each action. For basic action usage and options have a
look at the Rules section.
confirm
Ask for confirmation before continuing.
Source code in organize/actions/confirm.py
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34 | @dataclass(config=ConfigDict(coerce_numbers_to_str=True, extra="forbid"))
class Confirm:
"""Ask for confirmation before continuing."""
msg: str = "Continue?"
default: bool = True
action_config: ClassVar[ActionConfig] = ActionConfig(
name="confirm",
standalone=True,
files=True,
dirs=True,
)
def __post_init__(self):
self._msg = Template.from_string(self.msg)
def pipeline(self, res: Resource, output: Output, simulate: bool):
msg = render(self._msg, res.dict())
result = output.confirm(res=res, msg=msg, sender=self, default=self.default)
if not result:
raise StopIteration("Aborted")
|
Examples
Confirm before deleting a duplicate
rules:
- name: "Delete duplicates with confirmation"
locations:
- ~/Downloads
- ~/Documents
filters:
- not empty
- duplicate
- name
actions:
- confirm: "Delete {name}?"
- trash
copy
Copy a file or dir to a new location.
If the specified path does not exist it will be created.
Attributes: |
-
dest
(str )
–
The destination where the file / dir should be copied to.
If dest ends with a slash, it is assumed to be a target directory
and the file / dir will be copied into dest and keep its name.
-
on_conflict
(str )
–
What should happen in case dest already exists.
One of skip , overwrite , trash , rename_new and rename_existing .
Defaults to rename_new .
-
rename_template
(str )
–
A template for renaming the file / dir in case of a conflict.
Defaults to {name} {counter}{extension} .
-
autodetect_folder
(bool )
–
In case you forget the ending slash "/" to indicate copying into a folder
this settings will handle targets without a file extension as folders.
If you really mean to copy to a file without file extension, set this to
false.
Defaults to True.
-
continue_with
(str) = "copy" | "original" )
–
Continue the next action either with the path to the copy or the path the
original.
Defaults to "copy".
|
The next action will work with the created copy.
Source code in organize/actions/copy.py
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106 | @dataclass(config=ConfigDict(coerce_numbers_to_str=True, extra="forbid"))
class Copy:
"""Copy a file or dir to a new location.
If the specified path does not exist it will be created.
Attributes:
dest (str):
The destination where the file / dir should be copied to.
If `dest` ends with a slash, it is assumed to be a target directory
and the file / dir will be copied into `dest` and keep its name.
on_conflict (str):
What should happen in case **dest** already exists.
One of `skip`, `overwrite`, `trash`, `rename_new` and `rename_existing`.
Defaults to `rename_new`.
rename_template (str):
A template for renaming the file / dir in case of a conflict.
Defaults to `{name} {counter}{extension}`.
autodetect_folder (bool):
In case you forget the ending slash "/" to indicate copying into a folder
this settings will handle targets without a file extension as folders.
If you really mean to copy to a file without file extension, set this to
false.
Defaults to True.
continue_with (str) = "copy" | "original":
Continue the next action either with the path to the copy or the path the
original.
Defaults to "copy".
The next action will work with the created copy.
"""
dest: str
on_conflict: ConflictMode = "rename_new"
rename_template: str = "{name} {counter}{extension}"
autodetect_folder: bool = True
continue_with: Literal["copy", "original"] = "copy"
action_config: ClassVar[ActionConfig] = ActionConfig(
name="copy",
standalone=False,
files=True,
dirs=True,
)
def __post_init__(self):
self._dest = Template.from_string(self.dest)
self._rename_template = Template.from_string(self.rename_template)
def pipeline(self, res: Resource, output: Output, simulate: bool):
assert res.path is not None, "Does not support standalone mode"
rendered = render(self._dest, res.dict())
# fully resolve the destination for folder targets and prepare the folder
# structure
dst = prepare_target_path(
src_name=res.path.name,
dst=rendered,
autodetect_folder=self.autodetect_folder,
simulate=simulate,
)
# Resolve conflicts before copying the file to the destination
skip_action, dst = resolve_conflict(
dst=dst,
res=res,
conflict_mode=self.on_conflict,
rename_template=self._rename_template,
simulate=simulate,
output=output,
)
if skip_action:
return
output.msg(res=res, msg=f"Copy to {dst}", sender=self)
res.walker_skip_pathes.add(dst)
if not simulate:
if res.is_dir():
shutil.copytree(src=res.path, dst=dst)
else:
shutil.copy2(src=res.path, dst=dst)
# continue with either the original path or the path to the copy
if self.continue_with == "copy":
res.path = dst
|
Examples:
Copy all pdfs into ~/Desktop/somefolder/
and keep filenames
rules:
- locations: ~/Desktop
filters:
- extension: pdf
actions:
- copy: "~/Desktop/somefolder/"
Use a placeholder to copy all .pdf files into a "PDF" folder and all .jpg files into a "JPG" folder. Existing files will be overwritten.
rules:
- locations: ~/Desktop
filters:
- extension:
- pdf
- jpg
actions:
- copy:
dest: "~/Desktop/{extension.upper()}/"
on_conflict: overwrite
Copy into the folder Invoices
. Keep the filename but do not overwrite existing files.
To prevent overwriting files, an index is added to the filename, so somefile.jpg
becomes somefile 2.jpg
.
The counter separator is ' '
by default, but can be changed using the counter_separator
property.
rules:
- locations: ~/Desktop/Invoices
filters:
- extension:
- pdf
actions:
- copy:
dest: "~/Documents/Invoices/"
on_conflict: "rename_new"
rename_template: "{name} {counter}{extension}"
delete
Delete a file from disk.
Deleted files have no recovery option!
Using the Trash
action is strongly advised for most use-cases!
Source code in organize/actions/delete.py
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47 | @dataclass(config=ConfigDict(extra="forbid"))
class Delete:
"""
Delete a file from disk.
Deleted files have no recovery option!
Using the `Trash` action is strongly advised for most use-cases!
"""
action_config: ClassVar[ActionConfig] = ActionConfig(
name="delete",
standalone=False,
files=True,
dirs=True,
)
def pipeline(self, res: Resource, output: Output, simulate: bool):
assert res.path is not None, "Does not support standalone mode"
output.msg(res=res, msg=f"Deleting {res.path}", sender=self)
if not simulate:
delete(res.path)
res.path = None
|
Examples:
Delete old downloads.
rules:
- locations: "~/Downloads"
filters:
- lastmodified:
days: 365
- extension:
- png
- jpg
actions:
- delete
Delete all empty subfolders
rules:
- name: Delete all empty subfolders
locations:
- path: "~/Downloads"
max_depth: null
targets: dirs
filters:
- empty
actions:
- delete
echo
Prints the given message.
This can be useful to test your rules, especially in combination with placeholder
variables.
Attributes: |
-
msg
(str )
–
The message to print. Accepts placeholder variables.
|
Source code in organize/actions/echo.py
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37 | @dataclass(config=ConfigDict(extra="forbid"))
class Echo:
"""Prints the given message.
This can be useful to test your rules, especially in combination with placeholder
variables.
Attributes:
msg (str): The message to print. Accepts placeholder variables.
"""
msg: str = ""
action_config: ClassVar[ActionConfig] = ActionConfig(
name="echo",
standalone=True,
files=True,
dirs=True,
)
def __post_init__(self):
self._msg_templ = Template.from_string(self.msg)
def pipeline(self, res: Resource, output: Output, simulate: bool):
full_msg = render(self._msg_templ, res.dict())
output.msg(res, full_msg, sender=self)
|
Examples:
rules:
- name: "Find files older than a year"
locations: ~/Desktop
filters:
- lastmodified:
days: 365
actions:
- echo: "Found old file"
Prints "Hello World!" and filepath for each file on the desktop:
rules:
- locations:
- ~/Desktop
actions:
- echo: "Hello World! {path}"
This will print something like Found a ZIP: "backup"
for each file on your desktop
rules:
- locations:
- ~/Desktop
filters:
- extension
- name
actions:
- echo: 'Found a {extension.upper()}: "{name}"'
Show the {relative_path}
and {path}
of all files in '~/Downloads', '~/Desktop' and their subfolders:
rules:
- locations:
- path: ~/Desktop
max_depth: null
- path: ~/Downloads
max_depth: null
actions:
- echo: "Path: {path}"
- echo: "Relative: {relative_path}"
hardlink
Create a hardlink.
Attributes: |
-
dest
(str )
–
The hardlink destination. If dest ends with a slash `/``, create the
hardlink in the given directory. Can contain placeholders.
-
on_conflict
(str )
–
What should happen in case dest already exists.
One of skip , overwrite , trash , rename_new and rename_existing .
Defaults to rename_new .
-
rename_template
(str )
–
A template for renaming the file / dir in case of a conflict.
Defaults to {name} {counter}{extension} .
-
autodetect_folder
(bool )
–
In case you forget the ending slash "/" to indicate copying into a folder
this settings will handle targets without a file extension as folders.
If you really mean to copy to a file without file extension, set this to
false.
Default: true
|
Source code in organize/actions/hardlink.py
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89 | @dataclass(config=ConfigDict(coerce_numbers_to_str=True, extra="forbid"))
class Hardlink:
"""Create a hardlink.
Attributes:
dest (str):
The hardlink destination. If **dest** ends with a slash `/``, create the
hardlink in the given directory. Can contain placeholders.
on_conflict (str):
What should happen in case **dest** already exists.
One of `skip`, `overwrite`, `trash`, `rename_new` and `rename_existing`.
Defaults to `rename_new`.
rename_template (str):
A template for renaming the file / dir in case of a conflict.
Defaults to `{name} {counter}{extension}`.
autodetect_folder (bool):
In case you forget the ending slash "/" to indicate copying into a folder
this settings will handle targets without a file extension as folders.
If you really mean to copy to a file without file extension, set this to
false.
Default: true
"""
dest: str
on_conflict: ConflictMode = "rename_new"
rename_template: str = "{name} {counter}{extension}"
autodetect_folder: bool = True
action_config: ClassVar[ActionConfig] = ActionConfig(
name="hardlink",
standalone=False,
files=True,
dirs=True,
)
def __post_init__(self):
self._dest = Template.from_string(self.dest)
self._rename_template = Template.from_string(self.rename_template)
def pipeline(self, res: Resource, output: Output, simulate: bool):
assert res.path is not None, "Does not support standalone mode"
rendered = render(self._dest, res.dict())
dst = prepare_target_path(
src_name=res.path.name,
dst=rendered,
autodetect_folder=self.autodetect_folder,
simulate=simulate,
)
skip_action, dst = resolve_conflict(
dst=dst,
res=res,
conflict_mode=self.on_conflict,
rename_template=self._rename_template,
simulate=simulate,
output=output,
)
if skip_action:
return
output.msg(res=res, msg=f"Creating hardlink at {dst}", sender=self)
if not simulate:
create_hardlink(target=res.path, link=dst)
res.walker_skip_pathes.add(dst)
|
Add macOS tags.
Attributes: |
-
*tags
(str )
–
A list of tags or a single tag.
|
The color can be specified in brackets after the tag name, for example:
macos_tags: "Invoices (red)"
Available colors are none
, gray
, green
, purple
, blue
, yellow
, red
and
orange
.
Source code in organize/actions/macos_tags.py
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78 | @dataclass(config=ConfigDict(coerce_numbers_to_str=True, extra="forbid"))
class MacOSTags:
"""Add macOS tags.
Attributes:
*tags (str): A list of tags or a single tag.
The color can be specified in brackets after the tag name, for example:
```yaml
macos_tags: "Invoices (red)"
```
Available colors are `none`, `gray`, `green`, `purple`, `blue`, `yellow`, `red` and
`orange`.
"""
tags: FlatList[str]
action_config: ClassVar[ActionConfig] = ActionConfig(
name="macos_tags",
standalone=False,
files=True,
dirs=True,
)
def __post_init__(self):
self._tags = [Template.from_string(tag) for tag in self.tags]
if sys.platform != "darwin":
raise EnvironmentError("The macos_tags action is only available on macOS")
def pipeline(self, res: Resource, output: Output, simulate: bool):
import macos_tags
COLORS = [c.name.lower() for c in macos_tags.Color]
for template in self._tags:
tag = render(template, res.dict())
name, color = self._parse_tag(tag)
if color not in COLORS:
raise ValueError(
"color %s is unknown. (Available: %s)" % (color, " / ".join(COLORS))
)
output.msg(
res=res,
sender=self,
msg=f'Adding tag: "{name}" (color: {color})',
)
if not simulate:
_tag = macos_tags.Tag(
name=name,
color=macos_tags.Color[color.upper()],
) # type: ignore
macos_tags.add(_tag, file=str(res.path))
def _parse_tag(self, s):
"""parse a tag definition and return a tuple (name, color)"""
result = sm.match("{name} ({color})", s)
if not result:
return s, "none"
return result["name"], result["color"].lower()
|
Examples:
rules:
- name: "add a single tag"
locations: "~/Documents/Invoices"
filters:
- name:
startswith: "Invoice"
- extension: pdf
actions:
- macos_tags: Invoice
Adding multiple tags ("Invoice" and "Important")
rules:
- locations: "~/Documents/Invoices"
filters:
- name:
startswith: "Invoice"
- extension: pdf
actions:
- macos_tags:
- Important
- Invoice
Specify tag colors
rules:
- locations: "~/Documents/Invoices"
filters:
- name:
startswith: "Invoice"
- extension: pdf
actions:
- macos_tags:
- Important (green)
- Invoice (purple)
Add a templated tag with color
rules:
- locations: "~/Documents/Invoices"
filters:
- created
actions:
- macos_tags:
- Year-{created.year} (red)
move
Move a file to a new location.
The file can also be renamed.
If the specified path does not exist it will be created.
If you only want to rename the file and keep the folder, it is
easier to use the rename
action.
Attributes: |
-
dest
(str )
–
The destination where the file / dir should be moved to.
If dest ends with a slash, it is assumed to be a target directory
and the file / dir will be moved into dest and keep its name.
-
on_conflict
(str )
–
What should happen in case dest already exists.
One of skip , overwrite , trash , rename_new and rename_existing .
Defaults to rename_new .
-
rename_template
(str )
–
A template for renaming the file / dir in case of a conflict.
Defaults to {name} {counter}{extension} .
-
autodetect_folder
(bool )
–
In case you forget the ending slash "/" to indicate moving into a folder
this settings will handle targets without a file extension as folders.
If you really mean to move to a file without file extension, set this to
false.
Default: True
|
The next action will work with the moved file / dir.
Source code in organize/actions/move.py
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100 | @dataclass(config=ConfigDict(coerce_numbers_to_str=True, extra="forbid"))
class Move:
"""Move a file to a new location.
The file can also be renamed.
If the specified path does not exist it will be created.
If you only want to rename the file and keep the folder, it is
easier to use the `rename` action.
Attributes:
dest (str):
The destination where the file / dir should be moved to.
If `dest` ends with a slash, it is assumed to be a target directory
and the file / dir will be moved into `dest` and keep its name.
on_conflict (str):
What should happen in case **dest** already exists.
One of `skip`, `overwrite`, `trash`, `rename_new` and `rename_existing`.
Defaults to `rename_new`.
rename_template (str):
A template for renaming the file / dir in case of a conflict.
Defaults to `{name} {counter}{extension}`.
autodetect_folder (bool):
In case you forget the ending slash "/" to indicate moving into a folder
this settings will handle targets without a file extension as folders.
If you really mean to move to a file without file extension, set this to
false.
Default: True
The next action will work with the moved file / dir.
"""
dest: str
on_conflict: ConflictMode = "rename_new"
rename_template: str = "{name} {counter}{extension}"
autodetect_folder: bool = True
action_config: ClassVar[ActionConfig] = ActionConfig(
name="move",
standalone=False,
files=True,
dirs=True,
)
def __post_init__(self):
self._dest = Template.from_string(self.dest)
self._rename_template = Template.from_string(self.rename_template)
def pipeline(self, res: Resource, output: Output, simulate: bool):
assert res.path is not None, "Does not support standalone mode"
rendered = render(self._dest, res.dict())
# fully resolve the destination for folder targets and prepare the folder
# structure
dst = prepare_target_path(
src_name=res.path.name,
dst=rendered,
autodetect_folder=self.autodetect_folder,
simulate=simulate,
)
# Resolve conflicts before moving the file to the destination
skip_action, dst = resolve_conflict(
dst=dst,
res=res,
conflict_mode=self.on_conflict,
rename_template=self._rename_template,
simulate=simulate,
output=output,
)
if skip_action:
return
output.msg(res=res, msg=f"Move to {dst}", sender=self)
res.walker_skip_pathes.add(dst)
if not simulate:
shutil.move(src=res.path, dst=dst)
# continue with the new path
res.path = dst
|
Examples:
Move all pdfs and jpgs from the desktop into the folder "~/Desktop/media/". Filenames are not changed.
rules:
- locations: ~/Desktop
filters:
- extension:
- pdf
- jpg
actions:
- move: "~/Desktop/media/"
Use a placeholder to move all .pdf files into a "PDF" folder and all .jpg files into a
"JPG" folder. Existing files will be overwritten.
rules:
- locations: ~/Desktop
filters:
- extension:
- pdf
- jpg
actions:
- move:
dest: "~/Desktop/{extension.upper()}/"
on_conflict: "overwrite"
Move pdfs into the folder Invoices
. Keep the filename but do not overwrite existing files. To prevent overwriting files, an index is added to the filename, so somefile.jpg
becomes somefile 2.jpg
.
rules:
- locations: ~/Desktop/Invoices
filters:
- extension:
- pdf
actions:
- move:
dest: "~/Documents/Invoices/"
on_conflict: "rename_new"
rename_template: "{name} {counter}{extension}"
python
Execute python code.
Attributes: |
-
code
(str )
–
The python code to execute.
-
run_in_simulation
(bool )
–
Whether to execute this code in simulation mode (Default false).
|
Variables of previous filters are available, but you have to use the normal python
dictionary syntax x = regex["my_group"]
.
Source code in organize/actions/python.py
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72 | @dataclass(config=ConfigDict(coerce_numbers_to_str=True, extra="forbid"))
class Python:
"""Execute python code.
Attributes:
code (str): The python code to execute.
run_in_simulation (bool):
Whether to execute this code in simulation mode (Default false).
Variables of previous filters are available, but you have to use the normal python
dictionary syntax `x = regex["my_group"]`.
"""
code: str
run_in_simulation: bool = False
action_config: ClassVar[ActionConfig] = ActionConfig(
name="python",
standalone=True,
files=True,
dirs=True,
)
def __post_init__(self):
self.code = textwrap.dedent(self.code)
def __usercode__(self, print, **kwargs) -> Optional[Dict]:
raise NotImplementedError()
def pipeline(self, res: Resource, output: Output, simulate: bool):
if simulate and not self.run_in_simulation:
output.msg(
res=res,
msg="** Code not run in simulation. **",
level="warn",
sender=self,
)
return
def _output_msg(*values, sep: str = " ", end: str = ""):
"""
the print function for the use code needs to print via the current output
"""
msg = f"{sep.join(str(x) for x in values)}{end}"
output.msg(res=res, msg=msg, sender=self)
# codegen the user function with arguments as available in the resource
kwargs = ", ".join(res.dict().keys())
func = f"def __userfunc(print, {kwargs}):\n"
func += textwrap.indent(self.code, " ")
func += "\n\nself.__usercode__ = __userfunc"
exec(func, globals().copy(), locals().copy())
result = self.__usercode__(print=_output_msg, **res.dict())
# deep merge the resulting dict
if not (result is None or isinstance(result, dict)):
raise ValueError("The python code must return None or a dict")
if isinstance(result, dict):
res.deep_merge(key=self.action_config.name, data=result)
|
Examples:
A basic example that shows how to get the current file path and do some printing in a
for loop. The |
is yaml syntax for defining a string literal spanning multiple lines.
rules:
- locations: "~/Desktop"
actions:
- python: |
print('The path of the current file is %s' % path)
for _ in range(5):
print('Heyho, its me from the loop')
rules:
- name: "You can access filter data"
locations: ~/Desktop
filters:
- regex: '^(?P<name>.*)\.(?P<extension>.*)$'
actions:
- python: |
print('Name: %s' % regex["name"])
print('Extension: %s' % regex["extension"])
Running in simulation and yaml aliases:
my_python_script: &script |
print("Hello World!")
print(path)
rules:
- name: "Run in simulation and yaml alias"
locations:
- ~/Desktop/
actions:
- python:
code: *script
run_in_simulation: yes
You have access to all the python magic -- do a google search for each
filename starting with an underscore:
rules:
- locations: ~/Desktop
filters:
- name:
startswith: "_"
actions:
- python: |
import webbrowser
webbrowser.open('https://www.google.com/search?q=%s' % name)
rename
Renames a file.
Attributes: |
-
new_name
(str )
–
The new name for the file / dir.
-
on_conflict
(str )
–
What should happen in case dest already exists.
One of skip , overwrite , trash , rename_new and rename_existing .
Defaults to rename_new .
-
rename_template
(str )
–
A template for renaming the file / dir in case of a conflict.
Defaults to {name} {counter}{extension} .
|
The next action will work with the renamed file / dir.
Source code in organize/actions/rename.py
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78 | @dataclass(config=ConfigDict(coerce_numbers_to_str=True, extra="forbid"))
class Rename:
"""Renames a file.
Attributes:
new_name (str):
The new name for the file / dir.
on_conflict (str):
What should happen in case **dest** already exists.
One of `skip`, `overwrite`, `trash`, `rename_new` and `rename_existing`.
Defaults to `rename_new`.
rename_template (str):
A template for renaming the file / dir in case of a conflict.
Defaults to `{name} {counter}{extension}`.
The next action will work with the renamed file / dir.
"""
new_name: str
on_conflict: ConflictMode = "rename_new"
rename_template: str = "{name} {counter}{extension}"
# TODO: keep_extension?
action_config: ClassVar[ActionConfig] = ActionConfig(
name="rename",
standalone=False,
files=True,
dirs=True,
)
def __post_init__(self):
self._new_name = Template.from_string(self.new_name)
self._rename_template = Template.from_string(self.rename_template)
def pipeline(self, res: Resource, output: Output, simulate: bool):
assert res.path is not None, "Does not support standalone mode"
new_name = render(self._new_name, res.dict())
if "/" in new_name:
raise ValueError(
"The new name cannot contain slashes. "
"To move files or folders use `move`."
)
dst = res.path.with_name(new_name)
skip_action, dst = resolve_conflict(
dst=dst,
res=res,
conflict_mode=self.on_conflict,
rename_template=self._rename_template,
simulate=simulate,
output=output,
)
if skip_action:
return
output.msg(res=res, msg=f"Renaming to {new_name}", sender=self)
if not simulate:
res.path.rename(dst)
res.path = dst
res.walker_skip_pathes.add(dst)
|
Examples:
rules:
- name: "Convert all .PDF file extensions to lowercase (.pdf)"
locations: "~/Desktop"
filters:
- name
- extension: PDF
actions:
- rename: "{name}.pdf"
rules:
- name: "Convert **all** file extensions to lowercase"
locations: "~/Desktop"
filters:
- name
- extension
actions:
- rename: "{name}.{extension.lower()}"
shell
Executes a shell command
Attributes: |
-
cmd
(str )
–
-
run_in_simulation
(bool )
–
Whether to execute in simulation mode (default = false)
-
ignore_errors
(bool )
–
Whether to continue on returncodes != 0.
-
simulation_output
(str )
–
The value of {shell.output} if run in simulation
-
simulation_returncode
(int )
–
The value of {shell.returncode} if run in simulation
|
Returns
{shell.output}
(str
): The stdout of the executed process.
{shell.returncode}
(int
): The returncode of the executed process.
Source code in organize/actions/shell.py
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84 | @dataclass(config=ConfigDict(coerce_numbers_to_str=True, extra="forbid"))
class Shell:
"""
Executes a shell command
Attributes:
cmd (str): The command to execute.
run_in_simulation (bool):
Whether to execute in simulation mode (default = false)
ignore_errors (bool):
Whether to continue on returncodes != 0.
simulation_output (str):
The value of `{shell.output}` if run in simulation
simulation_returncode (int):
The value of `{shell.returncode}` if run in simulation
Returns
- `{shell.output}` (`str`): The stdout of the executed process.
- `{shell.returncode}` (`int`): The returncode of the executed process.
"""
cmd: str
run_in_simulation: bool = False
ignore_errors: bool = False
simulation_output: str = "** simulation **"
simulation_returncode: int = 0
action_config: ClassVar[ActionConfig] = ActionConfig(
name="shell",
standalone=True,
files=True,
dirs=True,
)
def __post_init__(self):
self._cmd = Template.from_string(self.cmd)
self._simulation_output = Template.from_string(self.simulation_output)
def pipeline(self, res: Resource, output: Output, simulate: bool):
full_cmd = render(self._cmd, res.dict())
if not simulate or self.run_in_simulation:
output.msg(res=res, msg=f"$ {full_cmd}", sender=self)
try:
call = subprocess.run(
full_cmd,
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
shell=True,
)
except subprocess.CalledProcessError as e:
if not self.ignore_errors:
raise e
res.vars[self.action_config.name] = {
"output": call.stdout.decode("utf-8"),
"returncode": call.returncode,
}
else:
output.msg(
res=res,
msg=f"** not run in simulation ** $ {full_cmd}",
sender=self,
)
res.vars[self.action_config.name] = {
"output": render(self._simulation_output, res.dict()),
"returncode": self.simulation_returncode,
}
|
Examples:
rules:
- name: "On macOS: Open all pdfs on your desktop"
locations: "~/Desktop"
filters:
- extension: pdf
actions:
- shell: 'open "{path}"'
symlink
Create a symbolic link.
Attributes: |
-
dest
(str )
–
The symlink destination. If dest ends with a slash `/``, create the
symlink in the given directory. Can contain placeholders.
-
on_conflict
(str )
–
What should happen in case dest already exists.
One of skip , overwrite , trash , rename_new and rename_existing .
Defaults to rename_new .
-
rename_template
(str )
–
A template for renaming the file / dir in case of a conflict.
Defaults to {name} {counter}{extension} .
-
autodetect_folder
(bool )
–
In case you forget the ending slash "/" to indicate creating the
link inside the destination folder this settings will handle targets
without a file extension as folders. If you really mean to copy to
a file without file extension, set this to false.
Default: true
|
Source code in organize/actions/symlink.py
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82 | @dataclass(config=ConfigDict(coerce_numbers_to_str=True, extra="forbid"))
class Symlink:
"""Create a symbolic link.
Attributes:
dest (str):
The symlink destination. If **dest** ends with a slash `/``, create the
symlink in the given directory. Can contain placeholders.
on_conflict (str):
What should happen in case **dest** already exists.
One of `skip`, `overwrite`, `trash`, `rename_new` and `rename_existing`.
Defaults to `rename_new`.
rename_template (str):
A template for renaming the file / dir in case of a conflict.
Defaults to `{name} {counter}{extension}`.
autodetect_folder (bool):
In case you forget the ending slash "/" to indicate creating the
link inside the destination folder this settings will handle targets
without a file extension as folders. If you really mean to copy to
a file without file extension, set this to false.
Default: true
"""
dest: str
on_conflict: ConflictMode = "rename_new"
rename_template: str = "{name} {counter}{extension}"
autodetect_folder: bool = True
action_config: ClassVar[ActionConfig] = ActionConfig(
name="symlink",
standalone=False,
files=True,
dirs=True,
)
def __post_init__(self):
self._dest = Template.from_string(self.dest)
self._rename_template = Template.from_string(self.rename_template)
def pipeline(self, res: Resource, output: Output, simulate: bool):
assert res.path is not None, "Does not support standalone mode"
rendered = render(self._dest, res.dict())
dst = prepare_target_path(
src_name=res.path.name,
dst=rendered,
autodetect_folder=self.autodetect_folder,
simulate=simulate,
)
skip_action, dst = resolve_conflict(
dst=dst,
res=res,
conflict_mode=self.on_conflict,
rename_template=self._rename_template,
simulate=simulate,
output=output,
)
if skip_action:
return
output.msg(res=res, msg=f"Creating symlink at {dst}", sender=self)
res.walker_skip_pathes.add(dst)
if not simulate:
dst.symlink_to(target=res.path, target_is_directory=res.is_dir())
|
trash
Move a file or dir into the trash.
Source code in organize/actions/trash.py
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34 | @dataclass(config=ConfigDict(coerce_numbers_to_str=True, extra="forbid"))
class Trash:
"""Move a file or dir into the trash."""
action_config: ClassVar[ActionConfig] = ActionConfig(
name="trash",
standalone=False,
files=True,
dirs=True,
)
def pipeline(self, res: Resource, output: Output, simulate: bool):
assert res.path is not None, "Does not support standalone mode"
output.msg(res=res, msg=f'Trash "{res.path}"', sender=self)
if not simulate:
trash(res.path)
|
Examples:
rules:
- name: Move all JPGs and PNGs on the desktop which are older than one year into the trash
locations: "~/Desktop"
filters:
- lastmodified:
years: 1
mode: older
- extension:
- png
- jpg
actions:
- trash
write
Write text to a file.
If the specified path does not exist it will be created.
Attributes: |
-
text
(str )
–
The text that should be written. Supports templates.
-
outfile
(str )
–
The file text should be written into. Supports templates.
-
mode
(str )
–
Can be either append (append text to the file), prepend (insert text as
first line) or overwrite (overwrite content with text).
Defaults to append .
-
encoding
(str )
–
The text encoding to use. Default: "utf-8".
-
newline
(str )
–
(Optional) Whether to append a newline to the given text .
Defaults to true .
-
clear_before_first_write
(bool )
–
(Optional) Clears the file before first appending / prepending text to it.
This happens only the first time the file is written to. If the rule filters
don't match anything the file is left as it is.
Defaults to false .
|
Source code in organize/actions/write.py
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101 | @dataclass(config=ConfigDict(coerce_numbers_to_str=True, extra="forbid"))
class Write:
"""
Write text to a file.
If the specified path does not exist it will be created.
Attributes:
text (str):
The text that should be written. Supports templates.
outfile (str):
The file `text` should be written into. Supports templates.
mode (str):
Can be either `append` (append text to the file), `prepend` (insert text as
first line) or `overwrite` (overwrite content with text).
Defaults to `append`.
encoding (str):
The text encoding to use. Default: "utf-8".
newline (str):
(Optional) Whether to append a newline to the given `text`.
Defaults to `true`.
clear_before_first_write (bool):
(Optional) Clears the file before first appending / prepending text to it.
This happens only the first time the file is written to. If the rule filters
don't match anything the file is left as it is.
Defaults to `false`.
"""
text: str
outfile: str
mode: Literal["append", "prepend", "overwrite"] = "append"
encoding: str = "utf-8"
newline: bool = True
clear_before_first_write: bool = False
action_config: ClassVar[ActionConfig] = ActionConfig(
name="write",
standalone=True,
files=True,
dirs=True,
)
def __post_init__(self):
self._text = Template.from_string(self.text)
self._path = Template.from_string(self.outfile)
self._known_files = set()
def pipeline(self, res: Resource, output: Output, simulate: bool):
text = render(self._text, res.dict())
path = Path(render(self._path, res.dict()))
resolved = path.resolve()
if resolved not in self._known_files:
self._known_files.add(resolved)
if not simulate:
resolved.parent.mkdir(parents=True, exist_ok=True)
# clear on first write
if resolved.exists() and self.clear_before_first_write:
output.msg(res=res, msg=f"Clearing file {path}", sender=self)
if not simulate:
resolved.open("w") # clear the file
output.msg(res=res, msg=f'{path}: {self.mode} "{text}"', sender=self)
if self.newline:
text += "\n"
if not simulate:
if self.mode == "append":
with open(path, "a", encoding=self.encoding) as f:
f.write(text)
elif self.mode == "prepend":
content = ""
if path.exists():
content = path.read_text(encoding=self.encoding)
path.write_text(text + content, encoding=self.encoding)
elif self.mode == "overwrite":
path.write_text(text, encoding=self.encoding)
|
Examples
rules:
- name: "Record file sizes"
locations: ~/Downloads
filters:
- size
actions:
- write:
outfile: "./sizes.txt"
text: "{size.traditional} -- {relative_path}"
mode: "append"
clear_before_first_write: true
This will create a file sizes.txt
in the current working folder which contains the
filesizes of everything in the ~/Downloads
folder:
2.9 MB -- SIM7600.pdf
1.0 MB -- Bildschirmfoto 2022-07-05 um 10.43.16.png
5.9 MB -- Albumcover.png
51.2 KB -- Urlaubsantrag 2022-04-19.pdf
1.8 MB -- ETH_USB_HUB_HAT.pdf
2.1 MB -- ArduinoDUE_V02g_sch.pdf
...
You can use templates both in the text as well as in the textfile parameter:
rules:
- name: "File sizes by extension"
locations: ~/Downloads
filters:
- size
- extension
actions:
- write:
outfile: "./sizes.{extension}.txt"
text: "{size.traditional} -- {relative_path}"
mode: "prepend"
clear_before_first_write: true
This will separate the filesizes by extension.