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}"

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)

macos_tags#

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) –

    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.
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}"'

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.