25 Commits
Author SHA1 Message Date
sarah a6a5b28631 7.5: Windows support 2026-10-09 14:00:38 +02:00
sarahandClaude Opus 5.5 4a52120b1a 7.4: never sort files that are still downloading
Files ending in .part, .crdownload or .download (any case), and a file next
to its own unfinished download (Firefox's empty placeholder), are skipped
before any pattern check, folder zipping or unzipping.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 10:52:18 +02:00
sarahandClaude Opus 5.5 234889ba6d 7.3: fix nested variables, catch-alls, paths and shared configs
- %Name% lookups also resolve inside the values they bring in, and unknown
  names are left as they are instead of crashing.
- Catch-all patterns ("*") are a fallback: specific patterns are tried first
  across all outputs, so a catch-all output listed first no longer takes
  every file.
- Directory paths only get %Name% lookups and date/time variables, so
  ! & @ ~ ^ in a path stay as they are; a leading ~ is the home directory.
- A directory config missing keys (one shared by a directory's input and
  output roles) gets them from the template instead of crashing SaraSortd.
- The history of a directory that sorts into itself compares file names as
  they are, so names with variable characters aren't re-sorted forever.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 18:18:02 +02:00
sarahandClaude Opus 5.5 4389a1c76e 7.2: directory configs can live outside their directories
DirConfDir keeps every directory's config (and history file) in one folder,
and [DirConfs] names a file for single directories, like SaraTag's Lexicons
and Records. Both are optional; without them configs stay inside the
directories as before. Clone now copies straight to the new name.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 18:09:49 +02:00
sarahandClaude Opus 5.5 aef32f6f3b Update NextNum/NextChar by entry position, not by Pattern
Two [[Files]] entries can now share a Pattern (with different
ValidInputDirs), and the counter of the first one was bumped for both.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 22:50:11 +02:00
sarahandClaude Opus 5.5 78216fc352 7.1: optional ValidInputDirs per [[Files]] entry
An output directory can now take different patterns from different input
directories. Entries without it behave as before. Also fixes the README's
outdated Names section (InDirConfName/OutDirConfName), NextNum and
LogFileName.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 22:23:36 +02:00
sarah 16e6b7196e README: banners over HTTPS 2026-09-24 22:58:00 +02:00
sarah ec9e97e099 README: load banners from Gitea 2026-09-24 22:25:51 +02:00
sarah 70a36d8b96 Merge pull request #61 from SRHTNIN/SarahTest
Sarah test
2026-05-10 23:35:59 +02:00
sarah 19ceec218f Renamed CheckInput tot SortTimer, added Unzip parameter with a list, and
fixed sorting directories to not need admin (at least on linux).
2026-05-10 23:33:44 +02:00
sarah 2cc72b1730 Added unzipping function, but haven't tested it yet. 2026-05-10 21:42:21 +02:00
sarah 2bf4457981 Merge pull request #56 from SRHTNIN/SarahTest
Sarah test
2026-05-02 14:54:31 +02:00
sarah 5c39166a66 Update README.md 2026-05-02 14:54:03 +02:00
sarah 6a6e7dd494 Update README.md 2026-05-02 14:53:45 +02:00
sarah a83b2e3c94 Update README.md 2026-05-02 14:52:14 +02:00
sarah 9be5938a87 Update README.md 2026-05-02 14:51:09 +02:00
sarah 4e00617e09 Update README.md 2026-05-02 14:50:17 +02:00
sarah c5612654f8 Added new install video to README. 2026-05-02 14:49:49 +02:00
sarah 324d06d6b4 Set RootDir to "Unset". I am dumb. 2026-05-02 14:34:26 +02:00
sarah b5afad3895 Changed file structure and moved assets to separate repository to keep
stuff clean. Also moved LICENSE up.
2026-05-02 14:24:48 +02:00
sarah a314614055 Merge pull request #53 from SRHTNIN/SarahTest
Sarah test
2026-05-02 00:50:12 +02:00
sarah 3aa28f76cf Update README.md 2026-05-02 00:49:36 +02:00
sarah 89f4e59041 Update README.md 2026-05-02 00:48:18 +02:00
sarah dc60a9828e Update README.md 2026-05-02 00:48:03 +02:00
sarah 4901d346f2 Added a new banner and icons for SaraSortd. (Calindra is the demon's
name.)
2026-05-02 00:47:32 +02:00
16 changed files with 367 additions and 156 deletions

No files matched your search

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

+6 -3
View File
@@ -1,8 +1,8 @@
Title = "SaraSortd Config"
Version = "6.1"
Version = "7.5"
SafeMode = 1
SilentMode = 0
CheckInput = 5
SortTimer = 1
DotFiles = 0
Logging = 1
OverwriteDirConf = 0
@@ -22,11 +22,14 @@ Second = "[s]"
VarCall = "%%"
[DirectoryPaths]
RootDir = "C:/Users/StigOscarAadalen-Aas/OneDrive - Telemark fylkeskommune/Desktop/Testdir"
RootDir = "Unset"
InputDir = [ "%RootDir%/Input" ]
OutputDir = [ "%RootDir%/Output" ]
LogDir = "%RootDir%/.Logs"
FailedDir = "%RootDir%/.Failed"
DirConfDir = ""
[DirConfs]
[Names]
LogFileName = "[y].[m].[d]"
+1 -1
View File
@@ -1,5 +1,5 @@
Title = "Unset"
Version = "6.1"
Version = "7.5"
ParentDir = "Unset"
SortDirs = 0
View File
File renamed without changes.
+2 -1
View File
@@ -1,10 +1,11 @@
Title = "Unset"
Version = "6.1"
Version = "7.5"
LastFile = "Unset"
ParentDir = "Unset"
ValidInputDirs = ["*"]
FileLimit = 0
DeleteOrg = 1
Unzip = []
[[Files]]
Pattern = "*"
+60 -26
View File
@@ -1,15 +1,17 @@
<img align="center" src="Banners/SaraSortd.png" width="100%" alt="SaraSortd"/>
(Version = "6.1")
<img align="center" src="https://git.srhtnin.garden/sarah/RepoAssets/raw/branch/main/SaraSortd/Banners/SaraSortd.png" width="100%" alt="SaraSortd: a file and directory sorting daemon built with python."/>
(Version = "7.5")
A python daemon that can automatically sort your files to your liking without using AI.
It's made by Sarah, hence the name. :3
If the first number in the version goes up, it means that old config files will not work with the new SaraSortd.py.
If the second number in the version goes up, something new got added, but your old config files will still work just fine.
<img align="center" src="Banners/Usage.png" width="100%" alt="Usage"/>
---
<img align="center" src="https://git.srhtnin.garden/sarah/RepoAssets/raw/branch/main/SaraSortd/Banners/Usage.png" width="100%" alt="Usage"/>
SaraSortd is for people who don't want to manually sort files, people who want to organise files, and people who want an automatic naming standard for their files.
@@ -21,54 +23,65 @@ Example use-cases:
- Regularly cloning important files into a backup directory.
<img align="center" src="Banners/Dependencies.png" width="100%" alt="Dependencies"/>
---
<img align="center" src="https://git.srhtnin.garden/sarah/RepoAssets/raw/branch/main/SaraSortd/Banners/Dependencies.png" width="100%" alt="Dependencies"/>
python
toml: Use ```pip install toml``` to install toml.
---
<img align="center" src="Banners/Installation.png" width="100%" alt="Installation"/>
<img align="center" src="https://git.srhtnin.garden/sarah/RepoAssets/raw/branch/main/SaraSortd/Banners/Installation.png" width="100%" alt="Installation"/>
Here's an [installation video](https://youtu.be/QkMZVSJ-PLQ?si=Nd20jDyHT7CN6Ztb) on Linux.
Here's an [installation video](https://youtu.be/ZMIaOhZA0kE).
Here's a step-by-step tutorial (in case you don't want to watch the video).
1. Install ZIP-file.
1. Install ZIP-file by clicking the green "Code" button on the top right, then clicking "Download Zip".
2. Unzip ZIP-file wherever you want the program to live.
3. Navigate to the new SaraSortd directory in your terminal.
4. Edit your config files to your liking.
3. Edit your config files to your liking. Make sure to edit your paths! (E.G. RootDir = "/home/janedoe/Programs/SaraSortd")
4. Navigate to the new SaraSortd directory in your terminal (CMD / PowerShell on Windows).
5. Run ```python SaraSortd.py``` to run the program. (This will make new directories for you.)
6. Edit your output directories' configs.
6. Edit your new output directories' configs.
<img align="center" src="Banners/ConfigInfo.png" width="100%" alt="Config Info"/>
---
<img align="center" src="https://git.srhtnin.garden/sarah/RepoAssets/raw/branch/main/SaraSortd/Banners/ConfigInfo.png" width="100%" alt="Config Info"/>
<img align="center" src="https://git.srhtnin.garden/sarah/RepoAssets/raw/branch/main/SaraSortd/Banners/GlobalConf.png" width="100%" alt="GlobalConf"/>
<img align="center" src="Banners/GlobalConf.png" width="100%" alt="GlobalConf"/>
```
Title = "SaraSortd Config" # NOTE: Do not change the title. This is so that the program knows which config file is which.
Version = "6.1" # Just the version number for the config file.
Version = "7.5" # Just the version number for the config file.
SafeMode = 1 # If SaraSortd should stop if an error with the config appears. 1 is stop, 0 is continue.
SilentMode = 0 # If SaraSortd should write things it does, to the terminal.
CheckInput = 3 # How many seconds it takes for SaraSortd to check input directories for new files to sort.
SortTimer = 3 # How many seconds it takes for SaraSortd to check input directories for new files to sort.
DotFiles = 0 # If SaraSortd should sort files that begin with ".". This is mainly for linux users, since hidden files always have "." in front of them.
# Files that are still downloading are never sorted or even checked (since 7.4): anything ending in .part (Firefox), .crdownload (Chrome and other Chromium browsers) or .download (Safari), and a file next to its own unfinished download (like the empty "video.mp4" Firefox makes beside "video.mp4.part"). They're sorted as usual once the download finishes.
Logging = 1 # If SaraSortd's logging system should be active. If this is 0, nothing will be logged into files.
OverwriteDirConf = 0 # If SaraSortd should overwrite directory configs with the config templates. 0 is no, 1 is yes.
[Variables] # Only use these in config files, not the actual file names themselves.
NextNum = "*" # The next available number. If 1 already exists, 2 will be used.
# Directory paths (InputDir, OutputDir, LogDir, FailedDir, ValidInputDirs, DirConfDir, [DirConfs]) only use %Name% lookups and the date and time variables (since 7.3). The single-character variables below are about the file being sorted, so a path like "%RootDir%/Stuff&Things" stays as it is. A "~" at the start of a path is your home directory.
NextNum = "!" # The next available number. If 1 already exists, 2 will be used.
NextChar = "&" # The next available character. If A already exists, B will be used.
Parent = "@" # The name of the parent directory.
OrgFileName = "\~" # The original name of the sorted file. If "blahblah.txt" gets sorted, "~" would be equal to "blahblah".
OrgFileName = "~" # The original name of the sorted file. If "blahblah.txt" gets sorted, "~" would be equal to "blahblah".
OrgFileType = "^" # The original file type of the sorted file. If "example.png" gets sorted, "^" would be equal to ".png".
@@ -92,15 +105,25 @@ RootDir = "Unset" # A variable directory. This doesn't NEED to exist for the pro
InputDir = ["%RootDir%"] # If files get put here, they'll be sorted. This is a list, so you can add multiple. E.G: ["%RootDir%", "%RootDir%/input"]
OutputDir = ["%RootDir%/Output"] # Where sorted files go. This is a list, so you can add multiple. E.G: ["%RootDir%/Output", "%RootDir%/Output2"]. The output directories have separate config files.
# Outputs are tried in this order, first with their specific patterns. Catch-all patterns ("*") are only a fallback for files no specific pattern took (since 7.3), so an output that takes everything can be anywhere in the list.
LogDir = "%RootDir%/.Logs" # Where log files from SaraSortd go.
FailedDir ="%RootDir%/.Failed" # For files that failed to be sorted.
[Names]
LogFileName = "[y].[m].[d]._*" # Name for log files.
DirConfDir = "" # Optional (since 7.2). Where directory configs live. If this is "", every directory keeps its config inside itself. If it's set, the configs (and history files) of all directories go here instead, so the directories stay clean. Relative paths count from GlobalConf.toml's own folder. E.G: DirConfDir = "Dirs"
# Each config is named after its directory's path below RootDir, with "/" turned into "_", and InDirConfName/OutDirConfName applied without the leading ".". E.G: "%RootDir%/Pictures/Cats" gets "Pictures_CatsConfig.toml".
DirConfName = ".@Config" # Name for config files for directories.
[DirConfs] # Optional (since 7.2). A config file for one directory, wherever you like. These win over DirConfDir. Relative paths count from GlobalConf.toml's own folder. E.G:
# "%RootDir%/Downloads" = "Configs/Downloads.toml"
# "%RootDir%/Pictures" = { In = "Configs/PicturesIn.toml", Out = "Configs/PicturesOut.toml" } # Separate input and output configs.
[Names]
LogFileName = "[y].[m].[d]" # Name for log files.
InDirConfName = ".@Config" # Name for config files for input directories.
OutDirConfName = ".@Config" # Name for config files for output directories. If a directory is both an input and an output and both names are the same, it uses one config file for both: its [[Files]] are then both the input's and the output's rules, and SaraSortd gives the file every key both roles need (since 7.3).
FileHistoryName = ".@History" # Name for file history files. This keeps track of sorted files if an input and output are the same directory.
@@ -137,16 +160,17 @@ Unzipping = "Unzipping: %%." # When a directory gets unzipped.
NoPermission = "Error: No permission to %%." # When SaraSortd lacks permission to do something.
# %% is the action that the program had no permission to do. E.G. "remove directory /home/bob/exampledir".
```
---
<img align="center" src="https://git.srhtnin.garden/sarah/RepoAssets/raw/branch/main/SaraSortd/Banners/OutputDirConf.png" width="100%" alt="OutputDirConf"/>
<img align="center" src="Banners/OutputDirConf.png" width="100%" alt="OutputDirConf"/>
The output directory config references the SaraSortd config (GlobalConf.toml). This means that you can, for example, use the character assigned to "NextNum" in GlobalConf.toml in this config (OutputDirConf.toml). This applies to all Variables in the SaraSortd config (GlobalConf.toml).
```
Title = "Unset" # NOTE: Do not change the title. This is so that the program knows which config file is which.
Version = "6.1" # The version number of the config.
Version = "7.5" # The version number of the config.
LastFile = "Unset" # The last file that was sorted to this directory. It gets automatically updated whenever a file gets sorted to this directory.
@@ -158,6 +182,9 @@ FileLimit = 0 # The max limit of files a directory can have. If this is 0, then
DeleteOrg = 1 # If SaraSortd should delete the original file or not. If this is 1, then sorted files will be 'moved' to an output directory. If not, they will be 'copied' to an output directory.
Unzip = [] # What type of files this output directory should try to unzip. E.G:
# Unzip = ["zip", "tar.gz"] would unzip .zip files and .tar files. This pairs insanely well with input directories' config's "SortDirs" parameter.
[[Files]] # The double brackets means that you can add multiple types of files to be sorted here. Just copy [[Files]] and all the parameters under it, and paste it under or over this section, then you may change the values of the new parameters. E.G:
# [[Files]]
@@ -187,18 +214,23 @@ NextChar = "A" # Used to keep track of the next available letter for file names.
CaseSensitive = 0 # Whether this "Pattern" is case-sensitive or not. 1 is yes, 0 is no. E.G: if CaseSensitive = 1 and this "Pattern" is "*.PNG", a file named "picture.png" won't get sorted to this directory. If CaseSensitive = 0 and this "Pattern" is "*.PNG", a file named "picture.png" *will* get sorted to this directory.
Overwrite = 1 # Whether this specific file type can be overwritten by others of the same type. 0 is no, 1 is yes.
# ValidInputDirs = ["%RootDir%/Downloads"] # Optional (since 7.1). Like the ValidInputDirs above, but only for this [[Files]] entry, so different input directories can send different file types here. Without it, this entry takes files from every input directory the ValidInputDirs above allows.
```
<img align="center" src="Banners/InputDirConf.png" width="100%" alt="InputDirConf"/>
---
<img align="center" src="https://git.srhtnin.garden/sarah/RepoAssets/raw/branch/main/SaraSortd/Banners/InputDirConf.png" width="100%" alt="InputDirConf"/>
```
Title = "Unset" # NOTE: Do not change the title. This is so that the program knows which config file is which.
Version = "6.1"
Version = "7.5"
ParentDir = "Unset" # The name of this directory. It gets automatically updated when this config gets copied to an output directory.
SortDirs = 0
SortDirs = 0 # If the input directory should be allowed to sort directories within it. Automatically uses .zip to move directories.
[[Files]] # The double brackets means that you can add multiple types of files to be sorted here. Just copy [[Files]] and all the parameters under it, and paste it under or over this section, then you may change the values of the new parameters.
@@ -206,3 +238,5 @@ Pattern = "*" # A pattern used to decide if a file should be sorted or not. * is
CaseSensitive = 0 # Whether Pattern is case-sensitive or not. If CaseSensitive = 1 and your pattern is "*.PNG", then a file named "example.png" won't be sorted.
```
---
+298 -125
View File
@@ -6,7 +6,7 @@ import shutil
import time
import toml
# Version = "6.1"
# Version = "7.5"
ConfPath = "./GlobalConf.toml"
@@ -151,8 +151,10 @@ def Parse(
return ""
Value = GetConf(Content, Path)
if Value is not None:
return Value
if Value is None:
return Match.group(0) # Unknown names are left as they are (they used to crash).
return ResolveLookups(str(Value), Path)
Now = datetime.datetime.now()
if OrgFile is not None:
@@ -189,10 +191,110 @@ def Parse(
return String
def ResolveLookups(String, Path = ConfPath, Depth = 0):
# %Name% lookups, also inside the values they bring in (since 7.3).
# E.G: ImageDir = "%RootDir%/Images" used as "%ImageDir%" gives the full path. Unknown names are left as they are.
def Replacer(Match):
Value = GetConf(Match.group(1), Path) if Match.group(1) else None
if Value is None or Depth >= 10:
return Match.group(0)
return ResolveLookups(str(Value), Path, Depth + 1)
VarCall0 = re.escape(ConfVars["VarCall"][0])
VarCall1 = re.escape(ConfVars["VarCall"][1])
return re.sub(f"{VarCall0}(.*?){VarCall1}", Replacer, String)
def ParsePath(String):
# Directory paths only get %Name% lookups and the date and time variables (since 7.3). The single-character
# variables (NextNum, NextChar, Parent, OrgFileName, OrgFileType) are about the file being sorted, so a path
# like "~/Stuff&Things" stays as it is. A "~" at the start is your home directory.
Now = datetime.datetime.now()
DateValues = {
ConfVars["Year"]: str(Now.year),
ConfVars["Month"]: str(Now.month).rjust(2, "0"),
ConfVars["Day"]: str(Now.day).rjust(2, "0"),
ConfVars["Hour"]: str(Now.hour).rjust(2, "0"),
ConfVars["Minute"]: str(Now.minute).rjust(2, "0"),
ConfVars["Second"]: str(Now.second).rjust(2, "0"),
}
for Key, Value in DateValues.items():
if Key and Key in String:
String = String.replace(Key, Value)
return os.path.expanduser(ResolveLookups(String))
def ConfFolder(PathText):
# Relative paths in GlobalConf.toml (DirConfDir, [DirConfs]) count from GlobalConf.toml's own folder.
PathText = ParsePath(PathText)
if os.path.isabs(PathText):
return os.path.normpath(PathText)
return os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(ConfPath)), PathText))
def Slashes(Path):
# Windows paths use "\\" (and configs may mix both); compare and name them with "/" only.
return Path.replace("\\", "/")
def FlatName(Path):
# A directory's name for files kept outside it: its path below RootDir (or its whole path), "/" turned into "_".
# E.G: "%RootDir%/Pictures/Cats" becomes "Pictures_Cats". On Windows a drive's ":" is dropped (not allowed in names).
Root = Slashes(ParsePath(ConfDirs.get("RootDir", ""))).rstrip("/")
Path = Slashes(Path).rstrip("/")
if Root and Path.startswith(Root + "/"):
Relative = Path[len(Root) + 1:]
else:
Relative = Path.strip("/") or os.path.basename(Path)
return Relative.replace(":", "").replace("/", "_")
def CentralConf(Path, Kind):
# Since 7.2, a directory's config can live outside it, like SaraTag's Lexicons:
# [DirConfs] names a file per directory ("dir" = "file", or "dir" = { In = "file", Out = "file" }),
# and DirConfDir holds the configs of every directory [DirConfs] doesn't name. None means inside the directory.
for Key, Value in (Conf.get("DirConfs") or {}).items():
if Slashes(ParsePath(Key)).rstrip("/") == Slashes(Path).rstrip("/"):
if isinstance(Value, dict):
Value = Value.get(Kind)
if Value:
return ConfFolder(Value)
if ConfDirs.get("DirConfDir"):
NameKey = "InDirConfName" if Kind == "In" else "OutDirConfName"
Name = Parse(String = ConfNames[NameKey], Parent = FlatName(Path)).lstrip(".")
return f"{ConfFolder(ConfDirs['DirConfDir'])}/{Name}.toml"
return None
def DirConfPath(Path, Kind):
# The config of an input ("In") or output ("Out") directory.
Central = CentralConf(Path, Kind)
if Central:
return Central
NameKey = "InDirConfName" if Kind == "In" else "OutDirConfName"
return f"{Path}/{Parse(String = ConfNames[NameKey], Path = ConfPath, Parent = os.path.basename(Path))}.toml"
def HistoryPath(Path):
# The history file of a directory that sorts into itself. It goes next to the directory's config
# when that's kept outside the directory.
Central = CentralConf(Path, "In") or CentralConf(Path, "Out")
if Central:
Name = Parse(String = ConfNames["FileHistoryName"], Parent = FlatName(Path)).lstrip(".")
return f"{os.path.dirname(Central)}/{Name}.toml"
return f"{Path}/{Parse(String = ConfNames['FileHistoryName'], Parent = os.path.basename(Path))}.toml"
def Clone(Source, Destination, NewName, Delete = False):
if (os.path.dirname(Source) == Destination):
FileHistoryPath = f"{Destination}/{Parse(ConfNames["FileHistoryName"], Parent = os.path.basename(Destination))}.toml"
print("FileHistoryPath:",FileHistoryPath)
FileHistoryPath = HistoryPath(Destination)
if not os.path.exists(FileHistoryPath):
with open(FileHistoryPath, "w", encoding = "utf-8") as HistoryFile:
@@ -204,61 +306,66 @@ def Clone(Source, Destination, NewName, Delete = False):
with open(FileHistoryPath, "w", encoding = "utf-8") as HistoryFile:
toml.dump(HistoryFileData, HistoryFile)
os.rename(Source, os.path.join(Destination, NewName))
os.replace(Source, os.path.join(Destination, NewName)) # overwrites on Windows too, like rename on Linux
return
Dir(Destination, Output = False, CopyConf = False)
shutil.copy2(Source, Destination)
BaseName = os.path.basename(Source)
shutil.move(os.path.join(Destination, BaseName), os.path.join(Destination, NewName))
# Straight to the new name: copying under the old name first could land on a file that's already
# there (E.G. a template, when DirConfDir is GlobalConf.toml's own folder).
shutil.copy2(Source, os.path.join(Destination, NewName))
if Delete:
os.remove(Source)
def CompleteDirConf(DirConf, Template, Kind):
# Gives an existing directory config the keys it's missing from the template (since 7.3). A directory that is
# both an input and an output shares one config by default, so the second role's keys used to be missing and
# SaraSortd crashed. Output configs' [[Files]] entries also get the output keys (NewFileName, NextNum, ...).
Data = toml.load(DirConf)
TemplateData = toml.load(Template)
Changed = False
for Key, Value in TemplateData.items():
if Key != "Files" and Key not in Data:
Data[Key] = Value
Changed = True
if Kind == "Out":
Defaults = (TemplateData.get("Files") or [{}])[0]
for Entry in Data.get("Files", []):
for Key, Value in Defaults.items():
if Key not in Entry:
Entry[Key] = Value
Changed = True
if Changed:
with open(DirConf, "w", encoding = "utf-8") as File:
toml.dump(Data, File)
def Dir(Path, Output = True, CopyConf = "OutputDirConf.toml"):
ParentName = os.path.basename(Path)
os.makedirs(Path, exist_ok=True)
if CopyConf:
if "Out" in CopyConf:
NewDirConf = f"{Parse(String = ConfNames['OutDirConfName'], Path = ConfPath, Parent = ParentName)}.toml"
for Kind in ("Out", "In"):
if Kind not in CopyConf:
continue
if Conf["OverwriteDirConf"] == 1 or not os.path.exists(os.path.join(f"{Path}/{NewDirConf}")):
Clone(CopyConf, Path, NewDirConf)
UpdateConf(
f"{Path}/{Parse(String = ConfNames['OutDirConfName'], Path = ConfPath, Parent = ParentName)}.toml",
"ParentDir",
ParentName,
)
DirConf = DirConfPath(Path, Kind)
UpdateConf(
f"{Path}/{Parse(String = ConfNames['OutDirConfName'], Path = ConfPath, Parent = ParentName)}.toml",
"Title",
f"{ParentName} Config",
)
if Conf["OverwriteDirConf"] == 1 or not os.path.exists(DirConf):
Clone(CopyConf, os.path.dirname(DirConf), os.path.basename(DirConf))
UpdateConf(DirConf, "ParentDir", ParentName)
UpdateConf(DirConf, "Title", f"{ParentName} Config")
if "In" in CopyConf:
NewDirConf = f"{Parse(String = ConfNames['InDirConfName'], Path = ConfPath, Parent = ParentName)}.toml"
if Conf["OverwriteDirConf"] == 1 or not os.path.exists(os.path.join(f"{Path}/{NewDirConf}")):
Clone(CopyConf, Path, NewDirConf)
UpdateConf(
f"{Path}/{Parse(String = ConfNames['InDirConfName'], Path = ConfPath, Parent = ParentName)}.toml",
"ParentDir",
ParentName,
)
UpdateConf(
f"{Path}/{Parse(String = ConfNames['InDirConfName'], Path = ConfPath, Parent = ParentName)}.toml",
"Title",
f"{ParentName} Config",
)
else:
CompleteDirConf(DirConf, CopyConf, Kind)
if Output:
ParsedPath = Parse(String = Path)
ParsedOutputs = [Parse(String = Dir) for Dir in ConfDirs["OutputDir"]]
ParsedPath = ParsePath(Path)
ParsedOutputs = [ParsePath(Dir) for Dir in ConfDirs["OutputDir"]]
if ParsedPath not in ParsedOutputs:
UpdateConf(ConfPath, "OutputDir", Path, True)
@@ -270,27 +377,62 @@ def LogWrite(Text):
ConfNames["LogFileName"] is not None
and ConfNames["LogFileName"] != "Unset"
):
Dir(Parse(String = ConfDirs["LogDir"], Path = ConfPath), Output = False, CopyConf = False)
LogFile = f"{Parse(String = ConfDirs['LogDir'], Path = ConfPath)}/{Parse(String = ConfNames['LogFileName'], Path = ConfPath)}.log"
Dir(ParsePath(ConfDirs["LogDir"]), Output = False, CopyConf = False)
LogFile = f"{ParsePath(ConfDirs['LogDir'])}/{Parse(String = ConfNames['LogFileName'], Path = ConfPath)}.log"
with open(LogFile, "a", encoding = "utf-8", buffering = 1) as File:
File.write(f"{Parse(String = ConfLog['All'], Path = ConfPath)}{Text}\n")
def FileInputValid(File, FilePath, ParentName):
# A [[Files]] entry can have its own ValidInputDirs (since 7.1), so one output directory can take
# different patterns from different input directories. Without one, the entry takes files from
# every input directory the output directory's own ValidInputDirs allows.
ValidInputDirs = File.get("ValidInputDirs")
if ValidInputDirs is None:
return True
for ValidInputDir in ValidInputDirs:
if fnmatch.fnmatchcase(os.path.dirname(FilePath), ParsePath(ValidInputDir)):
return True
return False
# Files that are still downloading (since 7.4): Firefox's .part, Chromium's .crdownload and Safari's .download.
# SaraSortd never touches them, not even to check them against patterns.
PartialDownloadSuffixes = (".part", ".crdownload", ".download")
def IsPartialDownload(FilePath):
if os.path.basename(FilePath).lower().endswith(PartialDownloadSuffixes):
return True
# A file next to its own unfinished download, E.G. the empty "video.mp4" Firefox makes beside
# "video.mp4.part" and fills in at the end. Moving it would break the download.
return any(os.path.exists(FilePath + Suffix) for Suffix in PartialDownloadSuffixes)
def IsCatchAll(Pattern):
# A pattern made of only "*" takes any file.
return Pattern != "" and set(Pattern) == {"*"}
def DecideNewPath(FilePath):
UnsortedFile = os.path.basename(FilePath)
Candidates = []
for Output in ConfDirs["OutputDir"]:
Output = Parse(String = Output, Path = ConfPath)
Output = ParsePath(Output)
ParentName = os.path.basename(Output)
OutDirConfName = Parse(String = ConfNames["OutDirConfName"], Path = ConfPath, Parent = ParentName)
OutDirConfPath = f"{Output}/{OutDirConfName}.toml"
OutDirConfPath = DirConfPath(Output, "Out")
ValidInputDirs = GetConf("ValidInputDirs", OutDirConfPath)
ValidInputDirs = GetConf("ValidInputDirs", OutDirConfPath) or []
InputDirValid = False
for ValidInputDir in ValidInputDirs:
ValidInputDir = Parse(String = ValidInputDir, Parent = GetConf("ParentDir", OutDirConfPath))
ValidInputDir = ParsePath(ValidInputDir)
TextOutput = Parse(String = ConfLog["MatchPattern"], VarCall = f"{os.path.dirname(FilePath)} against {ValidInputDir}")
LogWrite(TextOutput)
@@ -322,90 +464,94 @@ def DecideNewPath(FilePath):
if not FileConf:
continue
for File in FileConf:
Pattern = File["Pattern"]
Candidates.append((Output, ParentName, OutDirConfPath, FileConf))
TextOutput = Parse(String = ConfLog["MatchPattern"], VarCall = f"{UnsortedFile} against {Pattern}")
LogWrite(TextOutput)
Speak(TextOutput)
if File["CaseSensitive"] == 1:
Match = fnmatch.fnmatchcase(UnsortedFile, Pattern)
else:
Match = fnmatch.fnmatch(UnsortedFile.lower(), Pattern.lower())
if Match:
NewFileName = File["NewFileName"]
NewFileName = Parse(String = NewFileName, Path = OutDirConfPath, OrgFile = UnsortedFile, NextNum = File["NextNum"], NextChar = File["NextChar"], Parent = ParentName)
if File["Overwrite"] == 0 and os.path.exists(os.path.join(Output, NewFileName)):
# Specific patterns first, in OutputDir order. Catch-alls ("*") are a fallback for files nothing else took,
# so an output with the template's catch-all doesn't take every file just because it's listed first (since 7.3).
for CatchAll in (False, True):
for Output, ParentName, OutDirConfPath, FileConf in Candidates:
for FileIndex, File in enumerate(FileConf):
if IsCatchAll(File["Pattern"]) != CatchAll or not FileInputValid(File, FilePath, ParentName):
continue
if ConfVars["NextNum"] in File["NewFileName"]:
ConfData = toml.load(OutDirConfPath)
Width = len(File["NextNum"])
NewNum = str((int(File["NextNum"]) + 1)).rjust(Width, "0")
print(File["NextNum"],"=>",NewNum)
Pattern = File["Pattern"]
for Item in ConfData.get("Files", []):
if Item.get("Pattern") == File["Pattern"]:
Item["NextNum"] = NewNum
break
TextOutput = Parse(String = ConfLog["MatchPattern"], VarCall = f"{UnsortedFile} against {Pattern}")
LogWrite(TextOutput)
Speak(TextOutput)
with open(OutDirConfPath, "w", encoding="utf-8") as ConfFile:
toml.dump(ConfData, ConfFile)
if File["CaseSensitive"] == 1:
Match = fnmatch.fnmatchcase(UnsortedFile, Pattern)
TextOutput = Parse(String = ConfLog["ValueSet"], VarCall = f"NextNum to {NewNum}")
LogWrite(TextOutput)
Speak(TextOutput)
else:
Match = fnmatch.fnmatch(UnsortedFile.lower(), Pattern.lower())
if ConfVars["NextChar"] in File["NewFileName"]:
CurrentChar = File["NextChar"]
CurrentChar = CurrentChar.upper()
Result = list(CurrentChar)
if Match:
NewFileName = File["NewFileName"]
Count = len(Result) - 1
NewFileName = Parse(String = NewFileName, Path = OutDirConfPath, OrgFile = UnsortedFile, NextNum = File["NextNum"], NextChar = File["NextChar"], Parent = ParentName)
while Count >= 0:
if Result[Count] == "Z":
Result[Count] = "A"
Count -= 1
if File["Overwrite"] == 0 and os.path.exists(os.path.join(Output, NewFileName)):
continue
if ConfVars["NextNum"] in File["NewFileName"]:
ConfData = toml.load(OutDirConfPath)
Width = len(File["NextNum"])
NewNum = str((int(File["NextNum"]) + 1)).rjust(Width, "0")
print(File["NextNum"],"=>",NewNum)
# By position: two entries can share a Pattern (with different ValidInputDirs).
ConfData["Files"][FileIndex]["NextNum"] = NewNum
with open(OutDirConfPath, "w", encoding="utf-8") as ConfFile:
toml.dump(ConfData, ConfFile)
TextOutput = Parse(String = ConfLog["ValueSet"], VarCall = f"NextNum to {NewNum}")
LogWrite(TextOutput)
Speak(TextOutput)
if ConfVars["NextChar"] in File["NewFileName"]:
CurrentChar = File["NextChar"]
CurrentChar = CurrentChar.upper()
Result = list(CurrentChar)
Count = len(Result) - 1
while Count >= 0:
if Result[Count] == "Z":
Result[Count] = "A"
Count -= 1
else:
Result[Count] = chr(ord(Result[Count]) + 1)
break
else:
Result[Count] = chr(ord(Result[Count]) + 1)
break
Result.insert(0, "A")
else:
Result.insert(0, "A")
NewChar = "".join(Result)
NewChar = "".join(Result)
ConfData = toml.load(OutDirConfPath)
ConfData = toml.load(OutDirConfPath)
ConfData["Files"][FileIndex]["NextChar"] = NewChar
for Item in ConfData.get("Files", []):
if Item.get("Pattern") == File["Pattern"]:
Item["NextChar"] = NewChar
break
with open(OutDirConfPath, "w", encoding="utf-8") as ConfFile:
toml.dump(ConfData, ConfFile)
with open(OutDirConfPath, "w", encoding="utf-8") as ConfFile:
toml.dump(ConfData, ConfFile)
TextOutput = Parse(String = ConfLog["ValueSet"], VarCall = f"NextChar to {NewChar}")
LogWrite(TextOutput)
Speak(TextOutput)
TextOutput = Parse(String = ConfLog["ValueSet"], VarCall = f"NextChar to {NewChar}")
LogWrite(TextOutput)
Speak(TextOutput)
return f"{Output}/{NewFileName}"
return f"{Output}/{NewFileName}"
def Sort(FilePath):
NewPath = DecideNewPath(FilePath)
if NewPath is None:
Dir(Parse(String = ConfDirs["FailedDir"]), Output = False, CopyConf = False)
Dir(ParsePath(ConfDirs["FailedDir"]), Output = False, CopyConf = False)
NewName = os.path.basename(FilePath)
Clone(FilePath, Parse(String = ConfDirs["FailedDir"]), NewName, True)
Clone(FilePath, ParsePath(ConfDirs["FailedDir"]), NewName, True)
TextOutput = Parse(String = ConfLog["NotSorted"], VarCall = FilePath)
LogWrite(TextOutput)
@@ -415,7 +561,7 @@ def Sort(FilePath):
NewDirPath = os.path.dirname(NewPath)
NewName = os.path.basename(NewPath)
if (GetConf("DeleteOrg", f"{NewDirPath}/{Parse(String = ConfNames['OutDirConfName'], Parent = (os.path.basename(NewDirPath)))}.toml") == 1):
if (GetConf("DeleteOrg", DirConfPath(NewDirPath, "Out")) == 1):
Delete = True
else:
@@ -423,16 +569,16 @@ def Sort(FilePath):
try:
Clone(FilePath, NewDirPath, NewName, Delete)
UpdateConf(f"{NewDirPath}/{Parse(String = ConfNames['OutDirConfName'], Parent = os.path.basename(NewDirPath))}.toml", "LastFile", NewName)
UpdateConf(DirConfPath(NewDirPath, "Out"), "LastFile", NewName)
TextOutput = Parse(String = ConfLog["Sorted"], VarCall = f"{FilePath} to {NewDirPath}/{NewName}")
LogWrite(TextOutput)
Speak(TextOutput)
except Exception:
Dir(Parse(String = ConfDirs["FailedDir"]), Output = False, CopyConf = False)
Dir(ParsePath(ConfDirs["FailedDir"]), Output = False, CopyConf = False)
NewName = os.path.basename(NewPath)
Clone(FilePath, Parse(String = ConfDirs["FailedDir"]), NewName, Delete)
Clone(FilePath, ParsePath(ConfDirs["FailedDir"]), NewName, Delete)
TextOutput = Parse(String = ConfLog["NotSorted"], VarCall = FilePath)
LogWrite(TextOutput)
@@ -449,14 +595,14 @@ def Init():
if Start:
for InputDir in ConfDirs["InputDir"]:
InputDir = Parse(String = InputDir, Path = ConfPath, Parent = os.path.basename(os.path.dirname(InputDir)))
InputDir = ParsePath(InputDir)
Dir(InputDir, Output = False, CopyConf = "InputDirConf.toml")
for Output in ConfDirs["OutputDir"]:
Output = Parse(String = Output, Parent = os.path.basename(os.path.dirname(Output)))
Output = ParsePath(Output)
Dir(Output)
OutDirConfPath = f"{Output}/{Parse(String = ConfNames["OutDirConfName"], Parent = os.path.basename(Output))}.toml"
OutDirConfPath = DirConfPath(Output, "Out")
ConfData = toml.load(OutDirConfPath)
for Item in ConfData.get("Files", []):
@@ -490,20 +636,44 @@ def Main():
for Input in ConfDirs["InputDir"]:
RecurseDir = False
InputPath = Parse(Input)
InputPath = ParsePath(Input)
if not os.path.exists(InputPath):
continue
InputDirConfPath = f"{InputPath}/{Parse(ConfNames["InDirConfName"], Parent = os.path.basename(InputPath))}.toml"
InputDirConfPath = DirConfPath(InputPath, "In")
InputDirConf = toml.load(InputDirConfPath)
for Output in ConfDirs["OutputDir"]:
OutputPath = Parse(Output)
OutputPath = ParsePath(Output)
OutDirConfPath = DirConfPath(OutputPath, "Out")
if len(GetConf("Unzip", OutDirConfPath) or []) >= 1:
ZipTypes = GetConf("Unzip", OutDirConfPath)
for OutputFile in os.listdir(OutputPath):
if OutputFile.startswith(".") or IsPartialDownload(f"{OutputPath}/{OutputFile}"):
continue
for ZipType in ZipTypes:
print(ZipType)
if (OutputFile.lower()).endswith((ZipType).lower()):
shutil.unpack_archive(f"{OutputPath}/{OutputFile}", OutputPath)
TextOutput = Parse(String = ConfLog["Unzipping"], VarCall = f"{OutputPath}/{OutputFile}")
LogWrite(TextOutput)
Speak(TextOutput)
try:
os.remove(f"{OutputPath}/{OutputFile}")
except PermissionError:
TextOutput = Parse(String = ConfLog["NoPermission"], VarCall = f"remove {OutputPath}/{OutputFile}")
LogWrite(TextOutput)
Speak(TextOutput)
continue
if (OutputPath == InputPath):
RecurseDir = True
FileHistoryPath = f"{InputPath}/{Parse(ConfNames["FileHistoryName"], Parent = os.path.basename(InputPath))}.toml"
FileHistoryPath = HistoryPath(InputPath)
if not os.path.exists(FileHistoryPath):
with open(FileHistoryPath, "w", encoding = "utf-8") as HistoryFile:
@@ -511,7 +681,10 @@ def Main():
for FileName in os.listdir(InputPath):
FilePath = f"{InputPath}/{FileName}"
if os.path.isdir(FilePath) and InputDirConf["SortDirs"] == 0:
if IsPartialDownload(FilePath):
continue
if os.path.isdir(FilePath) and InputDirConf.get("SortDirs", 0) == 0:
continue
FileName = os.path.basename(FilePath)
@@ -529,7 +702,7 @@ def Main():
break
if Match:
if os.path.isdir(FilePath) and InputDirConf["SortDirs"] == 1:
if os.path.isdir(FilePath) and InputDirConf.get("SortDirs", 0) == 1:
TextOutput = Parse(String = ConfLog["Zipping"], VarCall = FilePath)
LogWrite(TextOutput)
Speak(TextOutput)
@@ -537,7 +710,7 @@ def Main():
shutil.make_archive(FilePath, "zip", FilePath)
try:
os.rmdir(FilePath)
shutil.rmtree(FilePath, ignore_errors=True)
except PermissionError:
TextOutput = Parse(String = ConfLog["NoPermission"], VarCall = f"remove directory {InputPath}/{FileName}")
@@ -550,12 +723,12 @@ def Main():
if RecurseDir:
HistoryFile = toml.load(FileHistoryPath)
if (Parse(FileName, Parent = os.path.basename(InputPath)) in HistoryFile["History"]):
if (FileName in HistoryFile["History"]):
continue
Sort(FilePath)
time.sleep(Conf.get("CheckInput", 10))
time.sleep(Conf.get("SortTimer", 10))
except KeyboardInterrupt:
TextOutput = GetConf("Stop", ConfPath)
Binary file not shown.