├── .github
├── CODEOWNERS
└── workflows
│ ├── codeql-analysis.yml
│ ├── link_check.yml
│ └── run_tests.yml
├── .gitignore
├── CHANGELOG.md
├── LICENSE
├── README.md
├── SUPPORTER.md
├── __init__.py
├── addon_types
└── __init__.py
├── addon_updater.py
├── addon_updater_ops.py
├── blender_manifest.toml
├── functions
├── __init__.py
├── blenderdefender_functions.py
├── json_functions.py
├── main_functions.py
└── register_functions.py
├── objects
├── path_generator.py
└── token.py
├── operators.py
├── panels.py
├── prefs.py
└── tests
├── test_path_generator.py
└── test_token.py
/.github/CODEOWNERS:
--------------------------------------------------------------------------------
1 | * @BlenderDefender
--------------------------------------------------------------------------------
/.github/workflows/codeql-analysis.yml:
--------------------------------------------------------------------------------
1 | # For most projects, this workflow file will not need changing; you simply need
2 | # to commit it to your repository.
3 | #
4 | # You may wish to alter this file to override the set of languages analyzed,
5 | # or to provide custom queries or build logic.
6 | #
7 | # ******** NOTE ********
8 | # We have attempted to detect the languages in your repository. Please check
9 | # the `language` matrix defined below to confirm you have the correct set of
10 | # supported CodeQL languages.
11 | #
12 | name: "CodeQL"
13 |
14 | on:
15 | push:
16 | branches: [ master ]
17 | pull_request:
18 | # The branches below must be a subset of the branches above
19 | branches: [ master ]
20 | schedule:
21 | - cron: '27 2 * * 4'
22 |
23 | jobs:
24 | analyze:
25 | name: Analyze
26 | runs-on: ubuntu-latest
27 |
28 | strategy:
29 | fail-fast: false
30 | matrix:
31 | language: [ 'python' ]
32 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]
33 | # Learn more:
34 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed
35 |
36 | steps:
37 | - name: Checkout repository
38 | uses: actions/checkout@v2
39 |
40 | # Initializes the CodeQL tools for scanning.
41 | - name: Initialize CodeQL
42 | uses: github/codeql-action/init@v1
43 | with:
44 | languages: ${{ matrix.language }}
45 | # If you wish to specify custom queries, you can do so here or in a config file.
46 | # By default, queries listed here will override any specified in a config file.
47 | # Prefix the list here with "+" to use these queries and those in the config file.
48 | # queries: ./path/to/local/query, your-org/your-repo/queries@main
49 |
50 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
51 | # If this step fails, then you should remove it and run the build manually (see below)
52 | - name: Autobuild
53 | uses: github/codeql-action/autobuild@v1
54 |
55 | # ℹ️ Command-line programs to run using the OS shell.
56 | # 📚 https://git.io/JvXDl
57 |
58 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
59 | # and modify them (or add more) to build your code if your project
60 | # uses a compiled language
61 |
62 | #- run: |
63 | # make bootstrap
64 | # make release
65 |
66 | - name: Perform CodeQL Analysis
67 | uses: github/codeql-action/analyze@v1
68 |
--------------------------------------------------------------------------------
/.github/workflows/link_check.yml:
--------------------------------------------------------------------------------
1 | name: Check Markdown links
2 |
3 | on: push
4 |
5 | jobs:
6 | markdown-link-check:
7 | runs-on: ubuntu-latest
8 | steps:
9 | - uses: actions/checkout@master
10 | - uses: gaurav-nelson/github-action-markdown-link-check@v1
11 |
--------------------------------------------------------------------------------
/.github/workflows/run_tests.yml:
--------------------------------------------------------------------------------
1 | name: Run tests
2 |
3 | on: push
4 |
5 | jobs:
6 | run-addon-tests:
7 | runs-on: ${{ matrix.platform }}
8 | strategy:
9 | matrix:
10 | platform:
11 | - ubuntu-latest
12 | blender-version:
13 | - "4.2.3"
14 | steps:
15 | - uses: actions/checkout@master
16 | - name: Cache Blender ${{ matrix.blender-version }}
17 | uses: actions/cache@v4
18 | id: cache-blender
19 | with:
20 | path: |
21 | blender-*
22 | _blender-executable-path.txt
23 | key: ${{ runner.os }}-${{ matrix.blender-version }}
24 | - run: python -m pip install --upgrade pip
25 | name: Update PIP
26 | - name: Download Blender ${{ matrix.blender-version }}
27 | if: steps.cache-blender.outputs.cache-hit != 'true'
28 | id: download-blender
29 | run: |
30 | python -m pip install --upgrade blender-downloader
31 | printf "%s" "$(blender-downloader \
32 | ${{ matrix.blender-version }} --extract --remove-compressed \
33 | --quiet --print-blender-executable)" > _blender-executable-path.txt
34 | - id: install-dependencies
35 | name: Install Dependencies
36 | run: |
37 | pip install pytest-blender pytest
38 | blender_executable="$(< _blender-executable-path.txt)"
39 | python_blender_executable="$(pytest-blender --blender-executable $blender_executable)"
40 | $python_blender_executable -m ensurepip
41 | $python_blender_executable -m pip install pytest
42 | echo "blender-executable=$blender_executable" >> $GITHUB_OUTPUT
43 | - run: pytest --blender-executable "${{steps.install-dependencies.outputs.blender-executable}}" tests/
44 | name: Run Tests 🧪
45 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | __pycache__/
2 | *_updater/
3 | build/
4 |
5 | # Ignore workspace settings
6 | .vscode
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | ## Version 1.3.1 - 2021-06-29
4 | ### Other changes
5 | - Codestyle: Rename Addon to Super Project Manager, adjust class names
6 |
7 | ---
8 |
9 | ## Version 1.3.0 - 2021-06-27
10 | ### Features
11 | - Feature: Automatically Set Render Output Path
12 | - Feature: Folder Structure Sets [#14](https://github.com/PidgeonTools/SuperProjectManager/issues/14)
13 | - Feature: Hidden Project info file, as a base for these features:
14 | - - Open .blend file with one click
15 | - Feature: Update BPS.json to work with the new features.
16 |
17 | ### Fixes
18 | - Fix: Open Finder ([#16](https://github.com/PidgeonTools/SuperProjectManager/issues/16))
19 |
20 | ### Other changes
21 | - Improvement: Better icons
22 | - Improvement: Multiple subfolders in one Folder (Syntax: Folder>>((Subfolder1>>Subsubfolder))++Subfolder2)
23 | - Improvement: Project display:
24 | - - Display the number of unfinished projects (You've got n unfinished projects)
25 | - - Option to rearrange Projects
26 | - - Option to Sort Project in Categories
27 | - Improvement: Update subfolder enum without restart
28 |
29 | ---
30 |
31 | ## Version 1.2.0 - 2021-02-10
32 | ### Features
33 | - Feature: Add option to prefix folders with the project name.
34 | - Feature: Copy file to project/target folder, even if it already exists in another folder (Inform the user):
35 | - Copy File Option
36 | - Cut File Option
37 | - Get File name
38 | - New Name Option
39 | - Feature: Let the user decide, how many folders are in the Project Root Folder.
40 | - Feature: Mark Project as open/unfinished
41 |
42 | ### Fixes
43 | - Fix: Bring back property "Open Folder after Build"
44 | - Fix: Correct Version Numbers
45 | - Fix: Error when trying to build without specifying any Folders within the Root Folder
46 | - Fix: Error when trying to save to subfolder
47 | - Fix: Project Folder doesn't open on Linux
48 | - Fix: Subfolders aren't created on Linux
49 |
50 | ### Other changes
51 | - Codestyle: Enhance Codestyle
52 | - Codestyle: Rename Addon to Blender Project Manager, adjust class names
53 | - Updater: Restrict Minimal Version to 1.0.2 (Rename of branch)
54 | - Updater: Update Addon Updater to latest version
55 |
56 | ---
57 |
58 | ## Version 1.1.0 - 2021-02-02
59 | ### Features
60 | - Feature: Add Addon Updater for Easy Updating
61 | - Feature: Blender file can now be saved to one of the subfolders
62 | - Feature: Blender File Saving has been optimized and is now enabled by default
63 | - Feature: Default File Path can now be edited from the addons preferences
64 | - Feature: Version Counting has been implemented
65 |
66 | ### Fixes
67 | - Fix: Clarify, what the properties mean
68 | - Fix: Default File Path is now the Users Home-Directory
69 | - Fix: Enable Blender 2.83 support
70 | - Fix: File name field is only shown if the file isn't saved to any directory
71 | - Fix: File Path Layout for Subfolder-Paths is now Folder>>Subfolder>>Subsubfolder
72 | - Fix: Remove Social Media Buttons, add wiki and issue page instead
73 |
74 | ### Other Changes
75 | - Code style: Split up files and make the files easy to read
76 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | GNU GENERAL PUBLIC LICENSE
2 | Version 3, 29 June 2007
3 |
4 | Copyright (C) 2007 Free Software Foundation, Inc.
5 | Everyone is permitted to copy and distribute verbatim copies
6 | of this license document, but changing it is not allowed.
7 |
8 | Preamble
9 |
10 | The GNU General Public License is a free, copyleft license for
11 | software and other kinds of works.
12 |
13 | The licenses for most software and other practical works are designed
14 | to take away your freedom to share and change the works. By contrast,
15 | the GNU General Public License is intended to guarantee your freedom to
16 | share and change all versions of a program--to make sure it remains free
17 | software for all its users. We, the Free Software Foundation, use the
18 | GNU General Public License for most of our software; it applies also to
19 | any other work released this way by its authors. You can apply it to
20 | your programs, too.
21 |
22 | When we speak of free software, we are referring to freedom, not
23 | price. Our General Public Licenses are designed to make sure that you
24 | have the freedom to distribute copies of free software (and charge for
25 | them if you wish), that you receive source code or can get it if you
26 | want it, that you can change the software or use pieces of it in new
27 | free programs, and that you know you can do these things.
28 |
29 | To protect your rights, we need to prevent others from denying you
30 | these rights or asking you to surrender the rights. Therefore, you have
31 | certain responsibilities if you distribute copies of the software, or if
32 | you modify it: responsibilities to respect the freedom of others.
33 |
34 | For example, if you distribute copies of such a program, whether
35 | gratis or for a fee, you must pass on to the recipients the same
36 | freedoms that you received. You must make sure that they, too, receive
37 | or can get the source code. And you must show them these terms so they
38 | know their rights.
39 |
40 | Developers that use the GNU GPL protect your rights with two steps:
41 | (1) assert copyright on the software, and (2) offer you this License
42 | giving you legal permission to copy, distribute and/or modify it.
43 |
44 | For the developers' and authors' protection, the GPL clearly explains
45 | that there is no warranty for this free software. For both users' and
46 | authors' sake, the GPL requires that modified versions be marked as
47 | changed, so that their problems will not be attributed erroneously to
48 | authors of previous versions.
49 |
50 | Some devices are designed to deny users access to install or run
51 | modified versions of the software inside them, although the manufacturer
52 | can do so. This is fundamentally incompatible with the aim of
53 | protecting users' freedom to change the software. The systematic
54 | pattern of such abuse occurs in the area of products for individuals to
55 | use, which is precisely where it is most unacceptable. Therefore, we
56 | have designed this version of the GPL to prohibit the practice for those
57 | products. If such problems arise substantially in other domains, we
58 | stand ready to extend this provision to those domains in future versions
59 | of the GPL, as needed to protect the freedom of users.
60 |
61 | Finally, every program is threatened constantly by software patents.
62 | States should not allow patents to restrict development and use of
63 | software on general-purpose computers, but in those that do, we wish to
64 | avoid the special danger that patents applied to a free program could
65 | make it effectively proprietary. To prevent this, the GPL assures that
66 | patents cannot be used to render the program non-free.
67 |
68 | The precise terms and conditions for copying, distribution and
69 | modification follow.
70 |
71 | TERMS AND CONDITIONS
72 |
73 | 0. Definitions.
74 |
75 | "This License" refers to version 3 of the GNU General Public License.
76 |
77 | "Copyright" also means copyright-like laws that apply to other kinds of
78 | works, such as semiconductor masks.
79 |
80 | "The Program" refers to any copyrightable work licensed under this
81 | License. Each licensee is addressed as "you". "Licensees" and
82 | "recipients" may be individuals or organizations.
83 |
84 | To "modify" a work means to copy from or adapt all or part of the work
85 | in a fashion requiring copyright permission, other than the making of an
86 | exact copy. The resulting work is called a "modified version" of the
87 | earlier work or a work "based on" the earlier work.
88 |
89 | A "covered work" means either the unmodified Program or a work based
90 | on the Program.
91 |
92 | To "propagate" a work means to do anything with it that, without
93 | permission, would make you directly or secondarily liable for
94 | infringement under applicable copyright law, except executing it on a
95 | computer or modifying a private copy. Propagation includes copying,
96 | distribution (with or without modification), making available to the
97 | public, and in some countries other activities as well.
98 |
99 | To "convey" a work means any kind of propagation that enables other
100 | parties to make or receive copies. Mere interaction with a user through
101 | a computer network, with no transfer of a copy, is not conveying.
102 |
103 | An interactive user interface displays "Appropriate Legal Notices"
104 | to the extent that it includes a convenient and prominently visible
105 | feature that (1) displays an appropriate copyright notice, and (2)
106 | tells the user that there is no warranty for the work (except to the
107 | extent that warranties are provided), that licensees may convey the
108 | work under this License, and how to view a copy of this License. If
109 | the interface presents a list of user commands or options, such as a
110 | menu, a prominent item in the list meets this criterion.
111 |
112 | 1. Source Code.
113 |
114 | The "source code" for a work means the preferred form of the work
115 | for making modifications to it. "Object code" means any non-source
116 | form of a work.
117 |
118 | A "Standard Interface" means an interface that either is an official
119 | standard defined by a recognized standards body, or, in the case of
120 | interfaces specified for a particular programming language, one that
121 | is widely used among developers working in that language.
122 |
123 | The "System Libraries" of an executable work include anything, other
124 | than the work as a whole, that (a) is included in the normal form of
125 | packaging a Major Component, but which is not part of that Major
126 | Component, and (b) serves only to enable use of the work with that
127 | Major Component, or to implement a Standard Interface for which an
128 | implementation is available to the public in source code form. A
129 | "Major Component", in this context, means a major essential component
130 | (kernel, window system, and so on) of the specific operating system
131 | (if any) on which the executable work runs, or a compiler used to
132 | produce the work, or an object code interpreter used to run it.
133 |
134 | The "Corresponding Source" for a work in object code form means all
135 | the source code needed to generate, install, and (for an executable
136 | work) run the object code and to modify the work, including scripts to
137 | control those activities. However, it does not include the work's
138 | System Libraries, or general-purpose tools or generally available free
139 | programs which are used unmodified in performing those activities but
140 | which are not part of the work. For example, Corresponding Source
141 | includes interface definition files associated with source files for
142 | the work, and the source code for shared libraries and dynamically
143 | linked subprograms that the work is specifically designed to require,
144 | such as by intimate data communication or control flow between those
145 | subprograms and other parts of the work.
146 |
147 | The Corresponding Source need not include anything that users
148 | can regenerate automatically from other parts of the Corresponding
149 | Source.
150 |
151 | The Corresponding Source for a work in source code form is that
152 | same work.
153 |
154 | 2. Basic Permissions.
155 |
156 | All rights granted under this License are granted for the term of
157 | copyright on the Program, and are irrevocable provided the stated
158 | conditions are met. This License explicitly affirms your unlimited
159 | permission to run the unmodified Program. The output from running a
160 | covered work is covered by this License only if the output, given its
161 | content, constitutes a covered work. This License acknowledges your
162 | rights of fair use or other equivalent, as provided by copyright law.
163 |
164 | You may make, run and propagate covered works that you do not
165 | convey, without conditions so long as your license otherwise remains
166 | in force. You may convey covered works to others for the sole purpose
167 | of having them make modifications exclusively for you, or provide you
168 | with facilities for running those works, provided that you comply with
169 | the terms of this License in conveying all material for which you do
170 | not control copyright. Those thus making or running the covered works
171 | for you must do so exclusively on your behalf, under your direction
172 | and control, on terms that prohibit them from making any copies of
173 | your copyrighted material outside their relationship with you.
174 |
175 | Conveying under any other circumstances is permitted solely under
176 | the conditions stated below. Sublicensing is not allowed; section 10
177 | makes it unnecessary.
178 |
179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
180 |
181 | No covered work shall be deemed part of an effective technological
182 | measure under any applicable law fulfilling obligations under article
183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or
184 | similar laws prohibiting or restricting circumvention of such
185 | measures.
186 |
187 | When you convey a covered work, you waive any legal power to forbid
188 | circumvention of technological measures to the extent such circumvention
189 | is effected by exercising rights under this License with respect to
190 | the covered work, and you disclaim any intention to limit operation or
191 | modification of the work as a means of enforcing, against the work's
192 | users, your or third parties' legal rights to forbid circumvention of
193 | technological measures.
194 |
195 | 4. Conveying Verbatim Copies.
196 |
197 | You may convey verbatim copies of the Program's source code as you
198 | receive it, in any medium, provided that you conspicuously and
199 | appropriately publish on each copy an appropriate copyright notice;
200 | keep intact all notices stating that this License and any
201 | non-permissive terms added in accord with section 7 apply to the code;
202 | keep intact all notices of the absence of any warranty; and give all
203 | recipients a copy of this License along with the Program.
204 |
205 | You may charge any price or no price for each copy that you convey,
206 | and you may offer support or warranty protection for a fee.
207 |
208 | 5. Conveying Modified Source Versions.
209 |
210 | You may convey a work based on the Program, or the modifications to
211 | produce it from the Program, in the form of source code under the
212 | terms of section 4, provided that you also meet all of these conditions:
213 |
214 | a) The work must carry prominent notices stating that you modified
215 | it, and giving a relevant date.
216 |
217 | b) The work must carry prominent notices stating that it is
218 | released under this License and any conditions added under section
219 | 7. This requirement modifies the requirement in section 4 to
220 | "keep intact all notices".
221 |
222 | c) You must license the entire work, as a whole, under this
223 | License to anyone who comes into possession of a copy. This
224 | License will therefore apply, along with any applicable section 7
225 | additional terms, to the whole of the work, and all its parts,
226 | regardless of how they are packaged. This License gives no
227 | permission to license the work in any other way, but it does not
228 | invalidate such permission if you have separately received it.
229 |
230 | d) If the work has interactive user interfaces, each must display
231 | Appropriate Legal Notices; however, if the Program has interactive
232 | interfaces that do not display Appropriate Legal Notices, your
233 | work need not make them do so.
234 |
235 | A compilation of a covered work with other separate and independent
236 | works, which are not by their nature extensions of the covered work,
237 | and which are not combined with it such as to form a larger program,
238 | in or on a volume of a storage or distribution medium, is called an
239 | "aggregate" if the compilation and its resulting copyright are not
240 | used to limit the access or legal rights of the compilation's users
241 | beyond what the individual works permit. Inclusion of a covered work
242 | in an aggregate does not cause this License to apply to the other
243 | parts of the aggregate.
244 |
245 | 6. Conveying Non-Source Forms.
246 |
247 | You may convey a covered work in object code form under the terms
248 | of sections 4 and 5, provided that you also convey the
249 | machine-readable Corresponding Source under the terms of this License,
250 | in one of these ways:
251 |
252 | a) Convey the object code in, or embodied in, a physical product
253 | (including a physical distribution medium), accompanied by the
254 | Corresponding Source fixed on a durable physical medium
255 | customarily used for software interchange.
256 |
257 | b) Convey the object code in, or embodied in, a physical product
258 | (including a physical distribution medium), accompanied by a
259 | written offer, valid for at least three years and valid for as
260 | long as you offer spare parts or customer support for that product
261 | model, to give anyone who possesses the object code either (1) a
262 | copy of the Corresponding Source for all the software in the
263 | product that is covered by this License, on a durable physical
264 | medium customarily used for software interchange, for a price no
265 | more than your reasonable cost of physically performing this
266 | conveying of source, or (2) access to copy the
267 | Corresponding Source from a network server at no charge.
268 |
269 | c) Convey individual copies of the object code with a copy of the
270 | written offer to provide the Corresponding Source. This
271 | alternative is allowed only occasionally and noncommercially, and
272 | only if you received the object code with such an offer, in accord
273 | with subsection 6b.
274 |
275 | d) Convey the object code by offering access from a designated
276 | place (gratis or for a charge), and offer equivalent access to the
277 | Corresponding Source in the same way through the same place at no
278 | further charge. You need not require recipients to copy the
279 | Corresponding Source along with the object code. If the place to
280 | copy the object code is a network server, the Corresponding Source
281 | may be on a different server (operated by you or a third party)
282 | that supports equivalent copying facilities, provided you maintain
283 | clear directions next to the object code saying where to find the
284 | Corresponding Source. Regardless of what server hosts the
285 | Corresponding Source, you remain obligated to ensure that it is
286 | available for as long as needed to satisfy these requirements.
287 |
288 | e) Convey the object code using peer-to-peer transmission, provided
289 | you inform other peers where the object code and Corresponding
290 | Source of the work are being offered to the general public at no
291 | charge under subsection 6d.
292 |
293 | A separable portion of the object code, whose source code is excluded
294 | from the Corresponding Source as a System Library, need not be
295 | included in conveying the object code work.
296 |
297 | A "User Product" is either (1) a "consumer product", which means any
298 | tangible personal property which is normally used for personal, family,
299 | or household purposes, or (2) anything designed or sold for incorporation
300 | into a dwelling. In determining whether a product is a consumer product,
301 | doubtful cases shall be resolved in favor of coverage. For a particular
302 | product received by a particular user, "normally used" refers to a
303 | typical or common use of that class of product, regardless of the status
304 | of the particular user or of the way in which the particular user
305 | actually uses, or expects or is expected to use, the product. A product
306 | is a consumer product regardless of whether the product has substantial
307 | commercial, industrial or non-consumer uses, unless such uses represent
308 | the only significant mode of use of the product.
309 |
310 | "Installation Information" for a User Product means any methods,
311 | procedures, authorization keys, or other information required to install
312 | and execute modified versions of a covered work in that User Product from
313 | a modified version of its Corresponding Source. The information must
314 | suffice to ensure that the continued functioning of the modified object
315 | code is in no case prevented or interfered with solely because
316 | modification has been made.
317 |
318 | If you convey an object code work under this section in, or with, or
319 | specifically for use in, a User Product, and the conveying occurs as
320 | part of a transaction in which the right of possession and use of the
321 | User Product is transferred to the recipient in perpetuity or for a
322 | fixed term (regardless of how the transaction is characterized), the
323 | Corresponding Source conveyed under this section must be accompanied
324 | by the Installation Information. But this requirement does not apply
325 | if neither you nor any third party retains the ability to install
326 | modified object code on the User Product (for example, the work has
327 | been installed in ROM).
328 |
329 | The requirement to provide Installation Information does not include a
330 | requirement to continue to provide support service, warranty, or updates
331 | for a work that has been modified or installed by the recipient, or for
332 | the User Product in which it has been modified or installed. Access to a
333 | network may be denied when the modification itself materially and
334 | adversely affects the operation of the network or violates the rules and
335 | protocols for communication across the network.
336 |
337 | Corresponding Source conveyed, and Installation Information provided,
338 | in accord with this section must be in a format that is publicly
339 | documented (and with an implementation available to the public in
340 | source code form), and must require no special password or key for
341 | unpacking, reading or copying.
342 |
343 | 7. Additional Terms.
344 |
345 | "Additional permissions" are terms that supplement the terms of this
346 | License by making exceptions from one or more of its conditions.
347 | Additional permissions that are applicable to the entire Program shall
348 | be treated as though they were included in this License, to the extent
349 | that they are valid under applicable law. If additional permissions
350 | apply only to part of the Program, that part may be used separately
351 | under those permissions, but the entire Program remains governed by
352 | this License without regard to the additional permissions.
353 |
354 | When you convey a copy of a covered work, you may at your option
355 | remove any additional permissions from that copy, or from any part of
356 | it. (Additional permissions may be written to require their own
357 | removal in certain cases when you modify the work.) You may place
358 | additional permissions on material, added by you to a covered work,
359 | for which you have or can give appropriate copyright permission.
360 |
361 | Notwithstanding any other provision of this License, for material you
362 | add to a covered work, you may (if authorized by the copyright holders of
363 | that material) supplement the terms of this License with terms:
364 |
365 | a) Disclaiming warranty or limiting liability differently from the
366 | terms of sections 15 and 16 of this License; or
367 |
368 | b) Requiring preservation of specified reasonable legal notices or
369 | author attributions in that material or in the Appropriate Legal
370 | Notices displayed by works containing it; or
371 |
372 | c) Prohibiting misrepresentation of the origin of that material, or
373 | requiring that modified versions of such material be marked in
374 | reasonable ways as different from the original version; or
375 |
376 | d) Limiting the use for publicity purposes of names of licensors or
377 | authors of the material; or
378 |
379 | e) Declining to grant rights under trademark law for use of some
380 | trade names, trademarks, or service marks; or
381 |
382 | f) Requiring indemnification of licensors and authors of that
383 | material by anyone who conveys the material (or modified versions of
384 | it) with contractual assumptions of liability to the recipient, for
385 | any liability that these contractual assumptions directly impose on
386 | those licensors and authors.
387 |
388 | All other non-permissive additional terms are considered "further
389 | restrictions" within the meaning of section 10. If the Program as you
390 | received it, or any part of it, contains a notice stating that it is
391 | governed by this License along with a term that is a further
392 | restriction, you may remove that term. If a license document contains
393 | a further restriction but permits relicensing or conveying under this
394 | License, you may add to a covered work material governed by the terms
395 | of that license document, provided that the further restriction does
396 | not survive such relicensing or conveying.
397 |
398 | If you add terms to a covered work in accord with this section, you
399 | must place, in the relevant source files, a statement of the
400 | additional terms that apply to those files, or a notice indicating
401 | where to find the applicable terms.
402 |
403 | Additional terms, permissive or non-permissive, may be stated in the
404 | form of a separately written license, or stated as exceptions;
405 | the above requirements apply either way.
406 |
407 | 8. Termination.
408 |
409 | You may not propagate or modify a covered work except as expressly
410 | provided under this License. Any attempt otherwise to propagate or
411 | modify it is void, and will automatically terminate your rights under
412 | this License (including any patent licenses granted under the third
413 | paragraph of section 11).
414 |
415 | However, if you cease all violation of this License, then your
416 | license from a particular copyright holder is reinstated (a)
417 | provisionally, unless and until the copyright holder explicitly and
418 | finally terminates your license, and (b) permanently, if the copyright
419 | holder fails to notify you of the violation by some reasonable means
420 | prior to 60 days after the cessation.
421 |
422 | Moreover, your license from a particular copyright holder is
423 | reinstated permanently if the copyright holder notifies you of the
424 | violation by some reasonable means, this is the first time you have
425 | received notice of violation of this License (for any work) from that
426 | copyright holder, and you cure the violation prior to 30 days after
427 | your receipt of the notice.
428 |
429 | Termination of your rights under this section does not terminate the
430 | licenses of parties who have received copies or rights from you under
431 | this License. If your rights have been terminated and not permanently
432 | reinstated, you do not qualify to receive new licenses for the same
433 | material under section 10.
434 |
435 | 9. Acceptance Not Required for Having Copies.
436 |
437 | You are not required to accept this License in order to receive or
438 | run a copy of the Program. Ancillary propagation of a covered work
439 | occurring solely as a consequence of using peer-to-peer transmission
440 | to receive a copy likewise does not require acceptance. However,
441 | nothing other than this License grants you permission to propagate or
442 | modify any covered work. These actions infringe copyright if you do
443 | not accept this License. Therefore, by modifying or propagating a
444 | covered work, you indicate your acceptance of this License to do so.
445 |
446 | 10. Automatic Licensing of Downstream Recipients.
447 |
448 | Each time you convey a covered work, the recipient automatically
449 | receives a license from the original licensors, to run, modify and
450 | propagate that work, subject to this License. You are not responsible
451 | for enforcing compliance by third parties with this License.
452 |
453 | An "entity transaction" is a transaction transferring control of an
454 | organization, or substantially all assets of one, or subdividing an
455 | organization, or merging organizations. If propagation of a covered
456 | work results from an entity transaction, each party to that
457 | transaction who receives a copy of the work also receives whatever
458 | licenses to the work the party's predecessor in interest had or could
459 | give under the previous paragraph, plus a right to possession of the
460 | Corresponding Source of the work from the predecessor in interest, if
461 | the predecessor has it or can get it with reasonable efforts.
462 |
463 | You may not impose any further restrictions on the exercise of the
464 | rights granted or affirmed under this License. For example, you may
465 | not impose a license fee, royalty, or other charge for exercise of
466 | rights granted under this License, and you may not initiate litigation
467 | (including a cross-claim or counterclaim in a lawsuit) alleging that
468 | any patent claim is infringed by making, using, selling, offering for
469 | sale, or importing the Program or any portion of it.
470 |
471 | 11. Patents.
472 |
473 | A "contributor" is a copyright holder who authorizes use under this
474 | License of the Program or a work on which the Program is based. The
475 | work thus licensed is called the contributor's "contributor version".
476 |
477 | A contributor's "essential patent claims" are all patent claims
478 | owned or controlled by the contributor, whether already acquired or
479 | hereafter acquired, that would be infringed by some manner, permitted
480 | by this License, of making, using, or selling its contributor version,
481 | but do not include claims that would be infringed only as a
482 | consequence of further modification of the contributor version. For
483 | purposes of this definition, "control" includes the right to grant
484 | patent sublicenses in a manner consistent with the requirements of
485 | this License.
486 |
487 | Each contributor grants you a non-exclusive, worldwide, royalty-free
488 | patent license under the contributor's essential patent claims, to
489 | make, use, sell, offer for sale, import and otherwise run, modify and
490 | propagate the contents of its contributor version.
491 |
492 | In the following three paragraphs, a "patent license" is any express
493 | agreement or commitment, however denominated, not to enforce a patent
494 | (such as an express permission to practice a patent or covenant not to
495 | sue for patent infringement). To "grant" such a patent license to a
496 | party means to make such an agreement or commitment not to enforce a
497 | patent against the party.
498 |
499 | If you convey a covered work, knowingly relying on a patent license,
500 | and the Corresponding Source of the work is not available for anyone
501 | to copy, free of charge and under the terms of this License, through a
502 | publicly available network server or other readily accessible means,
503 | then you must either (1) cause the Corresponding Source to be so
504 | available, or (2) arrange to deprive yourself of the benefit of the
505 | patent license for this particular work, or (3) arrange, in a manner
506 | consistent with the requirements of this License, to extend the patent
507 | license to downstream recipients. "Knowingly relying" means you have
508 | actual knowledge that, but for the patent license, your conveying the
509 | covered work in a country, or your recipient's use of the covered work
510 | in a country, would infringe one or more identifiable patents in that
511 | country that you have reason to believe are valid.
512 |
513 | If, pursuant to or in connection with a single transaction or
514 | arrangement, you convey, or propagate by procuring conveyance of, a
515 | covered work, and grant a patent license to some of the parties
516 | receiving the covered work authorizing them to use, propagate, modify
517 | or convey a specific copy of the covered work, then the patent license
518 | you grant is automatically extended to all recipients of the covered
519 | work and works based on it.
520 |
521 | A patent license is "discriminatory" if it does not include within
522 | the scope of its coverage, prohibits the exercise of, or is
523 | conditioned on the non-exercise of one or more of the rights that are
524 | specifically granted under this License. You may not convey a covered
525 | work if you are a party to an arrangement with a third party that is
526 | in the business of distributing software, under which you make payment
527 | to the third party based on the extent of your activity of conveying
528 | the work, and under which the third party grants, to any of the
529 | parties who would receive the covered work from you, a discriminatory
530 | patent license (a) in connection with copies of the covered work
531 | conveyed by you (or copies made from those copies), or (b) primarily
532 | for and in connection with specific products or compilations that
533 | contain the covered work, unless you entered into that arrangement,
534 | or that patent license was granted, prior to 28 March 2007.
535 |
536 | Nothing in this License shall be construed as excluding or limiting
537 | any implied license or other defenses to infringement that may
538 | otherwise be available to you under applicable patent law.
539 |
540 | 12. No Surrender of Others' Freedom.
541 |
542 | If conditions are imposed on you (whether by court order, agreement or
543 | otherwise) that contradict the conditions of this License, they do not
544 | excuse you from the conditions of this License. If you cannot convey a
545 | covered work so as to satisfy simultaneously your obligations under this
546 | License and any other pertinent obligations, then as a consequence you may
547 | not convey it at all. For example, if you agree to terms that obligate you
548 | to collect a royalty for further conveying from those to whom you convey
549 | the Program, the only way you could satisfy both those terms and this
550 | License would be to refrain entirely from conveying the Program.
551 |
552 | 13. Use with the GNU Affero General Public License.
553 |
554 | Notwithstanding any other provision of this License, you have
555 | permission to link or combine any covered work with a work licensed
556 | under version 3 of the GNU Affero General Public License into a single
557 | combined work, and to convey the resulting work. The terms of this
558 | License will continue to apply to the part which is the covered work,
559 | but the special requirements of the GNU Affero General Public License,
560 | section 13, concerning interaction through a network will apply to the
561 | combination as such.
562 |
563 | 14. Revised Versions of this License.
564 |
565 | The Free Software Foundation may publish revised and/or new versions of
566 | the GNU General Public License from time to time. Such new versions will
567 | be similar in spirit to the present version, but may differ in detail to
568 | address new problems or concerns.
569 |
570 | Each version is given a distinguishing version number. If the
571 | Program specifies that a certain numbered version of the GNU General
572 | Public License "or any later version" applies to it, you have the
573 | option of following the terms and conditions either of that numbered
574 | version or of any later version published by the Free Software
575 | Foundation. If the Program does not specify a version number of the
576 | GNU General Public License, you may choose any version ever published
577 | by the Free Software Foundation.
578 |
579 | If the Program specifies that a proxy can decide which future
580 | versions of the GNU General Public License can be used, that proxy's
581 | public statement of acceptance of a version permanently authorizes you
582 | to choose that version for the Program.
583 |
584 | Later license versions may give you additional or different
585 | permissions. However, no additional obligations are imposed on any
586 | author or copyright holder as a result of your choosing to follow a
587 | later version.
588 |
589 | 15. Disclaimer of Warranty.
590 |
591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
599 |
600 | 16. Limitation of Liability.
601 |
602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
610 | SUCH DAMAGES.
611 |
612 | 17. Interpretation of Sections 15 and 16.
613 |
614 | If the disclaimer of warranty and limitation of liability provided
615 | above cannot be given local legal effect according to their terms,
616 | reviewing courts shall apply local law that most closely approximates
617 | an absolute waiver of all civil liability in connection with the
618 | Program, unless a warranty or assumption of liability accompanies a
619 | copy of the Program in return for a fee.
620 |
621 | END OF TERMS AND CONDITIONS
622 |
623 | How to Apply These Terms to Your New Programs
624 |
625 | If you develop a new program, and you want it to be of the greatest
626 | possible use to the public, the best way to achieve this is to make it
627 | free software which everyone can redistribute and change under these terms.
628 |
629 | To do so, attach the following notices to the program. It is safest
630 | to attach them to the start of each source file to most effectively
631 | state the exclusion of warranty; and each file should have at least
632 | the "copyright" line and a pointer to where the full notice is found.
633 |
634 |
635 | Copyright (C)
636 |
637 | This program is free software: you can redistribute it and/or modify
638 | it under the terms of the GNU General Public License as published by
639 | the Free Software Foundation, either version 3 of the License, or
640 | (at your option) any later version.
641 |
642 | This program is distributed in the hope that it will be useful,
643 | but WITHOUT ANY WARRANTY; without even the implied warranty of
644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
645 | GNU General Public License for more details.
646 |
647 | You should have received a copy of the GNU General Public License
648 | along with this program. If not, see .
649 |
650 | Also add information on how to contact you by electronic and paper mail.
651 |
652 | If the program does terminal interaction, make it output a short
653 | notice like this when it starts in an interactive mode:
654 |
655 | Copyright (C)
656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
657 | This is free software, and you are welcome to redistribute it
658 | under certain conditions; type `show c' for details.
659 |
660 | The hypothetical commands `show w' and `show c' should show the appropriate
661 | parts of the General Public License. Of course, your program's commands
662 | might be different; for a GUI interface, you would use an "about box".
663 |
664 | You should also get your employer (if you work as a programmer) or school,
665 | if any, to sign a "copyright disclaimer" for the program, if necessary.
666 | For more information on this, and how to apply and follow the GNU GPL, see
667 | .
668 |
669 | The GNU General Public License does not permit incorporating your program
670 | into proprietary programs. If your program is a subroutine library, you
671 | may consider it more useful to permit linking proprietary applications with
672 | the library. If this is what you want to do, use the GNU Lesser General
673 | Public License instead of this License. But first, please read
674 | .
675 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [](https://blendermarket.com/products/superprojectmanager)
2 | 
3 | [](https://github.com/PidgeonTools/SuperProjectManager/issues)
4 | 
5 |
6 | # Super Project Manager
7 |
8 | Managing your unfinished Projects streamlined. Features:
9 |
10 | - Create your Project Folders consistently. With the click of a button, all your project folders are
11 | created and the Blender File is saved into the right folder.
12 | - Automatically append a version number to your Blender File, if needed.
13 | - Display your unfinished Projects in Blender, so you'll never forget about them.
14 | Check out the [wiki](https://github.com/PidgeonTools/SuperProjectManager/wiki), if you want to [learn more](https://github.com/PidgeonTools/SuperProjectManager/wiki)
15 |
16 | ## Upcoming Version:
17 |
18 | - [ ] Improvement: Finish project dialogue
19 | - [ ] Improvement: Clean up the user interface
20 |
21 |
22 |
23 |
24 |
25 |
26 |
31 |
32 |
33 |
34 | ## System requirements:
35 |
36 | | **OS** | **Blender** |
37 | | ------- | -------------------------------------------------- |
38 | | OSX | Testing, please give feedback if it works for you. |
39 | | Windows | Blender 2.83+ |
40 | | Linux | Blender 2.83+ |
41 |
42 | Got questions? [Join the Discord](https://bd-links.netlify.app/discord-spm)
--------------------------------------------------------------------------------
/SUPPORTER.md:
--------------------------------------------------------------------------------
1 | # Supporters of Super Project Manager, sorted by the amount they donated:
2 |
--------------------------------------------------------------------------------
/__init__.py:
--------------------------------------------------------------------------------
1 | # ##### BEGIN GPL LICENSE BLOCK #####
2 | #
3 | #
4 | # Copyright (C) <2023>
5 | #
6 | # This program is free software; you can redistribute it and/or
7 | # modify it under the terms of the GNU General Public License
8 | # as published by the Free Software Foundation; either version 3
9 | # of the License, or (at your option) any later version.
10 | #
11 | # This program is distributed in the hope that it will be useful,
12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 | # GNU General Public License for more details.
15 | #
16 | # You should have received a copy of the GNU General Public License
17 | # along with this program; if not, write to the Free Software Foundation,
18 | # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19 | #
20 | # ##### END GPL LICENSE BLOCK #####
21 |
22 | import bpy
23 |
24 | import os
25 | from os import path as p
26 | import shutil
27 |
28 | from .functions.register_functions import (
29 | register_properties,
30 | )
31 |
32 | from .functions.blenderdefender_functions import (
33 | setup_addons_data,
34 | )
35 |
36 | from . import (
37 | operators,
38 | prefs,
39 | panels
40 | )
41 |
42 | bl_info = {
43 | "name": "Super Project Manager (SPM)",
44 | "description": "Manage and setup your projects the easy way!",
45 | "author": "Blender Defender",
46 | "version": (1, 3, 1),
47 | "blender": (2, 83, 0),
48 | "location": "Properties >> Scene Properties >> Super Project Manager",
49 | "warning": "",
50 | "doc_url": "https://github.com/PidgeonTools/SuperProjectManager/wiki",
51 | "tracker_url": "https://github.com/PidgeonTools/SuperProjectManager/issues",
52 | "endpoint_url": "https://raw.githubusercontent.com/PidgeonTools/SAM-Endpoints/main/SuperProjectManager.json",
53 | "category": "System"
54 | }
55 |
56 |
57 | def register():
58 | setup_addons_data()
59 |
60 | if bpy.app.version < (4, 2):
61 | prefs.legacy_register(bl_info)
62 | else:
63 | prefs.register()
64 |
65 | operators.register()
66 | panels.register()
67 |
68 | register_properties()
69 |
70 |
71 | def unregister():
72 | operators.unregister()
73 | prefs.unregister()
74 | panels.unregister()
75 |
--------------------------------------------------------------------------------
/addon_types/__init__.py:
--------------------------------------------------------------------------------
1 | import typing
2 |
3 |
4 | class AddonPreferences():
5 | previous_set: str
6 | """The previous folder structure set, defaults to 'Default Folder Set'"""
7 |
8 | custom_folders: typing.List['ProjectFolderProps']
9 |
10 | automatic_folders: typing.List['ProjectFolderProps']
11 |
12 | active_project: str
13 | """The path to the project, that is currently displayed in the filebrowser panel"""
14 |
15 | project_paths: list # CollectionProperty(type=FilebrowserEntry)
16 | active_project_path: int
17 | # IntProperty(
18 | # name="Custom Property",
19 | # get=get_active_project_path,
20 | # set=set_active_project_path
21 | # )
22 |
23 | layout_tab: tuple
24 | # layout_tab: EnumProperty(
25 | # name="UI Section",
26 | # description="Display the different UI Elements of the Super Project Manager preferences.",
27 | # items=[
28 | # ("misc_settings", "General", "General settings of Super Project Manager."),
29 | # ("folder_structure_sets", "Folder Structures",
30 | # "Manage your folder structure settings."),
31 | # ("updater", "Updater", "Check for updates and install them."),
32 | # ],
33 | # default="misc_settings")
34 |
35 | folder_structure_sets: str
36 | # folder_structure_sets: EnumProperty(
37 | # name="Folder Structure Set",
38 | # description="A list of all available folder sets.",
39 | # items=structure_sets_enum,
40 | # update=structure_sets_enum_update
41 | # )
42 |
43 | prefix_with_project_name: bool
44 | """Whether to use the project name as prefix for all folders,
45 | defaults to False"""
46 |
47 | auto_set_render_outputpath: bool
48 | """Whether to use the feature for automatically setting the Render Output path,
49 | defaults to False"""
50 |
51 | default_project_location: str
52 | """The default Project Location,
53 | defaults to p.expanduser("~")"""
54 |
55 | save_folder: tuple
56 | """Where to save the blend file."""
57 |
58 | preview_subfolders: bool
59 | """Show the compiled subfolder-strings in the preferences,
60 | defaults to False"""
61 |
62 | enable_additional_rearrange_tools: bool
63 | """Show the "Move to top" and "Move to bottom" operators in the rearrange panel,
64 | defaults to False"""
65 |
66 |
67 | class ProjectFolderProps():
68 |
69 | render_outputpath: bool
70 | """If this folder input is used for setting the output path for your renders,
71 | defaults to False)"""
72 |
73 | folder_name: str
74 | """The folder name/path for a folder.
75 | defaults to ''"""
76 |
--------------------------------------------------------------------------------
/addon_updater_ops.py:
--------------------------------------------------------------------------------
1 | # ##### BEGIN GPL LICENSE BLOCK #####
2 | #
3 | # This program is free software; you can redistribute it and/or
4 | # modify it under the terms of the GNU General Public License
5 | # as published by the Free Software Foundation; either version 2
6 | # of the License, or (at your option) any later version.
7 | #
8 | # This program is distributed in the hope that it will be useful,
9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 | # GNU General Public License for more details.
12 | #
13 | # You should have received a copy of the GNU General Public License
14 | # along with this program; if not, write to the Free Software Foundation,
15 | # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 | #
17 | # ##### END GPL LICENSE BLOCK #####
18 |
19 | """Blender UI integrations for the addon updater.
20 |
21 | Implements draw calls, popups, and operators that use the addon_updater.
22 | """
23 |
24 | import os
25 |
26 | import bpy
27 | from bpy.app.handlers import persistent
28 |
29 | # updater import, import safely
30 | # Prevents popups for users with invalid python installs e.g. missing libraries
31 | try:
32 | from .addon_updater import Updater as updater
33 | except Exception as e:
34 | print("ERROR INITIALIZING UPDATER")
35 | print(str(e))
36 |
37 | class Singleton_updater_none(object):
38 | def __init__(self):
39 | self.addon = None
40 | self.verbose = False
41 | self.invalidupdater = True # used to distinguish bad install
42 | self.error = None
43 | self.error_msg = None
44 | self.async_checking = None
45 |
46 | def clear_state(self):
47 | self.addon = None
48 | self.verbose = False
49 | self.invalidupdater = True
50 | self.error = None
51 | self.error_msg = None
52 | self.async_checking = None
53 |
54 | def run_update(self): pass
55 | def check_for_update(self): pass
56 | updater = Singleton_updater_none()
57 | updater.error = "Error initializing updater module"
58 | updater.error_msg = str(e)
59 |
60 | # Must declare this before classes are loaded
61 | # otherwise the bl_idname's will not match and have errors.
62 | # Must be all lowercase and no spaces
63 | updater.addon = "super_project_manager"
64 |
65 |
66 | # -----------------------------------------------------------------------------
67 | # Blender version utils
68 | # -----------------------------------------------------------------------------
69 |
70 |
71 | def make_annotations(cls):
72 | """Add annotation attribute to class fields to avoid Blender 2.8 warnings"""
73 | if not hasattr(bpy.app, "version") or bpy.app.version < (2, 80):
74 | return cls
75 | bl_props = {k: v for k, v in cls.__dict__.items() if isinstance(v, tuple)}
76 | if bl_props:
77 | if '__annotations__' not in cls.__dict__:
78 | setattr(cls, '__annotations__', {})
79 | annotations = cls.__dict__['__annotations__']
80 | for k, v in bl_props.items():
81 | annotations[k] = v
82 | delattr(cls, k)
83 | return cls
84 |
85 |
86 | def layout_split(layout, factor=0.0, align=False):
87 | """Intermediate method for pre and post blender 2.8 split UI function"""
88 | if not hasattr(bpy.app, "version") or bpy.app.version < (2, 80):
89 | return layout.split(percentage=factor, align=align)
90 | return layout.split(factor=factor, align=align)
91 |
92 |
93 | def get_user_preferences(context=None):
94 | """Intermediate method for pre and post blender 2.8 grabbing preferences"""
95 | if not context:
96 | context = bpy.context
97 | prefs = None
98 | if hasattr(context, "user_preferences"):
99 | prefs = context.user_preferences.addons.get(__package__, None)
100 | elif hasattr(context, "preferences"):
101 | prefs = context.preferences.addons.get(__package__, None)
102 | if prefs:
103 | return prefs.preferences
104 | # To make the addon stable and non-exception prone, return None
105 | # raise Exception("Could not fetch user preferences")
106 | return None
107 |
108 |
109 | # -----------------------------------------------------------------------------
110 | # Updater operators
111 | # -----------------------------------------------------------------------------
112 |
113 |
114 | # simple popup for prompting checking for update & allow to install if available
115 | class addon_updater_install_popup(bpy.types.Operator):
116 | """Check and install update if available"""
117 | bl_label = "Update {x} addon".format(x=updater.addon)
118 | bl_idname = updater.addon+".updater_install_popup"
119 | bl_description = "Popup menu to check and display current updates available"
120 | bl_options = {'REGISTER', 'INTERNAL'}
121 |
122 | # if true, run clean install - ie remove all files before adding new
123 | # equivalent to deleting the addon and reinstalling, except the
124 | # updater folder/backup folder remains
125 | clean_install = bpy.props.BoolProperty(
126 | name="Clean install",
127 | description="If enabled, completely clear the addon's folder before installing new update, creating a fresh install",
128 | default=False,
129 | options={'HIDDEN'}
130 | )
131 | ignore_enum: bpy.props.EnumProperty(
132 | name="Process update",
133 | description="Decide to install, ignore, or defer new addon update",
134 | items=[
135 | ("install", "Update Now", "Install update now"),
136 | ("ignore", "Ignore", "Ignore this update to prevent future popups"),
137 | ("defer", "Defer", "Defer choice till next blender session")
138 | ],
139 | options={'HIDDEN'}
140 | )
141 |
142 | def check(self, context):
143 | return True
144 |
145 | def invoke(self, context, event):
146 | return context.window_manager.invoke_props_dialog(self)
147 |
148 | def draw(self, context):
149 | layout = self.layout
150 | if updater.invalidupdater == True:
151 | layout.label(text="Updater module error")
152 | return
153 | elif updater.update_ready == True:
154 | col = layout.column()
155 | col.scale_y = 0.7
156 | col.label(text="Update {} ready!".format(str(updater.update_version)),
157 | icon="LOOP_FORWARDS")
158 | col.label(
159 | text="Choose 'Update Now' & press OK to install, ", icon="BLANK1")
160 | col.label(text="or click outside window to defer", icon="BLANK1")
161 | row = col.row()
162 | row.prop(self, "ignore_enum", expand=True)
163 | col.split()
164 | elif updater.update_ready == False:
165 | col = layout.column()
166 | col.scale_y = 0.7
167 | col.label(text="No updates available")
168 | col.label(text="Press okay to dismiss dialog")
169 | # add option to force install
170 | else:
171 | # case: updater.update_ready = None
172 | # we have not yet checked for the update
173 | layout.label(text="Check for update now?")
174 |
175 | # potentially in future, could have UI for 'check to select old version'
176 | # to revert back to.
177 |
178 | def execute(self, context):
179 |
180 | # in case of error importing updater
181 | if updater.invalidupdater == True:
182 | return {'CANCELLED'}
183 |
184 | if updater.manual_only == True:
185 | bpy.ops.wm.url_open(url=updater.website)
186 | elif updater.update_ready == True:
187 |
188 | # action based on enum selection
189 | if self.ignore_enum == 'defer':
190 | return {'FINISHED'}
191 | elif self.ignore_enum == 'ignore':
192 | updater.ignore_update()
193 | return {'FINISHED'}
194 | # else: "install update now!"
195 |
196 | res = updater.run_update(
197 | force=False,
198 | callback=post_update_callback,
199 | clean=self.clean_install)
200 | # should return 0, if not something happened
201 | if updater.verbose:
202 | if res == 0:
203 | print("Updater returned successful")
204 | else:
205 | print("Updater returned {}, error occurred".format(res))
206 | elif updater.update_ready == None:
207 | _ = updater.check_for_update(now=True)
208 |
209 | # re-launch this dialog
210 | atr = addon_updater_install_popup.bl_idname.split(".")
211 | getattr(getattr(bpy.ops, atr[0]), atr[1])('INVOKE_DEFAULT')
212 | else:
213 | if updater.verbose:
214 | print("Doing nothing, not ready for update")
215 | return {'FINISHED'}
216 |
217 |
218 | # User preference check-now operator
219 | class addon_updater_check_now(bpy.types.Operator):
220 | bl_label = "Check now for "+updater.addon+" update"
221 | bl_idname = updater.addon+".updater_check_now"
222 | bl_description = "Check now for an update to the {x} addon".format(
223 | x=updater.addon)
224 | bl_options = {'REGISTER', 'INTERNAL'}
225 |
226 | def execute(self, context):
227 | if updater.invalidupdater == True:
228 | return {'CANCELLED'}
229 |
230 | if updater.async_checking == True and updater.error == None:
231 | # Check already happened
232 | # Used here to just avoid constant applying settings below
233 | # Ignoring if error, to prevent being stuck on the error screen
234 | return {'CANCELLED'}
235 |
236 | # apply the UI settings
237 | settings = get_user_preferences(context)
238 | if not settings:
239 | if updater.verbose:
240 | print("Could not get {} preferences, update check skipped".format(
241 | __package__))
242 | return {'CANCELLED'}
243 | updater.set_check_interval(enable=settings.auto_check_update,
244 | months=settings.updater_intrval_months,
245 | days=settings.updater_intrval_days,
246 | hours=settings.updater_intrval_hours,
247 | minutes=settings.updater_intrval_minutes
248 | ) # optional, if auto_check_update
249 |
250 | # input is an optional callback function
251 | # this function should take a bool input, if true: update ready
252 | # if false, no update ready
253 | updater.check_for_update_now(ui_refresh)
254 |
255 | return {'FINISHED'}
256 |
257 |
258 | class addon_updater_update_now(bpy.types.Operator):
259 | bl_label = "Update "+updater.addon+" addon now"
260 | bl_idname = updater.addon+".updater_update_now"
261 | bl_description = "Update to the latest version of the {x} addon".format(
262 | x=updater.addon)
263 | bl_options = {'REGISTER', 'INTERNAL'}
264 |
265 | # if true, run clean install - ie remove all files before adding new
266 | # equivalent to deleting the addon and reinstalling, except the
267 | # updater folder/backup folder remains
268 | clean_install = bpy.props.BoolProperty(
269 | name="Clean install",
270 | description="If enabled, completely clear the addon's folder before installing new update, creating a fresh install",
271 | default=False,
272 | options={'HIDDEN'}
273 | )
274 |
275 | def execute(self, context):
276 |
277 | # in case of error importing updater
278 | if updater.invalidupdater == True:
279 | return {'CANCELLED'}
280 |
281 | if updater.manual_only == True:
282 | bpy.ops.wm.url_open(url=updater.website)
283 | if updater.update_ready == True:
284 | # if it fails, offer to open the website instead
285 | try:
286 | res = updater.run_update(
287 | force=False,
288 | callback=post_update_callback,
289 | clean=self.clean_install)
290 |
291 | # should return 0, if not something happened
292 | if updater.verbose:
293 | if res == 0:
294 | print("Updater returned successful")
295 | else:
296 | print("Updater returned "+str(res)+", error occurred")
297 | except Exception as e:
298 | updater._error = "Error trying to run update"
299 | updater._error_msg = str(e)
300 | atr = addon_updater_install_manually.bl_idname.split(".")
301 | getattr(getattr(bpy.ops, atr[0]), atr[1])('INVOKE_DEFAULT')
302 | elif updater.update_ready == None:
303 | (update_ready, version, link) = updater.check_for_update(now=True)
304 | # re-launch this dialog
305 | atr = addon_updater_install_popup.bl_idname.split(".")
306 | getattr(getattr(bpy.ops, atr[0]), atr[1])('INVOKE_DEFAULT')
307 |
308 | elif updater.update_ready == False:
309 | self.report({'INFO'}, "Nothing to update")
310 | return {'CANCELLED'}
311 | else:
312 | self.report(
313 | {'ERROR'}, "Encountered problem while trying to update")
314 | return {'CANCELLED'}
315 |
316 | return {'FINISHED'}
317 |
318 |
319 | class addon_updater_update_target(bpy.types.Operator):
320 | bl_label = updater.addon+" version target"
321 | bl_idname = updater.addon+".updater_update_target"
322 | bl_description = "Install a targeted version of the {x} addon".format(
323 | x=updater.addon)
324 | bl_options = {'REGISTER', 'INTERNAL'}
325 |
326 | def target_version(self, context):
327 | # in case of error importing updater
328 | if updater.invalidupdater == True:
329 | ret = []
330 |
331 | ret = []
332 | i = 0
333 | for tag in updater.tags:
334 | ret.append((tag, tag, "Select to install "+tag))
335 | i += 1
336 | return ret
337 |
338 | target: bpy.props.EnumProperty(
339 | name="Target version to install",
340 | description="Select the version to install",
341 | items=target_version
342 | )
343 |
344 | # if true, run clean install - ie remove all files before adding new
345 | # equivalent to deleting the addon and reinstalling, except the
346 | # updater folder/backup folder remains
347 | clean_install = bpy.props.BoolProperty(
348 | name="Clean install",
349 | description="If enabled, completely clear the addon's folder before installing new update, creating a fresh install",
350 | default=False,
351 | options={'HIDDEN'}
352 | )
353 |
354 | @classmethod
355 | def poll(cls, context):
356 | if updater.invalidupdater == True:
357 | return False
358 | return updater.update_ready != None and len(updater.tags) > 0
359 |
360 | def invoke(self, context, event):
361 | return context.window_manager.invoke_props_dialog(self)
362 |
363 | def draw(self, context):
364 | layout = self.layout
365 | if updater.invalidupdater == True:
366 | layout.label(text="Updater error")
367 | return
368 | split = layout_split(layout, factor=0.66)
369 | subcol = split.column()
370 | subcol.label(text="Select install version")
371 | subcol = split.column()
372 | subcol.prop(self, "target", text="")
373 |
374 | def execute(self, context):
375 |
376 | # in case of error importing updater
377 | if updater.invalidupdater == True:
378 | return {'CANCELLED'}
379 |
380 | res = updater.run_update(
381 | force=False,
382 | revert_tag=self.target,
383 | callback=post_update_callback,
384 | clean=self.clean_install)
385 |
386 | # should return 0, if not something happened
387 | if res == 0:
388 | if updater.verbose:
389 | print("Updater returned successful")
390 | else:
391 | if updater.verbose:
392 | print("Updater returned "+str(res)+", error occurred")
393 | return {'CANCELLED'}
394 |
395 | return {'FINISHED'}
396 |
397 |
398 | class addon_updater_install_manually(bpy.types.Operator):
399 | """As a fallback, direct the user to download the addon manually"""
400 | bl_label = "Install update manually"
401 | bl_idname = updater.addon+".updater_install_manually"
402 | bl_description = "Proceed to manually install update"
403 | bl_options = {'REGISTER', 'INTERNAL'}
404 |
405 | error = bpy.props.StringProperty(
406 | name="Error Occurred",
407 | default="",
408 | options={'HIDDEN'}
409 | )
410 |
411 | def invoke(self, context, event):
412 | return context.window_manager.invoke_popup(self)
413 |
414 | def draw(self, context):
415 | layout = self.layout
416 |
417 | if updater.invalidupdater == True:
418 | layout.label(text="Updater error")
419 | return
420 |
421 | # use a "failed flag"? it shows this label if the case failed.
422 | if self.error != "":
423 | col = layout.column()
424 | col.scale_y = 0.7
425 | col.label(
426 | text="There was an issue trying to auto-install", icon="ERROR")
427 | col.label(
428 | text="Press the download button below and install", icon="BLANK1")
429 | col.label(text="the zip file like a normal addon.", icon="BLANK1")
430 | else:
431 | col = layout.column()
432 | col.scale_y = 0.7
433 | col.label(text="Install the addon manually")
434 | col.label(text="Press the download button below and install")
435 | col.label(text="the zip file like a normal addon.")
436 |
437 | # if check hasn't happened, i.e. accidentally called this menu
438 | # allow to check here
439 |
440 | row = layout.row()
441 |
442 | if updater.update_link != None:
443 | row.operator("wm.url_open",
444 | text="Direct download").url = updater.update_link
445 | else:
446 | row.operator("wm.url_open",
447 | text="(failed to retrieve direct download)")
448 | row.enabled = False
449 |
450 | if updater.website != None:
451 | row = layout.row()
452 | row.operator("wm.url_open", text="Open website").url =\
453 | updater.website
454 | else:
455 | row = layout.row()
456 | row.label(text="See source website to download the update")
457 |
458 | def execute(self, context):
459 | return {'FINISHED'}
460 |
461 |
462 | class addon_updater_updated_successful(bpy.types.Operator):
463 | """Addon in place, popup telling user it completed or what went wrong"""
464 | bl_label = "Installation Report"
465 | bl_idname = updater.addon+".updater_update_successful"
466 | bl_description = "Update installation response"
467 | bl_options = {'REGISTER', 'INTERNAL', 'UNDO'}
468 |
469 | error = bpy.props.StringProperty(
470 | name="Error Occurred",
471 | default="",
472 | options={'HIDDEN'}
473 | )
474 |
475 | def invoke(self, context, event):
476 | return context.window_manager.invoke_props_popup(self, event)
477 |
478 | def draw(self, context):
479 | layout = self.layout
480 |
481 | if updater.invalidupdater == True:
482 | layout.label(text="Updater error")
483 | return
484 |
485 | saved = updater.json
486 | if self.error != "":
487 | col = layout.column()
488 | col.scale_y = 0.7
489 | col.label(text="Error occurred, did not install", icon="ERROR")
490 | if updater.error_msg:
491 | msg = updater.error_msg
492 | else:
493 | msg = self.error
494 | col.label(text=str(msg), icon="BLANK1")
495 | rw = col.row()
496 | rw.scale_y = 2
497 | rw.operator("wm.url_open",
498 | text="Click for manual download.",
499 | icon="BLANK1"
500 | ).url = updater.website
501 | # manual download button here
502 | elif updater.auto_reload_post_update == False:
503 | # tell user to restart blender
504 | if "just_restored" in saved and saved["just_restored"] == True:
505 | col = layout.column()
506 | col.label(text="Addon restored", icon="RECOVER_LAST")
507 | alert_row = col.row()
508 | alert_row.alert = True
509 | alert_row.operator(
510 | "wm.quit_blender",
511 | text="Restart blender to reload",
512 | icon="BLANK1")
513 | updater.json_reset_restore()
514 | else:
515 | col = layout.column()
516 | col.label(text="Addon successfully installed",
517 | icon="FILE_TICK")
518 | alert_row = col.row()
519 | alert_row.alert = True
520 | alert_row.operator(
521 | "wm.quit_blender",
522 | text="Restart blender to reload",
523 | icon="BLANK1")
524 |
525 | else:
526 | # reload addon, but still recommend they restart blender
527 | if "just_restored" in saved and saved["just_restored"] == True:
528 | col = layout.column()
529 | col.scale_y = 0.7
530 | col.label(text="Addon restored", icon="RECOVER_LAST")
531 | col.label(text="Consider restarting blender to fully reload.",
532 | icon="BLANK1")
533 | updater.json_reset_restore()
534 | else:
535 | col = layout.column()
536 | col.scale_y = 0.7
537 | col.label(text="Addon successfully installed",
538 | icon="FILE_TICK")
539 | col.label(text="Consider restarting blender to fully reload.",
540 | icon="BLANK1")
541 |
542 | def execute(self, context):
543 | return {'FINISHED'}
544 |
545 |
546 | class addon_updater_restore_backup(bpy.types.Operator):
547 | """Restore addon from backup"""
548 | bl_label = "Restore backup"
549 | bl_idname = updater.addon+".updater_restore_backup"
550 | bl_description = "Restore addon from backup"
551 | bl_options = {'REGISTER', 'INTERNAL'}
552 |
553 | @classmethod
554 | def poll(cls, context):
555 | try:
556 | return os.path.isdir(os.path.join(updater.stage_path, "backup"))
557 | except:
558 | return False
559 |
560 | def execute(self, context):
561 | # in case of error importing updater
562 | if updater.invalidupdater == True:
563 | return {'CANCELLED'}
564 | updater.restore_backup()
565 | return {'FINISHED'}
566 |
567 |
568 | class addon_updater_ignore(bpy.types.Operator):
569 | """Prevent future update notice popups"""
570 | bl_label = "Ignore update"
571 | bl_idname = updater.addon+".updater_ignore"
572 | bl_description = "Ignore update to prevent future popups"
573 | bl_options = {'REGISTER', 'INTERNAL'}
574 |
575 | @classmethod
576 | def poll(cls, context):
577 | if updater.invalidupdater == True:
578 | return False
579 | elif updater.update_ready == True:
580 | return True
581 | else:
582 | return False
583 |
584 | def execute(self, context):
585 | # in case of error importing updater
586 | if updater.invalidupdater == True:
587 | return {'CANCELLED'}
588 | updater.ignore_update()
589 | self.report({"INFO"}, "Open addon preferences for updater options")
590 | return {'FINISHED'}
591 |
592 |
593 | class addon_updater_end_background(bpy.types.Operator):
594 | """Stop checking for update in the background"""
595 | bl_label = "End background check"
596 | bl_idname = updater.addon+".end_background_check"
597 | bl_description = "Stop checking for update in the background"
598 | bl_options = {'REGISTER', 'INTERNAL'}
599 |
600 | # @classmethod
601 | # def poll(cls, context):
602 | # if updater.async_checking == True:
603 | # return True
604 | # else:
605 | # return False
606 |
607 | def execute(self, context):
608 | # in case of error importing updater
609 | if updater.invalidupdater == True:
610 | return {'CANCELLED'}
611 | updater.stop_async_check_update()
612 | return {'FINISHED'}
613 |
614 |
615 | # -----------------------------------------------------------------------------
616 | # Handler related, to create popups
617 | # -----------------------------------------------------------------------------
618 |
619 |
620 | # global vars used to prevent duplicate popup handlers
621 | ran_autocheck_install_popup = False
622 | ran_update_sucess_popup = False
623 |
624 | # global var for preventing successive calls
625 | ran_background_check = False
626 |
627 |
628 | @persistent
629 | def updater_run_success_popup_handler(scene):
630 | global ran_update_sucess_popup
631 | ran_update_sucess_popup = True
632 |
633 | # in case of error importing updater
634 | if updater.invalidupdater == True:
635 | return
636 |
637 | try:
638 | if "scene_update_post" in dir(bpy.app.handlers):
639 | bpy.app.handlers.scene_update_post.remove(
640 | updater_run_success_popup_handler)
641 | else:
642 | bpy.app.handlers.depsgraph_update_post.remove(
643 | updater_run_success_popup_handler)
644 | except:
645 | pass
646 |
647 | atr = addon_updater_updated_successful.bl_idname.split(".")
648 | getattr(getattr(bpy.ops, atr[0]), atr[1])('INVOKE_DEFAULT')
649 |
650 |
651 | @persistent
652 | def updater_run_install_popup_handler(scene):
653 | global ran_autocheck_install_popup
654 | ran_autocheck_install_popup = True
655 |
656 | # in case of error importing updater
657 | if updater.invalidupdater == True:
658 | return
659 |
660 | try:
661 | if "scene_update_post" in dir(bpy.app.handlers):
662 | bpy.app.handlers.scene_update_post.remove(
663 | updater_run_install_popup_handler)
664 | else:
665 | bpy.app.handlers.depsgraph_update_post.remove(
666 | updater_run_install_popup_handler)
667 | except:
668 | pass
669 |
670 | if "ignore" in updater.json and updater.json["ignore"] == True:
671 | return # don't do popup if ignore pressed
672 | # elif type(updater.update_version) != type((0,0,0)):
673 | # # likely was from master or another branch, shouldn't trigger popup
674 | # updater.json_reset_restore()
675 | # return
676 | elif "version_text" in updater.json and "version" in updater.json["version_text"]:
677 | version = updater.json["version_text"]["version"]
678 | ver_tuple = updater.version_tuple_from_text(version)
679 |
680 | if ver_tuple < updater.current_version:
681 | # user probably manually installed to get the up to date addon
682 | # in here. Clear out the update flag using this function
683 | if updater.verbose:
684 | print("{} updater: appears user updated, clearing flag".format(
685 | updater.addon))
686 | updater.json_reset_restore()
687 | return
688 | atr = addon_updater_install_popup.bl_idname.split(".")
689 | getattr(getattr(bpy.ops, atr[0]), atr[1])('INVOKE_DEFAULT')
690 |
691 |
692 | def background_update_callback(update_ready):
693 | """Passed into the updater, background thread updater"""
694 | global ran_autocheck_install_popup
695 |
696 | # in case of error importing updater
697 | if updater.invalidupdater == True:
698 | return
699 | if updater.showpopups == False:
700 | return
701 | if update_ready != True:
702 | return
703 |
704 | # see if we need add to the update handler to trigger the popup
705 | handlers = []
706 | if "scene_update_post" in dir(bpy.app.handlers): # 2.7x
707 | handlers = bpy.app.handlers.scene_update_post
708 | else: # 2.8x
709 | handlers = bpy.app.handlers.depsgraph_update_post
710 | in_handles = updater_run_install_popup_handler in handlers
711 |
712 | if in_handles or ran_autocheck_install_popup:
713 | return
714 |
715 | if "scene_update_post" in dir(bpy.app.handlers): # 2.7x
716 | bpy.app.handlers.scene_update_post.append(
717 | updater_run_install_popup_handler)
718 | else: # 2.8x
719 | bpy.app.handlers.depsgraph_update_post.append(
720 | updater_run_install_popup_handler)
721 | ran_autocheck_install_popup = True
722 |
723 |
724 | def post_update_callback(module_name, res=None):
725 | """Callback for once the run_update function has completed
726 |
727 | Only makes sense to use this if "auto_reload_post_update" == False,
728 | i.e. don't auto-restart the addon
729 |
730 | Arguments:
731 | module_name: returns the module name from updater, but unused here
732 | res: If an error occurred, this is the detail string
733 | """
734 |
735 | # in case of error importing updater
736 | if updater.invalidupdater == True:
737 | return
738 |
739 | if res == None:
740 | # this is the same code as in conditional at the end of the register function
741 | # ie if "auto_reload_post_update" == True, comment out this code
742 | if updater.verbose:
743 | print("{} updater: Running post update callback".format(updater.addon))
744 |
745 | atr = addon_updater_updated_successful.bl_idname.split(".")
746 | getattr(getattr(bpy.ops, atr[0]), atr[1])('INVOKE_DEFAULT')
747 | global ran_update_sucess_popup
748 | ran_update_sucess_popup = True
749 | else:
750 | # some kind of error occurred and it was unable to install,
751 | # offer manual download instead
752 | atr = addon_updater_updated_successful.bl_idname.split(".")
753 | getattr(getattr(bpy.ops, atr[0]), atr[1])('INVOKE_DEFAULT', error=res)
754 | return
755 |
756 |
757 | def ui_refresh(update_status):
758 | # find a way to just re-draw self?
759 | # callback intended for trigger by async thread
760 | for windowManager in bpy.data.window_managers:
761 | for window in windowManager.windows:
762 | for area in window.screen.areas:
763 | area.tag_redraw()
764 |
765 |
766 | def check_for_update_background():
767 | """Function for asynchronous background check.
768 |
769 | *Could* be called on register, but would be bad practice.
770 | """
771 | if updater.invalidupdater == True:
772 | return
773 | global ran_background_check
774 | if ran_background_check == True:
775 | # Global var ensures check only happens once
776 | return
777 | elif updater.update_ready != None or updater.async_checking == True:
778 | # Check already happened
779 | # Used here to just avoid constant applying settings below
780 | return
781 |
782 | # apply the UI settings
783 | settings = get_user_preferences(bpy.context)
784 | if not settings:
785 | return
786 | updater.set_check_interval(enable=settings.auto_check_update,
787 | months=settings.updater_intrval_months,
788 | days=settings.updater_intrval_days,
789 | hours=settings.updater_intrval_hours,
790 | minutes=settings.updater_intrval_minutes
791 | ) # optional, if auto_check_update
792 |
793 | # input is an optional callback function
794 | # this function should take a bool input, if true: update ready
795 | # if false, no update ready
796 | if updater.verbose:
797 | print("{} updater: Running background check for update".format(
798 | updater.addon))
799 | updater.check_for_update_async(background_update_callback)
800 | ran_background_check = True
801 |
802 |
803 | def check_for_update_nonthreaded(self, context):
804 | """Can be placed in front of other operators to launch when pressed"""
805 | if updater.invalidupdater == True:
806 | return
807 |
808 | # only check if it's ready, ie after the time interval specified
809 | # should be the async wrapper call here
810 | settings = get_user_preferences(bpy.context)
811 | if not settings:
812 | if updater.verbose:
813 | print("Could not get {} preferences, update check skipped".format(
814 | __package__))
815 | return
816 | updater.set_check_interval(enable=settings.auto_check_update,
817 | months=settings.updater_intrval_months,
818 | days=settings.updater_intrval_days,
819 | hours=settings.updater_intrval_hours,
820 | minutes=settings.updater_intrval_minutes
821 | ) # optional, if auto_check_update
822 |
823 | (update_ready, version, link) = updater.check_for_update(now=False)
824 | if update_ready == True:
825 | atr = addon_updater_install_popup.bl_idname.split(".")
826 | getattr(getattr(bpy.ops, atr[0]), atr[1])('INVOKE_DEFAULT')
827 | else:
828 | if updater.verbose:
829 | print("No update ready")
830 | self.report({'INFO'}, "No update ready")
831 |
832 |
833 | def showReloadPopup():
834 | """For use in register only, to show popup after re-enabling the addon
835 |
836 | Must be enabled by developer
837 | """
838 | if updater.invalidupdater == True:
839 | return
840 | saved_state = updater.json
841 | global ran_update_sucess_popup
842 |
843 | has_state = saved_state != None
844 | just_updated = "just_updated" in saved_state
845 | updated_info = saved_state["just_updated"]
846 |
847 | if not (has_state and just_updated and updated_info):
848 | return
849 |
850 | updater.json_reset_postupdate() # so this only runs once
851 |
852 | # no handlers in this case
853 | if updater.auto_reload_post_update == False:
854 | return
855 |
856 | # see if we need add to the update handler to trigger the popup
857 | handlers = []
858 | if "scene_update_post" in dir(bpy.app.handlers): # 2.7x
859 | handlers = bpy.app.handlers.scene_update_post
860 | else: # 2.8x
861 | handlers = bpy.app.handlers.depsgraph_update_post
862 | in_handles = updater_run_success_popup_handler in handlers
863 |
864 | if in_handles or ran_update_sucess_popup is True:
865 | return
866 |
867 | if "scene_update_post" in dir(bpy.app.handlers): # 2.7x
868 | bpy.app.handlers.scene_update_post.append(
869 | updater_run_success_popup_handler)
870 | else: # 2.8x
871 | bpy.app.handlers.depsgraph_update_post.append(
872 | updater_run_success_popup_handler)
873 | ran_update_sucess_popup = True
874 |
875 |
876 | # -----------------------------------------------------------------------------
877 | # Example UI integrations
878 | # -----------------------------------------------------------------------------
879 |
880 |
881 | def update_notice_box_ui(self, context):
882 | """ Panel - Update Available for placement at end/beginning of panel
883 |
884 | After a check for update has occurred, this function will draw a box
885 | saying an update is ready, and give a button for: update now, open website,
886 | or ignore popup. Ideal to be placed at the end / beginning of a panel
887 | """
888 |
889 | if updater.invalidupdater == True:
890 | return
891 |
892 | saved_state = updater.json
893 | if updater.auto_reload_post_update == False:
894 | if "just_updated" in saved_state and saved_state["just_updated"] == True:
895 | layout = self.layout
896 | box = layout.box()
897 | col = box.column()
898 | alert_row = col.row()
899 | alert_row.alert = True
900 | alert_row.operator(
901 | "wm.quit_blender",
902 | text="Restart blender",
903 | icon="ERROR")
904 | col.label(text="to complete update")
905 |
906 | return
907 |
908 | # if user pressed ignore, don't draw the box
909 | if "ignore" in updater.json and updater.json["ignore"] == True:
910 | return
911 | if updater.update_ready != True:
912 | return
913 |
914 | layout = self.layout
915 | box = layout.box()
916 | col = box.column(align=True)
917 | col.label(text="Update ready!", icon="ERROR")
918 | col.separator()
919 | row = col.row(align=True)
920 | split = row.split(align=True)
921 | colL = split.column(align=True)
922 | colL.scale_y = 1.5
923 | colL.operator(addon_updater_ignore.bl_idname, icon="X", text="Ignore")
924 | colR = split.column(align=True)
925 | colR.scale_y = 1.5
926 | if updater.manual_only == False:
927 | colR.operator(addon_updater_update_now.bl_idname,
928 | text="Update", icon="LOOP_FORWARDS")
929 | col.operator("wm.url_open", text="Open website").url = updater.website
930 | #col.operator("wm.url_open",text="Direct download").url=updater.update_link
931 | col.operator(addon_updater_install_manually.bl_idname,
932 | text="Install manually")
933 | else:
934 | #col.operator("wm.url_open",text="Direct download").url=updater.update_link
935 | col.operator("wm.url_open", text="Get it now").url = updater.website
936 |
937 |
938 | def update_settings_ui(self, context, element=None):
939 | """Preferences - for drawing with full width inside user preferences
940 |
941 | Create a function that can be run inside user preferences panel for prefs UI
942 | Place inside UI draw using: addon_updater_ops.updaterSettingsUI(self, context)
943 | or by: addon_updater_ops.updaterSettingsUI(context)
944 | """
945 |
946 | # element is a UI element, such as layout, a row, column, or box
947 | if element == None:
948 | element = self.layout
949 | box = element.box()
950 |
951 | # in case of error importing updater
952 | if updater.invalidupdater == True:
953 | box.label(text="Error initializing updater code:")
954 | box.label(text=updater.error_msg)
955 | return
956 | settings = get_user_preferences(context)
957 | if not settings:
958 | box.label(text="Error getting updater preferences", icon='ERROR')
959 | return
960 |
961 | # auto-update settings
962 | box.label(text="Updater Settings")
963 | row = box.row()
964 |
965 | # special case to tell user to restart blender, if set that way
966 | if updater.auto_reload_post_update == False:
967 | saved_state = updater.json
968 | if "just_updated" in saved_state and saved_state["just_updated"] == True:
969 | row.alert = True
970 | row.operator(
971 | "wm.quit_blender",
972 | text="Restart blender to complete update",
973 | icon="ERROR")
974 | return
975 |
976 | split = layout_split(row, factor=0.4)
977 | subcol = split.column()
978 | subcol.prop(settings, "auto_check_update")
979 | subcol = split.column()
980 |
981 | if settings.auto_check_update == False:
982 | subcol.enabled = False
983 | subrow = subcol.row()
984 | subrow.label(text="Interval between checks")
985 | subrow = subcol.row(align=True)
986 | checkcol = subrow.column(align=True)
987 | checkcol.prop(settings, "updater_intrval_months")
988 | checkcol = subrow.column(align=True)
989 | checkcol.prop(settings, "updater_intrval_days")
990 | checkcol = subrow.column(align=True)
991 |
992 | # Consider un-commenting for local dev (e.g. to set shorter intervals)
993 | checkcol.prop(settings, "updater_intrval_hours")
994 | checkcol = subrow.column(align=True)
995 | checkcol.prop(settings, "updater_intrval_minutes")
996 |
997 | # checking / managing updates
998 | row = box.row()
999 | col = row.column()
1000 | if updater.error != None:
1001 | subcol = col.row(align=True)
1002 | subcol.scale_y = 1
1003 | split = subcol.split(align=True)
1004 | split.scale_y = 2
1005 | if "ssl" in updater.error_msg.lower():
1006 | split.enabled = True
1007 | split.operator(addon_updater_install_manually.bl_idname,
1008 | text=updater.error)
1009 | else:
1010 | split.enabled = False
1011 | split.operator(addon_updater_check_now.bl_idname,
1012 | text=updater.error)
1013 | split = subcol.split(align=True)
1014 | split.scale_y = 2
1015 | split.operator(addon_updater_check_now.bl_idname,
1016 | text="", icon="FILE_REFRESH")
1017 |
1018 | elif updater.update_ready == None and updater.async_checking == False:
1019 | col.scale_y = 2
1020 | col.operator(addon_updater_check_now.bl_idname)
1021 | elif updater.update_ready == None: # async is running
1022 | subcol = col.row(align=True)
1023 | subcol.scale_y = 1
1024 | split = subcol.split(align=True)
1025 | split.enabled = False
1026 | split.scale_y = 2
1027 | split.operator(addon_updater_check_now.bl_idname,
1028 | text="Checking...")
1029 | split = subcol.split(align=True)
1030 | split.scale_y = 2
1031 | split.operator(addon_updater_end_background.bl_idname,
1032 | text="", icon="X")
1033 |
1034 | elif updater.include_branches == True and \
1035 | len(updater.tags) == len(updater.include_branch_list) and \
1036 | updater.manual_only == False:
1037 | # no releases found, but still show the appropriate branch
1038 | subcol = col.row(align=True)
1039 | subcol.scale_y = 1
1040 | split = subcol.split(align=True)
1041 | split.scale_y = 2
1042 | split.operator(addon_updater_update_now.bl_idname,
1043 | text="Update directly to "+str(updater.include_branch_list[0]))
1044 | split = subcol.split(align=True)
1045 | split.scale_y = 2
1046 | split.operator(addon_updater_check_now.bl_idname,
1047 | text="", icon="FILE_REFRESH")
1048 |
1049 | elif updater.update_ready == True and updater.manual_only == False:
1050 | subcol = col.row(align=True)
1051 | subcol.scale_y = 1
1052 | split = subcol.split(align=True)
1053 | split.scale_y = 2
1054 | split.operator(addon_updater_update_now.bl_idname,
1055 | text="Update now to "+str(updater.update_version))
1056 | split = subcol.split(align=True)
1057 | split.scale_y = 2
1058 | split.operator(addon_updater_check_now.bl_idname,
1059 | text="", icon="FILE_REFRESH")
1060 |
1061 | elif updater.update_ready == True and updater.manual_only == True:
1062 | col.scale_y = 2
1063 | col.operator("wm.url_open",
1064 | text="Download "+str(updater.update_version)).url = updater.website
1065 | else: # i.e. that updater.update_ready == False
1066 | subcol = col.row(align=True)
1067 | subcol.scale_y = 1
1068 | split = subcol.split(align=True)
1069 | split.enabled = False
1070 | split.scale_y = 2
1071 | split.operator(addon_updater_check_now.bl_idname,
1072 | text="Addon is up to date")
1073 | split = subcol.split(align=True)
1074 | split.scale_y = 2
1075 | split.operator(addon_updater_check_now.bl_idname,
1076 | text="", icon="FILE_REFRESH")
1077 |
1078 | if updater.manual_only == False:
1079 | col = row.column(align=True)
1080 | # col.operator(addon_updater_update_target.bl_idname,
1081 | if updater.include_branches == True and len(updater.include_branch_list) > 0:
1082 | branch = updater.include_branch_list[0]
1083 | col.operator(addon_updater_update_target.bl_idname,
1084 | text="Install latest {} / old version".format(branch))
1085 | else:
1086 | col.operator(addon_updater_update_target.bl_idname,
1087 | text="Reinstall / install old version")
1088 | lastdate = "none found"
1089 | backuppath = os.path.join(updater.stage_path, "backup")
1090 | if "backup_date" in updater.json and os.path.isdir(backuppath):
1091 | if updater.json["backup_date"] == "":
1092 | lastdate = "Date not found"
1093 | else:
1094 | lastdate = updater.json["backup_date"]
1095 | backuptext = "Restore addon backup ({})".format(lastdate)
1096 | col.operator(addon_updater_restore_backup.bl_idname, text=backuptext)
1097 |
1098 | row = box.row()
1099 | row.scale_y = 0.7
1100 | lastcheck = updater.json["last_check"]
1101 | if updater.error != None and updater.error_msg != None:
1102 | row.label(text=updater.error_msg)
1103 | elif lastcheck != "" and lastcheck != None:
1104 | lastcheck = lastcheck[0: lastcheck.index(".")]
1105 | row.label(text="Last update check: " + lastcheck)
1106 | else:
1107 | row.label(text="Last update check: Never")
1108 |
1109 |
1110 | def update_settings_ui_condensed(self, context, element=None):
1111 | """Preferences - Condensed drawing within preferences
1112 |
1113 | Alternate draw for user preferences or other places, does not draw a box
1114 | """
1115 |
1116 | # element is a UI element, such as layout, a row, column, or box
1117 | if element == None:
1118 | element = self.layout
1119 | row = element.row()
1120 |
1121 | # in case of error importing updater
1122 | if updater.invalidupdater == True:
1123 | row.label(text="Error initializing updater code:")
1124 | row.label(text=updater.error_msg)
1125 | return
1126 | settings = get_user_preferences(context)
1127 | if not settings:
1128 | row.label(text="Error getting updater preferences", icon='ERROR')
1129 | return
1130 |
1131 | # special case to tell user to restart blender, if set that way
1132 | if updater.auto_reload_post_update == False:
1133 | saved_state = updater.json
1134 | if "just_updated" in saved_state and saved_state["just_updated"] == True:
1135 | row.alert = True # mark red
1136 | row.operator(
1137 | "wm.quit_blender",
1138 | text="Restart blender to complete update",
1139 | icon="ERROR")
1140 | return
1141 |
1142 | col = row.column()
1143 | if updater.error != None:
1144 | subcol = col.row(align=True)
1145 | subcol.scale_y = 1
1146 | split = subcol.split(align=True)
1147 | split.scale_y = 2
1148 | if "ssl" in updater.error_msg.lower():
1149 | split.enabled = True
1150 | split.operator(addon_updater_install_manually.bl_idname,
1151 | text=updater.error)
1152 | else:
1153 | split.enabled = False
1154 | split.operator(addon_updater_check_now.bl_idname,
1155 | text=updater.error)
1156 | split = subcol.split(align=True)
1157 | split.scale_y = 2
1158 | split.operator(addon_updater_check_now.bl_idname,
1159 | text="", icon="FILE_REFRESH")
1160 |
1161 | elif updater.update_ready == None and updater.async_checking == False:
1162 | col.scale_y = 2
1163 | col.operator(addon_updater_check_now.bl_idname)
1164 | elif updater.update_ready == None: # async is running
1165 | subcol = col.row(align=True)
1166 | subcol.scale_y = 1
1167 | split = subcol.split(align=True)
1168 | split.enabled = False
1169 | split.scale_y = 2
1170 | split.operator(addon_updater_check_now.bl_idname,
1171 | text="Checking...")
1172 | split = subcol.split(align=True)
1173 | split.scale_y = 2
1174 | split.operator(addon_updater_end_background.bl_idname,
1175 | text="", icon="X")
1176 |
1177 | elif updater.include_branches == True and \
1178 | len(updater.tags) == len(updater.include_branch_list) and \
1179 | updater.manual_only == False:
1180 | # no releases found, but still show the appropriate branch
1181 | subcol = col.row(align=True)
1182 | subcol.scale_y = 1
1183 | split = subcol.split(align=True)
1184 | split.scale_y = 2
1185 | split.operator(addon_updater_update_now.bl_idname,
1186 | text="Update directly to "+str(updater.include_branch_list[0]))
1187 | split = subcol.split(align=True)
1188 | split.scale_y = 2
1189 | split.operator(addon_updater_check_now.bl_idname,
1190 | text="", icon="FILE_REFRESH")
1191 |
1192 | elif updater.update_ready == True and updater.manual_only == False:
1193 | subcol = col.row(align=True)
1194 | subcol.scale_y = 1
1195 | split = subcol.split(align=True)
1196 | split.scale_y = 2
1197 | split.operator(addon_updater_update_now.bl_idname,
1198 | text="Update now to "+str(updater.update_version))
1199 | split = subcol.split(align=True)
1200 | split.scale_y = 2
1201 | split.operator(addon_updater_check_now.bl_idname,
1202 | text="", icon="FILE_REFRESH")
1203 |
1204 | elif updater.update_ready == True and updater.manual_only == True:
1205 | col.scale_y = 2
1206 | col.operator("wm.url_open",
1207 | text="Download "+str(updater.update_version)).url = updater.website
1208 | else: # i.e. that updater.update_ready == False
1209 | subcol = col.row(align=True)
1210 | subcol.scale_y = 1
1211 | split = subcol.split(align=True)
1212 | split.enabled = False
1213 | split.scale_y = 2
1214 | split.operator(addon_updater_check_now.bl_idname,
1215 | text="Addon is up to date")
1216 | split = subcol.split(align=True)
1217 | split.scale_y = 2
1218 | split.operator(addon_updater_check_now.bl_idname,
1219 | text="", icon="FILE_REFRESH")
1220 |
1221 | row = element.row()
1222 | row.prop(settings, "auto_check_update")
1223 |
1224 | row = element.row()
1225 | row.scale_y = 0.7
1226 | lastcheck = updater.json["last_check"]
1227 | if updater.error != None and updater.error_msg != None:
1228 | row.label(text=updater.error_msg)
1229 | elif lastcheck != "" and lastcheck != None:
1230 | lastcheck = lastcheck[0: lastcheck.index(".")]
1231 | row.label(text="Last check: " + lastcheck)
1232 | else:
1233 | row.label(text="Last check: Never")
1234 |
1235 |
1236 | def skip_tag_function(self, tag):
1237 | """A global function for tag skipping
1238 |
1239 | A way to filter which tags are displayed,
1240 | e.g. to limit downgrading too far
1241 | input is a tag text, e.g. "v1.2.3"
1242 | output is True for skipping this tag number,
1243 | False if the tag is allowed (default for all)
1244 | Note: here, "self" is the acting updater shared class instance
1245 | """
1246 |
1247 | # in case of error importing updater
1248 | if self.invalidupdater == True:
1249 | return False
1250 |
1251 | # ---- write any custom code here, return true to disallow version ---- #
1252 | #
1253 | # # Filter out e.g. if 'beta' is in name of release
1254 | # if 'beta' in tag.lower():
1255 | # return True
1256 | # ---- write any custom code above, return true to disallow version --- #
1257 |
1258 | if self.include_branches == True:
1259 | for branch in self.include_branch_list:
1260 | if tag["name"].lower() == branch:
1261 | return False
1262 |
1263 | # function converting string to tuple, ignoring e.g. leading 'v'
1264 | tupled = self.version_tuple_from_text(tag["name"])
1265 | if type(tupled) != type((1, 2, 3)):
1266 | return True
1267 |
1268 | # select the min tag version - change tuple accordingly
1269 | if self.version_min_update != None:
1270 | if tupled < self.version_min_update:
1271 | return True # skip if current version below this
1272 |
1273 | # select the max tag version
1274 | if self.version_max_update != None:
1275 | if tupled >= self.version_max_update:
1276 | return True # skip if current version at or above this
1277 |
1278 | # in all other cases, allow showing the tag for updating/reverting
1279 | return False
1280 |
1281 |
1282 | def select_link_function(self, tag):
1283 | """Only customize if trying to leverage "attachments" in *GitHub* releases
1284 |
1285 | A way to select from one or multiple attached donwloadable files from the
1286 | server, instead of downloading the default release/tag source code
1287 | """
1288 |
1289 | # -- Default, universal case (and is the only option for GitLab/Bitbucket)
1290 | link = tag["zipball_url"]
1291 |
1292 | # -- Example: select the first (or only) asset instead source code --
1293 | # if "assets" in tag and "browser_download_url" in tag["assets"][0]:
1294 | # link = tag["assets"][0]["browser_download_url"]
1295 |
1296 | # -- Example: select asset based on OS, where multiple builds exist --
1297 | # # not tested/no error checking, modify to fit your own needs!
1298 | # # assume each release has three attached builds:
1299 | # # release_windows.zip, release_OSX.zip, release_linux.zip
1300 | # # This also would logically not be used with "branches" enabled
1301 | # if platform.system() == "Darwin": # ie OSX
1302 | # link = [asset for asset in tag["assets"] if 'OSX' in asset][0]
1303 | # elif platform.system() == "Windows":
1304 | # link = [asset for asset in tag["assets"] if 'windows' in asset][0]
1305 | # elif platform.system() == "Linux":
1306 | # link = [asset for asset in tag["assets"] if 'linux' in asset][0]
1307 |
1308 | return link
1309 |
1310 |
1311 | # -----------------------------------------------------------------------------
1312 | # Register, should be run in the register module itself
1313 | # -----------------------------------------------------------------------------
1314 |
1315 |
1316 | classes = (
1317 | addon_updater_install_popup,
1318 | addon_updater_check_now,
1319 | addon_updater_update_now,
1320 | addon_updater_update_target,
1321 | addon_updater_install_manually,
1322 | addon_updater_updated_successful,
1323 | addon_updater_restore_backup,
1324 | addon_updater_ignore,
1325 | addon_updater_end_background
1326 | )
1327 |
1328 |
1329 | def register(bl_info):
1330 | """Registering the operators in this module"""
1331 | # safer failure in case of issue loading module
1332 | if updater.error:
1333 | print("Exiting updater registration, " + updater.error)
1334 | return
1335 | updater.clear_state() # clear internal vars, avoids reloading oddities
1336 |
1337 | # confirm your updater "engine" (Choose between Github, GitLab and Bitbucket)
1338 | updater.engine = "Github"
1339 | # Jump to line 60 after setting updater.user, updater.repo and updater.website
1340 | # Jump to line 980, if you want to hide the minutes/hours intervals
1341 |
1342 | # If using private repository, indicate the token here
1343 | # Must be set after assigning the engine.
1344 | # **WARNING** Depending on the engine, this token can act like a password!!
1345 | # Only provide a token if the project is *non-public*, see readme for
1346 | # other considerations and suggestions from a security standpoint
1347 | updater.private_token = None # "tokenstring"
1348 |
1349 | # choose your own username(all lowercase), must match website (not needed for GitLab)
1350 | updater.user = "pidgeontools"
1351 |
1352 | # choose your own repository, must match git name
1353 | updater.repo = "SuperProjectManager"
1354 |
1355 | # updater.addon = # define at top of module, MUST be done first
1356 |
1357 | # Website for manual addon download, optional but recommended to set
1358 | updater.website = "https://github.com/PidgeonTools/SuperProjectManager/"
1359 |
1360 | # Addon subfolder path
1361 | # "sample/path/to/addon"
1362 | # default is "" or None, meaning root
1363 | updater.subfolder_path = ""
1364 |
1365 | # used to check/compare versions
1366 | updater.current_version = bl_info["version"]
1367 |
1368 | # Optional, to hard-set update frequency, use this here - however,
1369 | # this demo has this set via UI properties.
1370 | # updater.set_check_interval(
1371 | # enable=False,months=0,days=0,hours=0,minutes=2)
1372 |
1373 | # Optional, consider turning off for production or allow as an option
1374 | # This will print out additional debugging info to the console
1375 | updater.verbose = True # make False for production default
1376 |
1377 | # Optional, customize where the addon updater processing subfolder is,
1378 | # essentially a staging folder used by the updater on its own
1379 | # Needs to be within the same folder as the addon itself
1380 | # Need to supply a full, absolute path to folder
1381 | # updater.updater_path = # set path of updater folder, by default:
1382 | # /addons/{__package__}/{__package__}_updater
1383 |
1384 | # auto create a backup of the addon when installing other versions
1385 | updater.backup_current = True # True by default
1386 |
1387 | # Sample ignore patterns for when creating backup of current during update
1388 | updater.backup_ignore_patterns = ["__pycache__"]
1389 | # Alternate example patterns
1390 | # updater.backup_ignore_patterns = [".git", "__pycache__", "*.bat", ".gitignore", "*.exe"]
1391 |
1392 | # Patterns for files to actively overwrite if found in new update
1393 | # file and are also found in the currently installed addon. Note that
1394 |
1395 | # by default (ie if set to []), updates are installed in the same way as blender:
1396 | # .py files are replaced, but other file types (e.g. json, txt, blend)
1397 | # will NOT be overwritten if already present in current install. Thus
1398 | # if you want to automatically update resources/non py files, add them
1399 | # as a part of the pattern list below so they will always be overwritten by an
1400 | # update. If a pattern file is not found in new update, no action is taken
1401 | # This does NOT detele anything, only defines what is allowed to be overwritten
1402 | updater.overwrite_patterns = ["*.png", "*.jpg", "README.md", "LICENSE.txt"]
1403 | # updater.overwrite_patterns = []
1404 | # other examples:
1405 | # ["*"] means ALL files/folders will be overwritten by update, was the behavior pre updater v1.0.4
1406 | # [] or ["*.py","*.pyc"] matches default blender behavior, ie same effect if user installs update manually without deleting the existing addon first
1407 | # e.g. if existing install and update both have a resource.blend file, the existing installed one will remain
1408 | # ["some.py"] means if some.py is found in addon update, it will overwrite any existing some.py in current addon install, if any
1409 | # ["*.json"] means all json files found in addon update will overwrite those of same name in current install
1410 | # ["*.png","README.md","LICENSE.txt"] means the readme, license, and all pngs will be overwritten by update
1411 |
1412 | # Patterns for files to actively remove prior to running update
1413 | # Useful if wanting to remove old code due to changes in filenames
1414 | # that otherwise would accumulate. Note: this runs after taking
1415 | # a backup (if enabled) but before placing in new update. If the same
1416 | # file name removed exists in the update, then it acts as if pattern
1417 | # is placed in the overwrite_patterns property. Note this is effectively
1418 | # ignored if clean=True in the run_update method
1419 | updater.remove_pre_update_patterns = ["*.py", "*.pyc"]
1420 | # Note setting ["*"] here is equivalent to always running updates with
1421 | # clean = True in the run_update method, ie the equivalent of a fresh,
1422 | # new install. This would also delete any resources or user-made/modified
1423 | # files setting ["__pycache__"] ensures the pycache folder is always removed
1424 | # The configuration of ["*.py","*.pyc"] is a safe option as this
1425 | # will ensure no old python files/caches remain in event different addon
1426 | # versions have different filenames or structures
1427 |
1428 | # Allow branches like 'master' as an option to update to, regardless
1429 | # of release or version.
1430 | # Default behavior: releases will still be used for auto check (popup),
1431 | # but the user has the option from user preferences to directly
1432 | # update to the master branch or any other branches specified using
1433 | # the "install {branch}/older version" operator.
1434 | updater.include_branches = True
1435 |
1436 | # (GitHub only) This options allows the user to use releases over tags for data,
1437 | # which enables pulling down release logs/notes, as well as specify installs from
1438 | # release-attached zips (instead of just the auto-packaged code generated with
1439 | # a release/tag). Setting has no impact on BitBucket or GitLab repos
1440 | updater.use_releases = False
1441 | # note: Releases always have a tag, but a tag may not always be a release
1442 | # Therefore, setting True above will filter out any non-annoted tags
1443 | # note 2: Using this option will also display the release name instead of
1444 | # just the tag name, bear this in mind given the skip_tag_function filtering above
1445 |
1446 | # if using "include_branches",
1447 | # updater.include_branch_list defaults to ['master'] branch if set to none
1448 | # example targeting another multiple branches allowed to pull from
1449 | # updater.include_branch_list = ['master', 'dev'] # example with two branches
1450 | # None is the equivalent to setting ['master']
1451 | updater.include_branch_list = None
1452 |
1453 | # Only allow manual install, thus prompting the user to open
1454 | # the addon's web page to download, specifically: updater.website
1455 | # Useful if only wanting to get notification of updates but not
1456 | # directly install.
1457 | updater.manual_only = False
1458 |
1459 | # Used for development only, "pretend" to install an update to test
1460 | # reloading conditions
1461 | updater.fake_install = False # Set to true to test callback/reloading
1462 |
1463 | # Show popups, ie if auto-check for update is enabled or a previous
1464 | # check for update in user preferences found a new version, show a popup
1465 | # (at most once per blender session, and it provides an option to ignore
1466 | # for future sessions); default behavior is set to True
1467 | updater.showpopups = True
1468 | # note: if set to false, there will still be an "update ready" box drawn
1469 | # using the `update_notice_box_ui` panel function.
1470 |
1471 | # Override with a custom function on what tags
1472 | # to skip showing for updater; see code for function above.
1473 | # Set the min and max versions allowed to install.
1474 | # Optional, default None
1475 | # min install (>=) will install this and higher
1476 | updater.version_min_update = (1, 2, 0)
1477 | # updater.version_min_update = None # if not wanting to define a min
1478 |
1479 | # max install (<) will install strictly anything lower
1480 | # updater.version_max_update = (9,9,9)
1481 | updater.version_max_update = None # set to None if not wanting to set max
1482 |
1483 | # Function defined above, customize as appropriate per repository
1484 | updater.skip_tag = skip_tag_function # min and max used in this function
1485 |
1486 | # Function defined above, customize as appropriate per repository; not required
1487 | updater.select_link = select_link_function
1488 |
1489 | # The register line items for all operators/panels
1490 | # If using bpy.utils.register_module(__name__) to register elsewhere
1491 | # in the addon, delete these lines (also from unregister)
1492 | for cls in classes:
1493 | # apply annotations to remove Blender 2.8 warnings, no effect on 2.7
1494 | make_annotations(cls)
1495 | # comment out this line if using bpy.utils.register_module(__name__)
1496 | bpy.utils.register_class(cls)
1497 |
1498 | # special situation: we just updated the addon, show a popup
1499 | # to tell the user it worked
1500 | # should be enclosed in try/catch in case other issues arise
1501 | showReloadPopup()
1502 |
1503 |
1504 | def unregister():
1505 | for cls in reversed(classes):
1506 | # comment out this line if using bpy.utils.unregister_module(__name__)
1507 | bpy.utils.unregister_class(cls)
1508 |
1509 | # clear global vars since they may persist if not restarting blender
1510 | updater.clear_state() # clear internal vars, avoids reloading oddities
1511 |
1512 | global ran_autocheck_install_popup
1513 | ran_autocheck_install_popup = False
1514 |
1515 | global ran_update_sucess_popup
1516 | ran_update_sucess_popup = False
1517 |
1518 | global ran_background_check
1519 | ran_background_check = False
1520 |
--------------------------------------------------------------------------------
/blender_manifest.toml:
--------------------------------------------------------------------------------
1 | schema_version = "1.0.0"
2 |
3 | id = "blenderdefender_spm"
4 | name = "Super Project Manager (SPM)"
5 | version = "1.3.1"
6 | tagline = "Manage and setup your projects the easy way"
7 | maintainer = "Blender Defender"
8 | website = "https://github.com/PidgeonTools/SuperProjectManager/issues"
9 |
10 | type = "add-on"
11 | blender_version_min = "4.2.0"
12 | license = ["SPDX:GPL-3.0-or-later"]
13 |
14 | [build]
15 | paths_exclude_pattern = [
16 | "/.git/",
17 | "/.github/",
18 | "/.vscode/",
19 | "/.pytest*/",
20 | ".gitignore",
21 | "/tests/",
22 | "__pycache__/",
23 | "blenderdefender_spm-*.zip",
24 | ]
25 |
--------------------------------------------------------------------------------
/functions/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PidgeonTools/SuperProjectManager/05184916f01c7779f2b4bd2298a6599b0f69011f/functions/__init__.py
--------------------------------------------------------------------------------
/functions/blenderdefender_functions.py:
--------------------------------------------------------------------------------
1 | # ##### BEGIN GPL LICENSE BLOCK #####
2 | #
3 | #
4 | # Copyright (C) <2021>
5 | #
6 | # This program is free software; you can redistribute it and/or
7 | # modify it under the terms of the GNU General Public License
8 | # as published by the Free Software Foundation; either version 3
9 | # of the License, or (at your option) any later version.
10 | #
11 | # This program is distributed in the hope that it will be useful,
12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 | # GNU General Public License for more details.
15 | #
16 | # You should have received a copy of the GNU General Public License
17 | # along with this program; if not, write to the Free Software Foundation,
18 | # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19 | #
20 | # ##### END GPL LICENSE BLOCK #####
21 |
22 |
23 | import os
24 | from os import path as p
25 |
26 | import json
27 |
28 | import time
29 |
30 | import shutil
31 |
32 | from .json_functions import (
33 | decode_json,
34 | encode_json
35 | )
36 |
37 |
38 | def setup_addons_data():
39 | """Setup and validate the addon data."""
40 | default_addons_data = {
41 | "automatic_folders": {
42 | "Default Folder Set": [
43 | [
44 | 0,
45 | "Blender Files"
46 | ],
47 | [
48 | 1,
49 | "Images>>Textures++References++Rendered Images"
50 | ],
51 | [
52 | 0,
53 | "Sounds"
54 | ]
55 | ]
56 | },
57 | "unfinished_projects": [],
58 | "version": 130
59 | }
60 |
61 | addons_data_path = p.join(
62 | p.expanduser("~"),
63 | "Blender Addons Data",
64 | "blender-project-starter"
65 | )
66 | addons_data_file = p.join(addons_data_path, "BPS.json")
67 |
68 | if not p.isdir(addons_data_path):
69 | os.makedirs(addons_data_path)
70 |
71 | if "BPS.json" not in os.listdir(addons_data_path):
72 | encode_json(default_addons_data, addons_data_file)
73 |
74 | addons_data = ""
75 | try:
76 | addons_data = decode_json(addons_data_file)
77 | except Exception:
78 | pass
79 |
80 | if type(addons_data) != dict:
81 | shutil.move(addons_data_file, p.join(addons_data_path,
82 | f"BPS.{time.strftime('%Y-%m-%d')}.json"))
83 | encode_json(default_addons_data, addons_data_file)
84 | addons_data = default_addons_data
85 |
86 | if addons_data.get("version") < 130:
87 | encode_json(update_json(addons_data), addons_data_file)
88 |
89 |
90 | def update_to_120(data):
91 | data["version"] = 120
92 | return data
93 |
94 |
95 | def update_to_130(data):
96 | default_folders = []
97 | while data["automatic_folders"]:
98 | folder = data["automatic_folders"].pop(0)
99 | default_folders.append([False, folder])
100 |
101 | data["automatic_folders"] = {}
102 | data["automatic_folders"]["Default Folder Set"] = default_folders
103 |
104 | for i in range(len(data["unfinished_projects"])):
105 | data["unfinished_projects"][i] = [
106 | "project", data["unfinished_projects"][i]]
107 |
108 | data["version"] = 130
109 |
110 | return data
111 |
112 |
113 | def update_json(data: dict) -> dict:
114 | version = data.get("version", 110)
115 |
116 | if version == 110:
117 | data = update_to_120(data)
118 | version = 120
119 |
120 | if version == 120:
121 | data = update_to_130(data)
122 | version = 130
123 |
124 | return data
125 |
--------------------------------------------------------------------------------
/functions/json_functions.py:
--------------------------------------------------------------------------------
1 | from os import path as p
2 |
3 | import json
4 |
5 | import typing
6 |
7 |
8 | def decode_json(path: str) -> typing.Union[dict, list]:
9 | with open(path) as f:
10 | j = json.load(f)
11 | return j
12 |
13 |
14 | def encode_json(j: typing.Union[dict, list], path: str) -> typing.Union[dict, list]:
15 | with open(path, "w+") as f:
16 | json.dump(j, f, indent=4)
17 | return j
18 |
--------------------------------------------------------------------------------
/functions/main_functions.py:
--------------------------------------------------------------------------------
1 | # ##### BEGIN GPL LICENSE BLOCK #####
2 | #
3 | #
4 | # Copyright (C) <2023>
5 | #
6 | # This program is free software; you can redistribute it and/or
7 | # modify it under the terms of the GNU General Public License
8 | # as published by the Free Software Foundation; either version 3
9 | # of the License, or (at your option) any later version.
10 | #
11 | # This program is distributed in the hope that it will be useful,
12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 | # GNU General Public License for more details.
15 | #
16 | # You should have received a copy of the GNU General Public License
17 | # along with this program; if not, write to the Free Software Foundation,
18 | # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19 | #
20 | # ##### END GPL LICENSE BLOCK #####
21 |
22 | import bpy
23 | from bpy.utils import previews
24 | from bpy.types import (
25 | Context
26 | )
27 |
28 | import os
29 | from os import path as p
30 |
31 | import sys
32 | import subprocess
33 |
34 | import json
35 | import time
36 |
37 | from .register_functions import (
38 | register_automatic_folders,
39 | unregister_automatic_folders,
40 | register_project_folders
41 | )
42 |
43 | from .json_functions import (
44 | decode_json,
45 | encode_json,
46 | )
47 |
48 | from ..addon_types import AddonPreferences
49 |
50 |
51 | C = bpy.context
52 | D = bpy.data
53 |
54 | BPS_DATA_FILE = p.join(
55 | p.expanduser("~"),
56 | "Blender Addons Data",
57 | "blender-project-starter",
58 | "BPS.json"
59 | )
60 |
61 |
62 | def generate_file_version_number(path):
63 | i = 1
64 | number = "0001"
65 |
66 | while p.exists("{}_v{}.blend".format(path, number)):
67 | i += 1
68 | number = str(i)
69 | number = "0" * (4 - len(number)) + number
70 |
71 | return "{}_v{}.blend".format(path, number)
72 |
73 |
74 | def is_file_in_project_folder(context: Context, filepath):
75 | if filepath == "":
76 | return False
77 |
78 | filepath = p.normpath(filepath)
79 | project_folder = p.normpath(p.join(context.scene.project_location,
80 | context.scene.project_name
81 | )
82 | )
83 | return filepath.startswith(project_folder)
84 |
85 |
86 | def save_filepath(context: Context, filename, subfolder):
87 | path = p.join(
88 | context.scene.project_location,
89 | context.scene.project_name,
90 | subfolder,
91 | filename
92 | ) + ".blend"
93 |
94 | return path
95 |
96 |
97 | def structure_sets_enum(self, context: Context):
98 | tooltip = "Select a folder Structure Set."
99 | items = []
100 |
101 | for i in decode_json(BPS_DATA_FILE)["automatic_folders"]:
102 | items.append((i, i, tooltip))
103 |
104 | return items
105 |
106 |
107 | def structure_sets_enum_update(self, context: Context):
108 | unregister_automatic_folders(self.automatic_folders, self.previous_set)
109 | register_automatic_folders(
110 | self.automatic_folders, self.folder_structure_sets)
111 | self.previous_set = self.folder_structure_sets
112 |
113 |
114 | def active_project_enum(self, context: Context):
115 | tooltip = "Select a project you want to work with."
116 | items = []
117 |
118 | options = decode_json(BPS_DATA_FILE).get("filebrowser_panel_options", [])
119 |
120 | for el in options:
121 | items.append((el, p.basename(el), tooltip))
122 |
123 | return items
124 |
125 |
126 | def active_project_enum_update(self: 'AddonPreferences', context: Context):
127 | register_project_folders(self.project_paths, self.active_project)
128 |
129 |
130 | def add_unfinished_project(project_path):
131 | data = decode_json(BPS_DATA_FILE)
132 |
133 | if ["project", project_path] in data["unfinished_projects"]:
134 | return {'WARNING'}, f"The Project {p.basename(project_path)} already exists in the list of unfinished Projects!"
135 |
136 | data["unfinished_projects"].append(["project", project_path])
137 | encode_json(data, BPS_DATA_FILE)
138 |
139 | return {'INFO'}, f"Successfully added project {p.basename(project_path)} to the list of unfinished projects."
140 |
141 |
142 | def finish_project(index):
143 | data = decode_json(BPS_DATA_FILE)
144 |
145 | data["unfinished_projects"].pop(index)
146 | encode_json(data, BPS_DATA_FILE)
147 |
148 |
149 | def write_project_info(root_path, blend_file_path):
150 | if not blend_file_path.endswith(".blend"):
151 | return {"WARNING"}, "Can't create a Super Project Manager project! Please select a Blender file and try again."
152 | data = {
153 | "blender_files": {
154 | "main_file": None,
155 | "other_files": []
156 | },
157 | }
158 | project_info_path = p.join(root_path, ".blender_pm")
159 | if p.exists(project_info_path):
160 | data = decode_json(project_info_path)
161 | set_file_hidden(project_info_path, False)
162 |
163 | bfiles = data["blender_files"]
164 | if bfiles["main_file"] and bfiles["main_file"] != blend_file_path:
165 | bfiles["other_files"].append(bfiles["main_file"])
166 | bfiles["main_file"] = blend_file_path
167 |
168 | data["build_date"] = int(time.time())
169 |
170 | encode_json(data, project_info_path)
171 |
172 | set_file_hidden(project_info_path)
173 |
174 | return {"INFO"}, "Successfully created a Super Project Manager project!"
175 |
176 |
177 | def set_file_hidden(f, hide_file=True):
178 | if sys.platform != "win32":
179 | return
180 |
181 | hide_flag = "+h" if hide_file else "-h"
182 | subprocess.call(f'attrib {hide_flag} "{f}"', shell=True)
183 |
--------------------------------------------------------------------------------
/functions/register_functions.py:
--------------------------------------------------------------------------------
1 | # ##### BEGIN GPL LICENSE BLOCK #####
2 | #
3 | #
4 | # Copyright (C) <2023>
5 | #
6 | # This program is free software; you can redistribute it and/or
7 | # modify it under the terms of the GNU General Public License
8 | # as published by the Free Software Foundation; either version 3
9 | # of the License, or (at your option) any later version.
10 | #
11 | # This program is distributed in the hope that it will be useful,
12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 | # GNU General Public License for more details.
15 | #
16 | # You should have received a copy of the GNU General Public License
17 | # along with this program; if not, write to the Free Software Foundation,
18 | # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19 | #
20 | # ##### END GPL LICENSE BLOCK #####
21 |
22 | import bpy
23 | from bpy.utils import previews
24 | from bpy.props import (
25 | BoolProperty,
26 | EnumProperty,
27 | StringProperty,
28 | )
29 |
30 | import os
31 | from os import path as p
32 |
33 | import sys
34 | import subprocess
35 |
36 | from .json_functions import (
37 | decode_json,
38 | encode_json
39 | )
40 |
41 | from .. import (
42 | addon_types,
43 | )
44 |
45 | C = bpy.context
46 | D = bpy.data
47 | ADDON_PACKAGE = __package__.rpartition(".")[0]
48 | Scene_Prop = bpy.types.Scene
49 |
50 | BPS_DATA_FILE = p.join(
51 | p.expanduser("~"),
52 | "Blender Addons Data",
53 | "blender-project-starter",
54 | "BPS.json"
55 | )
56 |
57 |
58 | def register_properties():
59 | prefs: 'addon_types.AddonPreferences' = C.preferences.addons[ADDON_PACKAGE].preferences
60 |
61 | Scene_Prop.project_name = StringProperty(
62 | name="Project Name",
63 | subtype="NONE",
64 | default="My_Project"
65 | )
66 | Scene_Prop.project_location = StringProperty(
67 | name="Project Location",
68 | description="Saves the location of file",
69 | subtype="DIR_PATH",
70 | default=prefs.default_project_location
71 | )
72 | Scene_Prop.project_setup = EnumProperty(
73 | name="Project Setup",
74 | items=[
75 | ("Automatic_Setup", "Automatic Setup", "Automatic Project Setup "),
76 | ("Custom_Setup", "Custom Setup", "My Custom Setup")
77 | ]
78 | )
79 |
80 | Scene_Prop.open_directory = BoolProperty(name="Open Directory",
81 | default=True)
82 | Scene_Prop.add_new_project = BoolProperty(name="New unfinished project",
83 | default=True)
84 | Scene_Prop.save_blender_file = BoolProperty(name="Save Blender File",
85 | description="Save Blender \
86 | File on build. If disabled, only the project folders are created",
87 | default=True)
88 |
89 | Scene_Prop.cut_or_copy = BoolProperty(
90 | name="Cut or Copy",
91 | description="Decide, if you want to cut or copy your file from the \
92 | current folder to the project folder.",
93 | )
94 | Scene_Prop.save_file_with_new_name = BoolProperty(
95 | name="Save Blender File with another name",
96 | )
97 | Scene_Prop.save_blender_file_versioned = BoolProperty(
98 | name="Add Version Number",
99 | description="Add a Version Number if the File already exists",
100 | )
101 | Scene_Prop.save_file_name = StringProperty(name="Save File Name",
102 | default="My Blend")
103 | Scene_Prop.remap_relative = BoolProperty(name="Remap Relative",
104 | default=True)
105 | Scene_Prop.compress_save = BoolProperty(name="Compress Save")
106 | Scene_Prop.set_render_output = BoolProperty(name="Set the Render Output")
107 |
108 | Scene_Prop.project_rearrange_mode = BoolProperty(
109 | name="Switch to Rearrange Mode")
110 |
111 |
112 | def register_automatic_folders(folders, folderset="Default Folder Set"):
113 |
114 | index = 0
115 | for folder in folders:
116 | folders.remove(index)
117 |
118 | data = decode_json(BPS_DATA_FILE)
119 |
120 | for folder in data["automatic_folders"][folderset]:
121 | f = folders.add()
122 | f["render_outputpath"] = folder[0]
123 | f["folder_name"] = folder[1]
124 |
125 |
126 | def unregister_automatic_folders(folders, folderset="Default Folder Set"):
127 | data = []
128 | original_json = decode_json(BPS_DATA_FILE)
129 |
130 | for folder in folders:
131 | data.append([int(folder.render_outputpath),
132 | folder.folder_name])
133 |
134 | original_json["automatic_folders"][folderset] = data
135 |
136 | encode_json(original_json, BPS_DATA_FILE)
137 |
138 |
139 | def register_project_folders(project_folders, project_path):
140 | project_info: str = p.join(project_path, ".blender_pm")
141 |
142 | index = 0
143 | for folder in project_folders:
144 | project_folders.remove(index)
145 |
146 | project_metadata: dict = {}
147 |
148 | if p.exists(project_info):
149 | project_metadata: dict = decode_json(project_info)
150 |
151 | folders: list = project_metadata.get("displayed_project_folders", [])
152 | if len(folders) == 0:
153 | folders = [{"folder_path": f}
154 | for f in os.listdir(project_path) if p.isdir(p.join(project_path, f))]
155 |
156 | for folder in folders:
157 | f = project_folders.add()
158 |
159 | folder_name = p.basename(folder.get("folder_path", ""))
160 | full_path = p.join(project_path, folder.get("folder_path", ""))
161 |
162 | f["icon"] = folder.get("icon", "FILE_FOLDER")
163 | f["name"] = folder_name
164 | f["is_valid"] = p.exists(full_path)
165 | f["path"] = full_path
166 |
--------------------------------------------------------------------------------
/objects/path_generator.py:
--------------------------------------------------------------------------------
1 | import os
2 | from os import path as p
3 |
4 | import typing
5 |
6 | ADDON_PACKAGE = __package__.rpartition(".")[0]
7 |
8 | try:
9 | from .token import Token
10 | from ..addon_types import AddonPreferences
11 | from bpy.types import (
12 | Context
13 | )
14 | except:
15 | import sys
16 | sys.path.append(p.dirname(p.dirname(__file__)))
17 | from objects.token import Token
18 |
19 |
20 | class Subfolders():
21 | def __init__(self, string: str, prefix: str = ""):
22 | self.prefix = prefix
23 | self.tree = {}
24 | self.warnings = []
25 |
26 | self._return_from_close_bracket = False
27 |
28 | self.tokens = self.tokenize(string)
29 | if len(self.tokens) == 0:
30 | return
31 |
32 | self.tree = self.parse_tree()
33 |
34 | def __str__(self) -> str:
35 | """Return a string representation of the folder tree."""
36 | return "/\n" + self.__to_string(self.tree)
37 |
38 | def __to_string(self, subtree: dict = None, row_prefix: str = "") -> str:
39 | """Recursive helper function for __str__()."""
40 |
41 | # Unicode characters for the tree represantation.
42 | UNICODE_RIGHT = "\u2514"
43 | UNICODE_VERTICAL_RIGHT = "\u251C"
44 | UNICODE_VERTICAL = "\u2502"
45 |
46 | return_string = ""
47 |
48 | folders = subtree.keys()
49 |
50 | for i, folder in enumerate(folders):
51 | unicode_prefix = UNICODE_VERTICAL_RIGHT + " "
52 | row_prefix_addition = UNICODE_VERTICAL + " "
53 |
54 | if i == len(folders) - 1:
55 | unicode_prefix = UNICODE_RIGHT + " "
56 | row_prefix_addition = " "
57 |
58 | return_string += row_prefix + unicode_prefix + self.prefix + folder + "\n"
59 |
60 | return_string += self.__to_string(subtree.get(folder,
61 | {}), row_prefix + row_prefix_addition)
62 |
63 | return return_string
64 |
65 | def tokenize(self, string: str):
66 | """Tokenize a string with the syntax foo>>bar>>((spam>>eggs))++lorem++impsum
67 | Possible Tokens: String token, branch down token >>, brackets (( and )), add token ++
68 | Avoiding Regex. Instead, first envelope the tokens with the safe phrase ::safephrase.
69 | This phrase won't occur in the string, so it can be safely used for splitting in the next step.
70 | In the next step, the string is split up into all tokens by splitting up along ::safephrase
71 | Finally, all empty strings are removed to avoid errors."""
72 |
73 | string = string.replace(">>", "::safephrase>::safephrase")
74 | string = string.replace("++", "::safephrase+::safephrase")
75 | string = string.replace("((", "::safephrase(::safephrase")
76 | string = string.replace("))", "::safephrase)::safephrase")
77 |
78 | tokenized_string = string.split("::safephrase")
79 | if tokenized_string.count("(") != tokenized_string.count(")"):
80 | self.warnings.append(
81 | "Unmatched Brackets detected! This might lead to unexpected behaviour when compiling paths!")
82 |
83 | tokens = [Token(el) for el in tokenized_string if el != ""]
84 |
85 | return tokens
86 |
87 | def parse_tree(self):
88 | """Parse tokens as tree of paths."""
89 | tree: dict = {}
90 | active_folder = ""
91 |
92 | if not self.tokens[-1].is_valid_closing_token():
93 | last_token = str(self.tokens.pop()) * 2
94 | self.warnings.append(
95 | f"A folder path should not end with '{last_token}'!")
96 |
97 | while self.tokens:
98 | if self._return_from_close_bracket:
99 | return tree
100 |
101 | token = self.tokens.pop(0)
102 |
103 | if token.is_string():
104 | tree[str(token)] = {}
105 | active_folder = str(token)
106 | continue
107 |
108 | if token.is_branch_down():
109 | if active_folder == "":
110 | self.warnings.append(
111 | "A '>>' can't be used until at least one Folder name is specified! This rule also applies for subfolders.")
112 | continue
113 |
114 | tree[active_folder] = self.parse_tree()
115 | continue
116 |
117 | if token.is_bracket_open():
118 | tree.update(self.parse_tree())
119 |
120 | self._return_from_close_bracket = False
121 | continue
122 |
123 | if token.is_bracket_close():
124 | self._return_from_close_bracket = True
125 | return tree
126 |
127 | return tree
128 |
129 | def compile_paths(self, subpath: str = "", subtree: dict = None,) -> typing.List[str]:
130 | """Compile the Tree into a list of relative paths."""
131 | paths = []
132 |
133 | if subtree is None:
134 | subtree = self.tree
135 |
136 | for folder in subtree.keys():
137 | path = p.join(subpath, self.prefix + folder)
138 | paths.append(path)
139 |
140 | paths.extend(self.compile_paths(path, subtree.get(folder, {})))
141 |
142 | return paths
143 |
144 | def build_folders(self, project_dir: str) -> None:
145 | """Create the folders on the system."""
146 | for path in self.compile_paths(project_dir):
147 |
148 | if not p.isdir(path):
149 | os.makedirs(path)
150 |
151 |
152 | def subfolder_enum(self, context: 'Context'):
153 | prefs: 'AddonPreferences' = context.preferences.addons[ADDON_PACKAGE].preferences
154 |
155 | tooltip = "Select Folder as target folder for your Blender File. \
156 | Uses Folders from Automatic Setup."
157 | items = [(" ", "Root", tooltip)]
158 |
159 | folders = self.automatic_folders
160 | if context.scene.project_setup == "Custom_Setup":
161 | folders = self.custom_folders
162 |
163 | prefix = ""
164 | if prefs.prefix_with_project_name:
165 | prefix = context.scene.project_name + "_"
166 |
167 | try:
168 | for folder in folders:
169 | for subfolder in Subfolders(folder.folder_name, prefix).compile_paths():
170 | # subfolder = subfolder.replace(
171 | # "/", ">>").replace("//", ">>").replace("\\", ">>")
172 | items.append(
173 | (subfolder, subfolder.replace(prefix, ""), tooltip))
174 | except Exception as e:
175 | print("Exception in function subfolder_enum")
176 | print(e)
177 |
178 | return items
179 |
--------------------------------------------------------------------------------
/objects/token.py:
--------------------------------------------------------------------------------
1 | # ##### BEGIN GPL LICENSE BLOCK #####
2 | #
3 | #
4 | # Copyright (C) <2023>
5 | #
6 | # This program is free software; you can redistribute it and/or
7 | # modify it under the terms of the GNU General Public License
8 | # as published by the Free Software Foundation; either version 3
9 | # of the License, or (at your option) any later version.
10 | #
11 | # This program is distributed in the hope that it will be useful,
12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 | # GNU General Public License for more details.
15 | #
16 | # You should have received a copy of the GNU General Public License
17 | # along with this program; if not, write to the Free Software Foundation,
18 | # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19 | #
20 | # ##### END GPL LICENSE BLOCK #####
21 |
22 | class Token():
23 | def __init__(self, value: str) -> None:
24 | self.value = value
25 | self.type = "STRING"
26 |
27 | if value == ">":
28 | self.type = "BRANCH_DOWN"
29 |
30 | if value == "(":
31 | self.type = "BRACKET_OPEN"
32 |
33 | if value == ")":
34 | self.type = "BRACKET_CLOSE"
35 |
36 | if value == "+":
37 | self.type = "ADD"
38 |
39 | def __str__(self) -> str:
40 | return self.value
41 |
42 | def __eq__(self, __value: 'Token') -> bool:
43 | return self.value == __value.value
44 |
45 | def is_string(self) -> bool:
46 | return self.type == "STRING"
47 |
48 | def is_branch_down(self) -> bool:
49 | return self.type == "BRANCH_DOWN"
50 |
51 | def is_bracket_open(self) -> bool:
52 | return self.type == "BRACKET_OPEN"
53 |
54 | def is_bracket_close(self) -> bool:
55 | return self.type == "BRACKET_CLOSE"
56 |
57 | def is_add(self) -> bool:
58 | return self.type == "ADD"
59 |
60 | def is_valid_closing_token(self) -> bool:
61 | return self.type in ["STRING", "BRACKET_CLOSE"]
62 |
--------------------------------------------------------------------------------
/operators.py:
--------------------------------------------------------------------------------
1 | # ##### BEGIN GPL LICENSE BLOCK #####
2 | #
3 | #
4 | # Copyright (C) <2023>
5 | #
6 | # This program is free software; you can redistribute it and/or
7 | # modify it under the terms of the GNU General Public License
8 | # as published by the Free Software Foundation; either version 3
9 | # of the License, or (at your option) any later version.
10 | #
11 | # This program is distributed in the hope that it will be useful,
12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 | # GNU General Public License for more details.
15 | #
16 | # You should have received a copy of the GNU General Public License
17 | # along with this program; if not, write to the Free Software Foundation,
18 | # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19 | #
20 | # ##### END GPL LICENSE BLOCK #####
21 |
22 | import bpy
23 | from bpy.props import (
24 | StringProperty,
25 | IntProperty,
26 | EnumProperty
27 | )
28 | from bpy.types import (
29 | Context,
30 | Event,
31 | UILayout,
32 | Operator
33 | )
34 |
35 | from bpy_extras.io_utils import ImportHelper
36 |
37 | import os
38 | from os import path as p
39 |
40 | import time
41 |
42 | from .addon_types import AddonPreferences
43 |
44 | from .functions.main_functions import (
45 | generate_file_version_number,
46 | is_file_in_project_folder,
47 | save_filepath,
48 | add_unfinished_project,
49 | finish_project,
50 | write_project_info,
51 | set_file_hidden
52 | )
53 |
54 | from .functions.json_functions import (
55 | decode_json,
56 | encode_json
57 | )
58 |
59 | from .functions.register_functions import (
60 | register_automatic_folders,
61 | unregister_automatic_folders,
62 | register_project_folders
63 | )
64 |
65 | from .objects.path_generator import (
66 | Subfolders,
67 | )
68 |
69 | C = bpy.context
70 |
71 | BPS_DATA_DIR = p.join(p.expanduser(
72 | "~"), "Blender Addons Data", "blender-project-starter")
73 | BPS_DATA_FILE = p.join(BPS_DATA_DIR, "BPS.json")
74 |
75 |
76 | class SUPER_PROJECT_MANAGER_OT_Build_Project(Operator):
77 | bl_idname = "super_project_manager.build_project"
78 | bl_label = "Build Project"
79 | bl_description = "Build Project Operator "
80 | bl_options = {"REGISTER", "UNDO"}
81 |
82 | def execute(self, context: Context):
83 |
84 | D = bpy.data
85 | scene = context.scene
86 | prefs: 'AddonPreferences' = C.preferences.addons[__package__].preferences
87 | projectpath = p.join(context.scene.project_location,
88 | context.scene.project_name)
89 | filename = context.scene.save_file_name
90 |
91 | # Set the prefix.
92 | prefix = ""
93 | if prefs.prefix_with_project_name:
94 | prefix = context.scene.project_name + "_"
95 |
96 | # Set the list of Subfolders.
97 | folders = prefs.automatic_folders
98 | if context.scene.project_setup == "Custom_Setup":
99 | folders = prefs.custom_folders
100 |
101 | # Set the render outputfolder FULL path.
102 | is_render_outputfolder_set = [e.render_outputpath for e in folders]
103 | render_outputfolder = None
104 |
105 | if True in is_render_outputfolder_set:
106 | unparsed_string = folders[is_render_outputfolder_set.index(
107 | True)].folder_name
108 | render_outputfolder = Subfolders(
109 | unparsed_string, prefix).compile_paths(p.join(context.scene.project_location,
110 | context.scene.project_name))[-1] # Use last path.
111 |
112 | # Create the Project Folder.
113 | if not p.isdir(projectpath):
114 | os.makedirs(projectpath)
115 |
116 | # Build all Project Folders
117 | for folder in folders:
118 | try:
119 | s = Subfolders(folder.folder_name, prefix)
120 | s.build_folders(p.join(context.scene.project_location,
121 | context.scene.project_name))
122 | except:
123 | pass
124 |
125 | # Set the subfolder of the Blender file.
126 | subfolder: str = prefs.save_folder.strip()
127 |
128 | # Set the path the Blender File gets saved to.
129 | filepath = D.filepath
130 | old_filepath = None
131 | if filepath == "":
132 | filepath = save_filepath(context, filename, subfolder)
133 | elif not is_file_in_project_folder(context, D.filepath):
134 | old_filepath = D.filepath
135 | filepath = save_filepath(context, filename, subfolder)
136 | if not context.scene.save_file_with_new_name:
137 | filepath = save_filepath(context, p.basename(
138 | D.filepath).split(".blend")[0], subfolder)
139 | elif context.scene.save_blender_file_versioned:
140 | filepath = generate_file_version_number(
141 | D.filepath.split(".blen")[0].split("_v0")[0])
142 |
143 | # Set the render Output path automatically.
144 | if prefs.auto_set_render_outputpath and render_outputfolder and context.scene.set_render_output:
145 | context.scene.render.filepath = "//" + \
146 | p.relpath(render_outputfolder, p.dirname(filepath)) + "\\"
147 |
148 | if context.scene.save_blender_file:
149 | bpy.ops.wm.save_as_mainfile(filepath=filepath,
150 | compress=scene.compress_save,
151 | relative_remap=scene.remap_relative
152 | )
153 |
154 | if context.scene.cut_or_copy and old_filepath:
155 | os.remove(old_filepath)
156 |
157 | # Add the project to the list of unfinished projects.
158 | if context.scene.add_new_project:
159 | add_unfinished_project(projectpath)
160 |
161 | # Store all the necessary data in the project info file
162 | write_project_info(projectpath, filepath)
163 |
164 | # Open the project directory in the explorer, if wanted.
165 | if context.scene.open_directory:
166 | open_location = p.join(context.scene.project_location,
167 | context.scene.project_name)
168 | open_location = p.realpath(open_location)
169 |
170 | bpy.ops.wm.path_open(filepath=open_location)
171 |
172 | return {"FINISHED"}
173 |
174 |
175 | class SUPER_PROJECT_MANAGER_OT_add_folder(Operator):
176 | bl_idname = "super_project_manager.add_folder"
177 | bl_label = "Add Folder"
178 | bl_description = "Add a Folder with the subfolder \
179 | Layout Folder>>Subfolder>>Subsubfolder."
180 |
181 | coming_from: StringProperty()
182 |
183 | def execute(self, context: Context):
184 | pref: 'AddonPreferences' = context.preferences.addons[__package__].preferences
185 |
186 | if self.coming_from == "prefs":
187 | folder = pref.automatic_folders.add()
188 |
189 | else:
190 | folder = pref.custom_folders.add()
191 |
192 | return {"FINISHED"}
193 |
194 |
195 | class SUPER_PROJECT_MANAGER_OT_remove_folder(Operator):
196 | bl_idname = "super_project_manager.remove_folder"
197 | bl_label = "Remove Folder"
198 | bl_description = "Remove the selected Folder."
199 |
200 | index: IntProperty()
201 | coming_from: StringProperty()
202 |
203 | def execute(self, context: Context):
204 | pref: 'AddonPreferences' = context.preferences.addons[__package__].preferences
205 |
206 | if self.coming_from == "prefs":
207 | folder = pref.automatic_folders.remove(self.index)
208 |
209 | else:
210 | folder = pref.custom_folders.remove(self.index)
211 |
212 | return {"FINISHED"}
213 |
214 |
215 | class SUPER_PROJECT_MANAGER_OT_add_project(Operator, ImportHelper):
216 | bl_idname = "super_project_manager.add_project"
217 | bl_label = "Add Project"
218 | bl_description = "Add a Project"
219 |
220 | filter_glob: StringProperty(default='*.filterall', options={'HIDDEN'})
221 |
222 | def execute(self, context: Context):
223 | projectpath = p.dirname(self.filepath)
224 |
225 | message_type, message = add_unfinished_project(projectpath)
226 | self.report(message_type, message)
227 | return {"FINISHED"}
228 |
229 | def draw(self, context: Context):
230 | layout = self.layout
231 | layout.label(text="Please select a project Directory")
232 |
233 |
234 | class SUPER_PROJECT_MANAGER_OT_finish_project(Operator):
235 | bl_idname = "super_project_manager.finish_project"
236 | bl_label = "Finish Project"
237 | bl_description = "Finish the selected Project."
238 | bl_options = {'REGISTER', 'UNDO'}
239 |
240 | index: IntProperty()
241 | project_name: StringProperty()
242 |
243 | def execute(self, context: Context):
244 | finish_project(self.index)
245 | return {'FINISHED'}
246 |
247 | def invoke(self, context: Context, event: Event):
248 | return context.window_manager.invoke_props_dialog(self)
249 |
250 | def draw(self, context: Context):
251 | layout: UILayout = self.layout
252 | # layout.prop(self, "disable")
253 |
254 | layout.label(
255 | text="Congratulations, you've finished your project!")
256 | layout.separator()
257 |
258 | confirmation_text = f"Click OK below to remove '{self.project_name}' from your ToDo List."
259 | if len(confirmation_text) > 55:
260 | # Break up the confirmation text, if it is longer than 55 characters.
261 | pieces = confirmation_text.split(" ")
262 | confirmation_lines = []
263 |
264 | line = ""
265 | for p in pieces:
266 | if len(line + p) > 55:
267 | confirmation_lines.append(line)
268 | line = ""
269 |
270 | line += p + " "
271 |
272 | confirmation_lines.append(line)
273 |
274 | # Display the confirmation text line by line
275 | for line in confirmation_lines:
276 | row = layout.row()
277 | row.scale_y = 0.6
278 | row.label(text=line)
279 |
280 | else:
281 | layout.label(text=confirmation_text)
282 |
283 |
284 | class SUPER_PROJECT_MANAGER_OT_redefine_project_path(Operator, ImportHelper):
285 | bl_idname = "super_project_manager.redefine_project_path"
286 | bl_label = "Update Project path"
287 | bl_description = "Your project has changed location - \
288 | please update the project path"
289 |
290 | name: StringProperty()
291 | filter_glob: StringProperty(default='*.filterall', options={'HIDDEN'})
292 | index: IntProperty()
293 |
294 | def execute(self, context: Context):
295 | projectpath = p.dirname(self.filepath)
296 | self.redefine_project_path(self.index, projectpath)
297 |
298 | message = "Successfully changed project path: " + \
299 | p.basename(projectpath)
300 | self.report({'INFO'}, message)
301 | return {"FINISHED"}
302 |
303 | def draw(self, context: Context):
304 | name = self.name
305 |
306 | layout = self.layout
307 | layout.label(text="Please select your project Directory for:")
308 | layout.label(text=name)
309 |
310 | def redefine_project_path(self, index, new_path):
311 | data = decode_json(BPS_DATA_FILE)
312 |
313 | data["unfinished_projects"][index][1] = new_path
314 | encode_json(data, BPS_DATA_FILE)
315 |
316 |
317 | class SUPER_PROJECT_MANAGER_OT_open_blender_file(Operator):
318 | """Open the latest Blender-File of a project"""
319 | bl_idname = "super_project_manager.open_blender_file"
320 | bl_label = "Open Blender File"
321 | bl_options = {'REGISTER', 'UNDO'}
322 |
323 | filepath: StringProperty()
324 | message_type: StringProperty()
325 | message: StringProperty()
326 |
327 | def execute(self, context: Context):
328 |
329 | bpy.ops.wm.open_mainfile(filepath=self.filepath)
330 | self.report(
331 | {self.message_type}, self.message)
332 | return {"FINISHED"}
333 |
334 |
335 | class SUPER_PROJECT_MANAGER_ot_define_blend_file_location(Operator, ImportHelper):
336 | """This Operator is used to (re)define the location of the projects main Blender File"""
337 | bl_idname = "super_project_manager.define_blend_file_location"
338 | bl_label = "Define Project Blender File Path"
339 | bl_options = {'REGISTER', 'UNDO'}
340 | bl_description = "Can't find the right path to the Blender File. \
341 | Please select the latest Blender File of you Project."
342 |
343 | filter_glob: StringProperty(default='*.blend', options={'HIDDEN'})
344 | message_type: StringProperty()
345 | message: StringProperty()
346 | projectpath: StringProperty()
347 |
348 | def execute(self, context: Context):
349 | # print(self.filepath)
350 | write_project_info(self.projectpath, self.filepath)
351 |
352 | message = "Successfully defined Blender Filepath: " + \
353 | p.basename(self.filepath)
354 | self.report({'INFO'}, message)
355 |
356 | bpy.ops.super_project_manager.open_blender_file(
357 | filepath=self.filepath, message_type="INFO", message=f"Opened the project file found in {self.filepath}")
358 | return {"FINISHED"}
359 |
360 | def draw(self, context: Context):
361 | name = p.basename(self.projectpath)
362 |
363 | layout = self.layout
364 | layout.label(text=self.message_type + ": " + self.message)
365 | layout.label(text="Please select your project Directory for:")
366 | layout.label(text=name)
367 |
368 |
369 | class SUPER_PROJECT_MANAGER_ot_rearrange_up(Operator):
370 | """Rearrange a Project or Label one step up."""
371 | bl_idname = "super_project_manager.rearrange_up"
372 | bl_label = "Rearrange Up"
373 | bl_options = {'REGISTER', 'UNDO'}
374 |
375 | index: IntProperty()
376 |
377 | def execute(self, context: Context):
378 | index = self.index
379 | data = decode_json(BPS_DATA_FILE)
380 |
381 | data["unfinished_projects"][index], data["unfinished_projects"][index -
382 | 1] = data["unfinished_projects"][index - 1], data["unfinished_projects"][index]
383 |
384 | encode_json(data, BPS_DATA_FILE)
385 | return {'FINISHED'}
386 |
387 |
388 | class SUPER_PROJECT_MANAGER_ot_rearrange_down(Operator):
389 | """Rearrange a Project or Label one step down."""
390 | bl_idname = "super_project_manager.rearrange_down"
391 | bl_label = "Rearrange Down"
392 | bl_options = {'REGISTER', 'UNDO'}
393 |
394 | index: IntProperty()
395 |
396 | def execute(self, context: Context):
397 | index = self.index
398 | data = decode_json(BPS_DATA_FILE)
399 |
400 | data["unfinished_projects"][index], data["unfinished_projects"][index +
401 | 1] = data["unfinished_projects"][index + 1], data["unfinished_projects"][index]
402 |
403 | encode_json(data, BPS_DATA_FILE)
404 | return {'FINISHED'}
405 |
406 |
407 | class SUPER_PROJECT_MANAGER_ot_rearrange_to_top(Operator):
408 | """Rearrange a Project or Label to the top."""
409 | bl_idname = "super_project_manager.rearrange_to_top"
410 | bl_label = "Rearrange to Top"
411 | bl_options = {'REGISTER', 'UNDO'}
412 |
413 | index: IntProperty()
414 |
415 | def execute(self, context: Context):
416 | index = self.index
417 | data = decode_json(BPS_DATA_FILE)
418 |
419 | element = data["unfinished_projects"].pop(index)
420 |
421 | data["unfinished_projects"].insert(0, element)
422 |
423 | encode_json(data, BPS_DATA_FILE)
424 | return {'FINISHED'}
425 |
426 |
427 | class SUPER_PROJECT_MANAGER_ot_rearrange_to_bottom(Operator):
428 | """Rearrange a Project or Label to the bottom."""
429 | bl_idname = "super_project_manager.rearrange_to_bottom"
430 | bl_label = "Rearrange to Bottom"
431 | bl_options = {'REGISTER', 'UNDO'}
432 |
433 | index: IntProperty()
434 |
435 | def execute(self, context: Context):
436 | index = self.index
437 | data = decode_json(BPS_DATA_FILE)
438 |
439 | element = data["unfinished_projects"].pop(index)
440 |
441 | data["unfinished_projects"].append(element)
442 |
443 | encode_json(data, BPS_DATA_FILE)
444 | return {'FINISHED'}
445 |
446 |
447 | class SUPER_PROJECT_MANAGER_ot_add_label(Operator):
448 | """Add a category Label to the open projects list."""
449 | bl_idname = "super_project_manager.add_label"
450 | bl_label = "Add Label"
451 | bl_options = {'REGISTER', 'UNDO'}
452 |
453 | label: StringProperty()
454 |
455 | def execute(self, context: Context):
456 | data = decode_json(BPS_DATA_FILE)
457 |
458 | data["unfinished_projects"].append(["label", self.label])
459 |
460 | encode_json(data, BPS_DATA_FILE)
461 | return {'FINISHED'}
462 |
463 | def invoke(self, context: Context, event: Event):
464 | return context.window_manager.invoke_props_dialog(self)
465 |
466 | def draw(self, context: Context):
467 | layout: UILayout = self.layout
468 |
469 | layout.prop(self, "label", text="Category Label Text:")
470 |
471 |
472 | class SUPER_PROJECT_MANAGER_ot_remove_label(Operator):
473 | """Remove a category Label from the open projects list."""
474 | bl_idname = "super_project_manager.remove_label"
475 | bl_label = "Remove Label"
476 | bl_options = {'REGISTER', 'UNDO'}
477 |
478 | index: IntProperty()
479 |
480 | def execute(self, context: Context):
481 | data = decode_json(BPS_DATA_FILE)
482 |
483 | data["unfinished_projects"].pop(self.index)
484 |
485 | encode_json(data, BPS_DATA_FILE)
486 | return {'FINISHED'}
487 |
488 |
489 | class SUPER_PROJECT_MANAGER_ot_change_label(Operator):
490 | """Change a category Label from the open projects list."""
491 | bl_idname = "super_project_manager.change_label"
492 | bl_label = "Change Label"
493 | bl_options = {'REGISTER', 'UNDO'}
494 |
495 | index: IntProperty()
496 | label: StringProperty()
497 |
498 | def execute(self, context: Context):
499 | data = decode_json(BPS_DATA_FILE)
500 |
501 | data["unfinished_projects"][self.index] = ["label", self.label]
502 |
503 | encode_json(data, BPS_DATA_FILE)
504 | return {'FINISHED'}
505 |
506 | def invoke(self, context: Context, event: Event):
507 | return context.window_manager.invoke_props_dialog(self)
508 |
509 | def draw(self, context: Context):
510 | layout: UILayout = self.layout
511 |
512 | layout.prop(self, "label", text="Category Label Text:")
513 |
514 |
515 | class SUPER_PROJECT_MANAGER_ot_add_structure_set(Operator):
516 | """Adds a new folder structure set."""
517 | bl_idname = "super_project_manager.add_structure_set"
518 | bl_label = "Add Folder Structure Set"
519 | bl_options = {'REGISTER', 'UNDO'}
520 |
521 | name: StringProperty()
522 |
523 | def execute(self, context: Context):
524 | prefs: 'AddonPreferences' = context.preferences.addons[__package__].preferences
525 |
526 | data = decode_json(BPS_DATA_FILE)
527 |
528 | data["automatic_folders"][self.name] = []
529 |
530 | encode_json(data, BPS_DATA_FILE)
531 |
532 | prefs.folder_structure_sets = self.name
533 |
534 | return {'FINISHED'}
535 |
536 | def invoke(self, context: Context, event: Event):
537 | return context.window_manager.invoke_props_dialog(self)
538 |
539 | def draw(self, context: Context):
540 | layout = self.layout
541 |
542 | layout.prop(self, "name", text="Folder Structure Set Name:")
543 |
544 |
545 | class SUPER_PROJECT_MANAGER_ot_remove_structure_set(Operator):
546 | """Remove a folder structure set"""
547 | bl_idname = "super_project_manager.remove_structure_set"
548 | bl_label = "Remove Set"
549 | bl_options = {'REGISTER', 'UNDO'}
550 |
551 | structure_set: StringProperty()
552 |
553 | def execute(self, context: Context):
554 | prefs: 'AddonPreferences' = context.preferences.addons[__package__].preferences
555 | prefs.folder_structure_sets = "Default Folder Set"
556 |
557 | if self.structure_set == "Default Folder Set":
558 | return {'FINISHED'}
559 |
560 | data = decode_json(BPS_DATA_FILE)
561 |
562 | data["automatic_folders"].pop(self.structure_set)
563 |
564 | encode_json(data, BPS_DATA_FILE)
565 |
566 | return {'FINISHED'}
567 |
568 |
569 | class SUPER_PROJECT_MANAGER_OT_add_panel_project(Operator):
570 | """Add a project to the project panel."""
571 | bl_idname = "super_project_manager.add_panel_project"
572 | bl_label = "Add"
573 | bl_options = {'REGISTER', 'UNDO'}
574 |
575 | def execute(self, context: 'Context'):
576 | data: dict = decode_json(BPS_DATA_FILE)
577 | prefs: 'AddonPreferences' = context.preferences.addons[__package__].preferences
578 |
579 | project_path = p.normpath(
580 | context.space_data.params.directory.decode("utf-8"))
581 |
582 | options = data.get("filebrowser_panel_options", [])[:]
583 | options.append(project_path)
584 |
585 | data["filebrowser_panel_options"] = options
586 |
587 | encode_json(data, BPS_DATA_FILE)
588 |
589 | prefs.active_project = project_path
590 |
591 | return {'FINISHED'}
592 |
593 |
594 | class SUPER_PROJECT_MANAGER_OT_remove_panel_project(Operator):
595 | """Remove a project from the project panel."""
596 | bl_idname = "super_project_manager.remove_panel_project"
597 | bl_label = "remove"
598 | bl_options = {'REGISTER', 'UNDO'}
599 |
600 | def execute(self, context: 'Context'):
601 | data: dict = decode_json(BPS_DATA_FILE)
602 | prefs: 'AddonPreferences' = context.preferences.addons[__package__].preferences
603 |
604 | options = data.get("filebrowser_panel_options", [])[:]
605 | options.remove(prefs.active_project)
606 |
607 | data["filebrowser_panel_options"] = options
608 |
609 | encode_json(data, BPS_DATA_FILE)
610 |
611 | prefs.active_project = options[0]
612 |
613 | return {'FINISHED'}
614 |
615 |
616 | class SUPER_PROJECT_MANAGER_OT_panel_folder_base(Operator):
617 | """Base operator for panel folder operations"""
618 | bl_idname = "super_project_manager.panel_folder_base"
619 | bl_label = "Panel Folder Base"
620 | bl_options = {'REGISTER', 'UNDO'}
621 |
622 | def invoke(self, context: Context, event: Event):
623 | prefs: 'AddonPreferences' = context.preferences.addons[__package__].preferences
624 |
625 | if p.exists(p.join(prefs.active_project, ".blender_pm")):
626 | return self.execute(context)
627 |
628 | return context.window_manager.invoke_props_dialog(self)
629 |
630 | def draw(self, context: Context):
631 | layout: 'UILayout' = self.layout
632 |
633 | layout.label(text="This project is missing important metadata.")
634 | layout.label(
635 | text="Do you want to continue? (Metadata will be added now)")
636 |
637 | def execute(self, context: 'Context'):
638 | prefs: 'AddonPreferences' = context.preferences.addons[__package__].preferences
639 | project_metadata_file = p.join(prefs.active_project, ".blender_pm")
640 |
641 | if not p.exists(project_metadata_file):
642 | encode_json({"blender_files": {
643 | "main_file": "",
644 | "other_files": []
645 | }, "build_date": time.time()}, project_metadata_file)
646 |
647 | set_file_hidden(project_metadata_file, False)
648 |
649 | data: dict = decode_json(project_metadata_file)
650 |
651 | folders = data.get("displayed_project_folders", [])[:]
652 |
653 | if len(folders) == 0:
654 | for f in prefs.project_paths:
655 | folders.append({"folder_path": f.path})
656 |
657 | data["displayed_project_folders"] = self.manipulate_folders(
658 | context, folders)
659 | encode_json(data, project_metadata_file)
660 | register_project_folders(prefs.project_paths, prefs.active_project)
661 |
662 | set_file_hidden(project_metadata_file)
663 |
664 | return {'FINISHED'}
665 |
666 | def manipulate_folders(self, context: 'Context', folders: 'list') -> list:
667 | return folders
668 |
669 |
670 | class SUPER_PROJECT_MANAGER_OT_add_panel_project_folder(SUPER_PROJECT_MANAGER_OT_panel_folder_base):
671 | """Add the current folder path to the project panel."""
672 | bl_idname = "super_project_manager.add_panel_project_folder"
673 | bl_label = "Add Folder"
674 | bl_options = {'REGISTER', 'UNDO'}
675 |
676 | def manipulate_folders(self, context: Context, folders: list) -> list:
677 | folders = folders[:]
678 |
679 | folder_path = p.normpath(
680 | context.space_data.params.directory.decode("utf-8"))
681 | folders.append({"folder_path": folder_path})
682 |
683 | paths = []
684 | i = 0
685 | while i < len(folders):
686 | if folders[i].get("folder_path", "") in paths:
687 | folders.pop(i)
688 | continue
689 |
690 | paths.append(folders[i].get("folder_path", ""))
691 | i += 1
692 |
693 | return folders
694 |
695 |
696 | class SUPER_PROJECT_MANAGER_OT_remove_panel_project_folder(SUPER_PROJECT_MANAGER_OT_panel_folder_base):
697 | """Remove a folder from the project panel."""
698 | bl_idname = "super_project_manager.remove_panel_project_folder"
699 | bl_label = "remove"
700 | bl_options = {'REGISTER', 'UNDO'}
701 |
702 | index: IntProperty()
703 |
704 | def manipulate_folders(self, context: Context, folders: list) -> list:
705 | folders = folders[:]
706 | folders.pop(self.index)
707 |
708 | return folders
709 |
710 |
711 | class SUPER_PROJECT_MANAGER_OT_move_panel_project_folder(SUPER_PROJECT_MANAGER_OT_panel_folder_base):
712 | """Rearrange the project panel"""
713 | bl_idname = "super_project_manager.move_panel_project_folder"
714 | bl_label = "Move"
715 | bl_options = {'REGISTER', 'UNDO'}
716 |
717 | index: IntProperty()
718 | direction: IntProperty()
719 |
720 | def manipulate_folders(self, context: Context, folders: list) -> list:
721 | folders = folders[:]
722 |
723 | f = folders.pop(self.index)
724 | folders.insert(self.index + self.direction, f)
725 |
726 | return folders
727 |
728 |
729 | classes = (
730 | SUPER_PROJECT_MANAGER_OT_add_folder,
731 | SUPER_PROJECT_MANAGER_OT_remove_folder,
732 | SUPER_PROJECT_MANAGER_OT_Build_Project,
733 | SUPER_PROJECT_MANAGER_OT_add_project,
734 | SUPER_PROJECT_MANAGER_OT_finish_project,
735 | SUPER_PROJECT_MANAGER_OT_redefine_project_path,
736 | SUPER_PROJECT_MANAGER_OT_open_blender_file,
737 | SUPER_PROJECT_MANAGER_ot_define_blend_file_location,
738 | SUPER_PROJECT_MANAGER_ot_rearrange_up,
739 | SUPER_PROJECT_MANAGER_ot_rearrange_down,
740 | SUPER_PROJECT_MANAGER_ot_rearrange_to_top,
741 | SUPER_PROJECT_MANAGER_ot_rearrange_to_bottom,
742 | SUPER_PROJECT_MANAGER_ot_add_label,
743 | SUPER_PROJECT_MANAGER_ot_remove_label,
744 | SUPER_PROJECT_MANAGER_ot_change_label,
745 | SUPER_PROJECT_MANAGER_ot_add_structure_set,
746 | SUPER_PROJECT_MANAGER_ot_remove_structure_set,
747 | SUPER_PROJECT_MANAGER_OT_add_panel_project,
748 | SUPER_PROJECT_MANAGER_OT_remove_panel_project,
749 | SUPER_PROJECT_MANAGER_OT_add_panel_project_folder,
750 | SUPER_PROJECT_MANAGER_OT_remove_panel_project_folder,
751 | SUPER_PROJECT_MANAGER_OT_move_panel_project_folder,
752 | )
753 |
754 |
755 | def register():
756 | prefs: 'AddonPreferences' = C.preferences.addons[__package__].preferences
757 | register_automatic_folders(prefs.automatic_folders, prefs.previous_set)
758 | for cls in classes:
759 | bpy.utils.register_class(cls)
760 |
761 |
762 | def unregister():
763 | prefs: 'AddonPreferences' = C.preferences.addons[__package__].preferences
764 | unregister_automatic_folders(prefs.automatic_folders, prefs.previous_set)
765 | for cls in reversed(classes):
766 | bpy.utils.unregister_class(cls)
767 |
--------------------------------------------------------------------------------
/panels.py:
--------------------------------------------------------------------------------
1 | # ##### BEGIN GPL LICENSE BLOCK #####
2 | #
3 | #
4 | # Copyright (C) <2021>
5 | # Modified <2021>
6 | #
7 | # This program is free software; you can redistribute it and/or
8 | # modify it under the terms of the GNU General Public License
9 | # as published by the Free Software Foundation; either version 3
10 | # of the License, or (at your option) any later version.
11 | #
12 | # This program is distributed in the hope that it will be useful,
13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 | # GNU General Public License for more details.
16 | #
17 | # You should have received a copy of the GNU General Public License
18 | # along with this program; if not, write to the Free Software Foundation,
19 | # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
20 | #
21 | # ##### END GPL LICENSE BLOCK #####
22 |
23 | # import operators
24 | import bpy
25 | from bpy.types import (
26 | Context,
27 | Panel,
28 | UILayout,
29 | UIList,
30 | )
31 |
32 | import os
33 | from os import path as p
34 |
35 | from typing import List
36 |
37 | from .addon_types import AddonPreferences
38 |
39 | from .functions.main_functions import is_file_in_project_folder
40 |
41 | from .functions.json_functions import decode_json
42 |
43 | C = bpy.context
44 |
45 | BPS_DATA_FILE = p.join(
46 | p.expanduser("~"),
47 | "Blender Addons Data",
48 | "blender-project-starter",
49 | "BPS.json"
50 | )
51 |
52 |
53 | class SUPER_PROJECT_MANAGER_PT_main_panel(Panel):
54 | bl_label = "Super Project Manager"
55 | bl_idname = "super_project_manager_PT__main_panel"
56 | bl_space_type = "PROPERTIES"
57 | bl_region_type = "WINDOW"
58 | bl_context = "scene"
59 | bl_order = 0
60 |
61 | def draw(self, context: Context):
62 | pass
63 |
64 |
65 | class SUPER_PROJECT_MANAGER_PT_starter_main_panel(Panel):
66 | bl_label = "Project Starter"
67 | bl_idname = "super_project_manager_PT_starter_main_panel"
68 | bl_space_type = "PROPERTIES"
69 | bl_region_type = "WINDOW"
70 | bl_context = "scene"
71 | bl_parent_id = "super_project_manager_PT__main_panel"
72 |
73 | def draw(self, context: Context):
74 | prefs: 'AddonPreferences' = C.preferences.addons[__package__].preferences
75 |
76 | layout: UILayout = self.layout
77 |
78 | # Project Name Property
79 | row = layout.row()
80 | row.label(text="Project Name")
81 |
82 | row = layout.row()
83 | row.prop(context.scene, "project_name", text="")
84 |
85 | layout.separator(factor=0.5)
86 |
87 | # Project Location Property
88 | row = layout.row()
89 | row.label(text="Project Location")
90 |
91 | row = layout.row()
92 | row.prop(context.scene,
93 | "project_location",
94 | text="")
95 |
96 | layout.separator(factor=0.5)
97 |
98 | # Layout all options for saving the Blender File.
99 | layout.prop(context.scene, "save_blender_file",
100 | text="Save Blender File")
101 | if context.scene.save_blender_file:
102 | self.draw_file_options(context)
103 |
104 | layout.separator(factor=2.0)
105 |
106 | # Project Setup (Automatic/Manual)
107 | row = layout.row()
108 | row.label(text="Project Setup")
109 | row = layout.row()
110 | row.prop(context.scene,
111 | "project_setup",
112 | text="",
113 | expand=False)
114 |
115 | if context.scene.project_setup == "Custom_Setup":
116 | box = layout.box()
117 | box.label(text="Custom Folder Setup",
118 | icon="NEWFOLDER")
119 |
120 | render_outpath_active = True in [
121 | e.render_outputpath for e in prefs.custom_folders]
122 |
123 | for index, folder in enumerate(prefs.custom_folders):
124 | row = box.row()
125 |
126 | # Folder Name
127 | row.prop(folder, "folder_name", text="")
128 |
129 | # Render Output
130 | if prefs.auto_set_render_outputpath:
131 | col = row.column()
132 | col.enabled = folder.render_outputpath or not render_outpath_active
133 | col.prop(folder, "render_outputpath",
134 | text="", icon="OUTPUT", emboss=folder.render_outputpath)
135 |
136 | # Remove button
137 | op = row.operator("super_project_manager.remove_folder",
138 | text="",
139 | emboss=False,
140 | icon="PANEL_CLOSE")
141 | op.index = index
142 | op.coming_from = "panel"
143 |
144 | row = box.row()
145 | op = row.operator("super_project_manager.add_folder",
146 | icon="PLUS")
147 | op.coming_from = "panel"
148 |
149 | layout.separator(factor=1.0)
150 |
151 | layout.prop(context.scene,
152 | "add_new_project",
153 | text="Add project to unfinished projects list.",
154 | expand=False)
155 |
156 | layout.prop(context.scene,
157 | "open_directory",
158 | text="Open Directory after Build",
159 | expand=False)
160 |
161 | # Build Project Button
162 | row = layout.row(align=False)
163 | row.scale_x = 2.0
164 | row.scale_y = 2.0
165 | row.operator("super_project_manager.build_project",
166 | text="Build Project") # , icon_value=ic)
167 |
168 | def draw_file_options(self, context: Context):
169 | D = bpy.data
170 | prefs: 'AddonPreferences' = C.preferences.addons[__package__].preferences
171 |
172 | layout: UILayout = self.layout
173 | layout.enabled = context.scene.save_blender_file
174 |
175 | box = layout.box()
176 |
177 | if D.filepath == "":
178 | # File Name
179 | row = box.row()
180 | row.label(text="File Name")
181 | row = box.row()
182 | row.prop(context.scene, "save_file_name", text="")
183 |
184 | # Subdirectory
185 | row = box.row()
186 | row.label(text="Subdirectory")
187 | row = box.row()
188 | row.prop(prefs, "save_folder", text="")
189 |
190 | elif not is_file_in_project_folder(context, D.filepath):
191 | if context.scene.cut_or_copy:
192 | box.prop(context.scene,
193 | "cut_or_copy",
194 | text="Change to Copy File",
195 | toggle=True)
196 | else:
197 | box.prop(context.scene,
198 | "cut_or_copy",
199 | text="Change to Cut File",
200 | toggle=True)
201 | box.prop(prefs, "save_folder")
202 | box.prop(context.scene,
203 | "save_file_with_new_name",
204 | text="Save with new File Name")
205 | if context.scene.save_file_with_new_name:
206 | box.prop(context.scene,
207 | "save_file_name",
208 | text="Save File Name")
209 | else:
210 | box.prop(context.scene, "save_blender_file_versioned")
211 |
212 | box.separator()
213 |
214 | box.label(text="Further options:")
215 |
216 | # Remap relative
217 | row = box.row(align=False)
218 | row.prop(context.scene,
219 | "remap_relative",
220 | text="Remap Relative")
221 |
222 | # Compress file
223 | row = box.row(align=False)
224 | row.prop(context.scene,
225 | "compress_save",
226 | text="Compress File")
227 |
228 | # Automatically set the render output.
229 | if prefs.auto_set_render_outputpath:
230 | row = box.row()
231 | row.prop(context.scene,
232 | "set_render_output",
233 | icon="OUTPUT",
234 | text="Set Render Output")
235 |
236 |
237 | class SUPER_PROJECT_MANAGER_PT_Open_Projects_subpanel(Panel):
238 | bl_label = "Project Manager"
239 | bl_idname = "super_project_manager_PT_Open_Projects_subpanel"
240 | bl_space_type = "PROPERTIES"
241 | bl_region_type = "WINDOW"
242 | bl_context = "scene"
243 | bl_parent_id = "super_project_manager_PT__main_panel"
244 |
245 | def draw(self, context: Context):
246 | layout: UILayout = self.layout
247 |
248 | data: List[List[str]] = decode_json(
249 | BPS_DATA_FILE)["unfinished_projects"]
250 |
251 | project_count = len([e for e in data if e[0] == "project"])
252 | layout.label(
253 | text="Here are your {} unfinished projects:".format(project_count))
254 |
255 | if project_count == 0:
256 | url = "https://bd-links.netlify.app/randorender"
257 |
258 | layout.separator(factor=0.25)
259 | layout.label(text="Nothing to do.", icon="CHECKMARK")
260 | layout.operator(
261 | "wm.url_open", text="Find a project idea").url = url
262 | layout.separator(factor=0.75)
263 |
264 | elif context.scene.project_rearrange_mode:
265 | self.draw_rearrange(context, data)
266 | layout.operator("super_project_manager.add_label",
267 | text="Add Category Label",
268 | icon="PLUS")
269 | layout.prop(context.scene, "project_rearrange_mode",
270 | text="Switch to Project Display", toggle=True)
271 | else:
272 | self.draw_normal(context, data)
273 | layout.prop(context.scene, "project_rearrange_mode",
274 | text="Switch to Rearrange Mode", toggle=True)
275 |
276 | layout.operator("super_project_manager.add_project",
277 | text="Add unfinished project",
278 | icon="PLUS")
279 |
280 | # Return the path to the latest Blender File.
281 | # If the latest Blender File is unavailable, the path to an older File
282 | # is returned. If no file is available, None is returned.
283 | def path_to_blend(self, projectpath):
284 | if not p.exists(p.join(projectpath, ".blender_pm")):
285 | return None, "WARNING", "Your project is not a Super Project Manager Project."
286 |
287 | blender_files = decode_json(
288 | p.join(projectpath, ".blender_pm"))["blender_files"]
289 | filepath = blender_files["main_file"]
290 | if p.exists(filepath):
291 | # self.report(
292 | # {"INFO"}, "Opened the project file found in {}".format(filepath))
293 | return filepath, "INFO", "Opened the project file found in {}".format(filepath)
294 |
295 | for filepath in blender_files["other_files"][::-1]:
296 | if p.exists(filepath):
297 | # self.report(
298 | # {"WARNING"}, "The latest File is unavailable. Opening the newest version available: {}".format(filepath))
299 | return filepath, "WARNING", "The latest File is unavailable. Opening the newest version available: {}".format(filepath)
300 |
301 | return None, "ERROR", "No Blender File found in this project! Please select the latest project file."
302 |
303 | # Drawing Function for the regular project display mode.
304 | def draw_normal(self, context: Context, data):
305 | layout: UILayout = self.layout
306 |
307 | for index, entry in enumerate(data):
308 | type = entry[0]
309 | content = entry[1]
310 |
311 | if type == "project":
312 | project = content
313 | project_name = p.basename(project)
314 |
315 | row = layout.row()
316 |
317 | row.label(text=project_name)
318 |
319 | if not p.exists(project):
320 | op = row.operator("super_project_manager.redefine_project_path",
321 | text="",
322 | icon="ERROR")
323 | op.index = index
324 | op.name = project_name
325 |
326 | operator = "super_project_manager.open_blender_file"
327 | if not self.path_to_blend(project)[0]:
328 | operator = "super_project_manager.define_blend_file_location"
329 | op = row.operator(operator,
330 | text="",
331 | emboss=False,
332 | icon="BLENDER")
333 | project_details = self.path_to_blend(project)
334 | if project_details[0]:
335 | op.filepath = project_details[0]
336 | else:
337 | op.projectpath = project
338 | op.message_type = project_details[1]
339 | op.message = project_details[2]
340 |
341 | op = row.operator("wm.path_open",
342 | text="",
343 | emboss=False,
344 | icon="FOLDER_REDIRECT")
345 | op.filepath = project
346 |
347 | op = row.operator("super_project_manager.finish_project",
348 | text="",
349 | emboss=False,
350 | icon="CHECKMARK")
351 | op.index = index
352 | op.project_name = project_name
353 |
354 | if type == "label":
355 | label = content
356 | row = layout.row()
357 | row.label(text="")
358 | row = layout.row()
359 | row.label(text=label)
360 |
361 | # Drawing Function for the project rearrange mode.
362 | def draw_rearrange(self, context: Context, data):
363 | layout: UILayout = self.layout
364 | prefs: 'AddonPreferences' = context.preferences.addons[__package__].preferences
365 |
366 | for index, entry in enumerate(data):
367 | type = entry[0]
368 | content = entry[1]
369 |
370 | content = p.basename(content)
371 | row = layout.row()
372 | row.label(text=content)
373 |
374 | if type == "label":
375 | op = row.operator("super_project_manager.remove_label",
376 | text="",
377 | emboss=False,
378 | icon="PANEL_CLOSE")
379 | op.index = index
380 | op = row.operator("super_project_manager.change_label",
381 | text="",
382 | emboss=False,
383 | icon="FILE_TEXT")
384 | op.index = index
385 |
386 | if index > 0 and prefs.enable_additional_rearrange_tools:
387 | op = row.operator("super_project_manager.rearrange_to_top",
388 | text="",
389 | emboss=False,
390 | icon="EXPORT")
391 | op.index = index
392 |
393 | if index > 0:
394 | op = row.operator("super_project_manager.rearrange_up",
395 | text="",
396 | emboss=False,
397 | icon="SORT_DESC")
398 | op.index = index
399 |
400 | if index < len(data) - 1:
401 | op = row.operator("super_project_manager.rearrange_down",
402 | text="",
403 | emboss=False,
404 | icon="SORT_ASC")
405 | op.index = index
406 |
407 | if index < len(data) - 1 and prefs.enable_additional_rearrange_tools:
408 | op = row.operator("super_project_manager.rearrange_to_bottom",
409 | text="",
410 | emboss=False,
411 | icon="IMPORT")
412 | op.index = index
413 |
414 |
415 | class SUPER_PROJECT_MANAGER_PT_filebrowser_project_paths(Panel):
416 | bl_idname = "SUPER_PROJECT_MANAGER_PT_filebrowser_project_paths"
417 | bl_label = "Project Paths"
418 | bl_space_type = "FILE_BROWSER"
419 | bl_region_type = "TOOLS" # Works for adding a category for preset file paths
420 | # bl_region_type = "WINDOW" # No failure, doesn't show
421 | # bl_region_type = "HEADER" # No failure, doesn't show
422 | # bl_region_type = "UI" # Shows in the topbar
423 | # bl_region_type = "TOOL_PROPS" # Shows in the right panel/sidebar
424 | # bl_region_type = "EXECUTE" # Shows at the bottom (Below Open/Cancel)
425 |
426 | bl_category = "Bookmarks"
427 | # bl_options = {'DEFAULT_CLOSED'}
428 |
429 | # @classmethod
430 | # def poll(self, context):
431 | # Testing the poll method
432 | # return bpy.data.scenes["Scene"].frame_current == 1
433 |
434 | # def draw_header(self, context: Context):
435 | # self.layout.label(text="Project Name") # Dynamic panel title
436 |
437 | def draw(self, context: bpy.types.Context):
438 | layout: 'UILayout' = self.layout
439 | space = context.space_data
440 | scene = context.scene
441 | prefs = context.preferences.addons[__package__].preferences
442 |
443 | row = layout.row(align=True)
444 | row.prop(prefs, "active_project", text="")
445 | row.operator("super_project_manager.add_panel_project",
446 | text="", icon="ADD")
447 | row.operator(
448 | "super_project_manager.remove_panel_project", text="", icon="REMOVE")
449 |
450 | row = layout.row()
451 | row.template_list("SPM_UL_dir", "", prefs, "project_paths",
452 | prefs, "active_project_path", item_dyntip_propname="path", rows=1, maxrows=10) # Paths layout
453 |
454 | row = layout.row()
455 | row.operator("super_project_manager.add_panel_project_folder",
456 | icon="ADD")
457 |
458 |
459 | class SPM_UL_dir(UIList):
460 | def draw_item(self, _context, layout, _data, item, icon, _active_data, _active_propname, _index):
461 | direntry = item
462 | # space = context.space_data
463 |
464 | is_active_path = _index == _active_data.active_project_path
465 |
466 | if self.layout_type in {'DEFAULT', 'COMPACT'}:
467 | row: 'UILayout' = layout.row(align=True)
468 | row.enabled = direntry.is_valid
469 |
470 | # Non-editable entries would show grayed-out, which is bad in this specific case, so switch to mere label.
471 | row.label(text=direntry.name, icon=item.icon)
472 |
473 | if is_active_path:
474 | self.draw_active_path(
475 | row, _index, len(_active_data.project_paths))
476 |
477 | elif self.layout_type == 'GRID':
478 | layout.alignment = 'CENTER'
479 | layout.prop(direntry, "path", text="")
480 |
481 | def draw_active_path(self, layout: 'UILayout', index: int, project_paths_length: int):
482 | UP = -1
483 | DOWN = 1
484 |
485 | if index > 0:
486 | op = layout.operator("super_project_manager.move_panel_project_folder",
487 | icon='SORT_DESC', text="", emboss=False)
488 | op.index = index
489 | op.direction = UP
490 |
491 | if index < project_paths_length - 1:
492 | op = layout.operator("super_project_manager.move_panel_project_folder",
493 | icon='SORT_ASC', text="", emboss=False)
494 | op.index = index
495 | op.direction = DOWN
496 |
497 | layout.separator(factor=0.5)
498 | op = layout.operator("super_project_manager.remove_panel_project_folder",
499 | icon='X', text="", emboss=False)
500 | op.index = index
501 |
502 |
503 | classes = (
504 | SUPER_PROJECT_MANAGER_PT_main_panel,
505 | SUPER_PROJECT_MANAGER_PT_starter_main_panel,
506 | # SUPER_PROJECT_MANAGER_PT_Blender_File_save_options_subpanel,
507 | SUPER_PROJECT_MANAGER_PT_Open_Projects_subpanel,
508 | SUPER_PROJECT_MANAGER_PT_filebrowser_project_paths,
509 | SPM_UL_dir,
510 | )
511 |
512 |
513 | def register():
514 | for cls in classes:
515 | bpy.utils.register_class(cls)
516 |
517 |
518 | def unregister():
519 | for cls in reversed(classes):
520 | bpy.utils.unregister_class(cls)
521 |
--------------------------------------------------------------------------------
/prefs.py:
--------------------------------------------------------------------------------
1 | # ##### BEGIN GPL LICENSE BLOCK #####
2 | #
3 | #
4 | # Copyright (C) <2023>
5 | #
6 | # This program is free software; you can redistribute it and/or
7 | # modify it under the terms of the GNU General Public License
8 | # as published by the Free Software Foundation; either version 3
9 | # of the License, or (at your option) any later version.
10 | #
11 | # This program is distributed in the hope that it will be useful,
12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 | # GNU General Public License for more details.
15 | #
16 | # You should have received a copy of the GNU General Public License
17 | # along with this program; if not, write to the Free Software Foundation,
18 | # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19 | #
20 | # ##### END GPL LICENSE BLOCK #####
21 |
22 | import bpy
23 | from bpy.props import (
24 | StringProperty,
25 | EnumProperty,
26 | CollectionProperty,
27 | BoolProperty,
28 | IntProperty
29 | )
30 | from bpy.types import (
31 | PropertyGroup,
32 | AddonPreferences,
33 | Context,
34 | UILayout
35 | )
36 |
37 | import os
38 | from os import path as p
39 |
40 | import re
41 |
42 | from . import addon_updater_ops
43 |
44 | from .functions.main_functions import (
45 | structure_sets_enum,
46 | structure_sets_enum_update,
47 | active_project_enum,
48 | active_project_enum_update
49 | )
50 |
51 | from .objects.path_generator import (
52 | Subfolders,
53 | subfolder_enum,
54 | )
55 |
56 | C = bpy.context
57 | D = bpy.data
58 |
59 | FOLDER_BOX_PADDING_X = 0.1
60 | FOLDER_BOX_PADDING_Y = 1
61 | WARNING_MARGIN_BOTTOM = 0.2
62 | ADD_FOLDER_BUTTON_MARGIN_TOP = 0.4
63 |
64 |
65 | class project_folder_props(PropertyGroup):
66 |
67 | render_outputpath: BoolProperty(
68 | name="Render Output",
69 | description="Set the last path this folder input results in as output path for your renders",
70 | default=False)
71 | folder_name: StringProperty(
72 | name="Folder Name",
73 | description="Automatic Setup Folder. \
74 | Format for Adding Subfolders: Folder>>Subfolder>>Subsubfolder",
75 | default="")
76 |
77 |
78 | class FilebrowserEntry(PropertyGroup):
79 |
80 | icon: StringProperty(default="FILE_FOLDER")
81 | is_valid: BoolProperty()
82 | """Whether this path is currently reachable"""
83 |
84 | name: StringProperty()
85 |
86 | path: StringProperty()
87 |
88 | use_save: BoolProperty()
89 | """Whether this path is saved in bookmarks, or generated from OS"""
90 |
91 |
92 | def get_active_project_path(self):
93 | active_directory = bpy.context.space_data.params.directory.decode(
94 | encoding="utf-8")
95 |
96 | for i, p in enumerate(self.project_paths):
97 | if os.path.normpath(p.path) == os.path.normpath(active_directory):
98 | return i
99 |
100 | return -1
101 |
102 |
103 | def set_active_project_path(self, value):
104 | bpy.context.space_data.params.directory = self.project_paths[value].path.encode(
105 | )
106 |
107 | # Custom setter logic
108 | self["active_project_path"] = value
109 |
110 |
111 | def layout_tab_items(self, context: 'Context'):
112 | items = [
113 | ("misc_settings", "General", "General settings of Super Project Manager."),
114 | ("folder_structure_sets", "Folder Structures",
115 | "Manage your folder structure settings."),
116 | ]
117 |
118 | if bpy.app.version < (4, 2):
119 | items.append(
120 | ("updater", "Updater", "Check for updates and install them."),
121 | )
122 |
123 | return items
124 |
125 |
126 | class SUPER_PROJECT_MANAGER_APT_Preferences(AddonPreferences):
127 | bl_idname = __package__
128 | previous_set: StringProperty(default="Default Folder Set")
129 |
130 | custom_folders: CollectionProperty(type=project_folder_props)
131 |
132 | automatic_folders: CollectionProperty(type=project_folder_props)
133 |
134 | active_project: EnumProperty(
135 | name="Active Project",
136 | description="Which project should be displayed in the Filebrowser panel.",
137 | items=active_project_enum,
138 | update=active_project_enum_update
139 | )
140 |
141 | project_paths: CollectionProperty(type=FilebrowserEntry)
142 | active_project_path: IntProperty(
143 | name="Custom Property",
144 | get=get_active_project_path,
145 | set=set_active_project_path
146 | )
147 |
148 | layout_tab: EnumProperty(
149 | name="UI Section",
150 | description="Display the different UI Elements of the Super Project Manager preferences.",
151 | items=layout_tab_items,
152 | default=0)
153 |
154 | folder_structure_sets: EnumProperty(
155 | name="Folder Structure Set",
156 | description="A list of all available folder sets.",
157 | items=structure_sets_enum,
158 | update=structure_sets_enum_update
159 | )
160 |
161 | prefix_with_project_name: BoolProperty(
162 | name="Project Name Prefix",
163 | description="If enabled, use the project name as prefix for all folders",
164 | default=False,
165 | )
166 |
167 | auto_set_render_outputpath: BoolProperty(
168 | name="Auto Set Render Output Path",
169 | description="If enabled, the feature to automatically set the Render Output path can be used",
170 | default=False,
171 | )
172 |
173 | default_project_location: StringProperty(
174 | name="Default Project Location",
175 | subtype="DIR_PATH",
176 | default=p.expanduser("~")
177 | )
178 |
179 | save_folder: EnumProperty(
180 | name="Save to",
181 | items=subfolder_enum
182 | )
183 |
184 | preview_subfolders: BoolProperty(
185 | name="Preview compiled Subfolders",
186 | description="Show the compiled subfolder-strings in the preferences",
187 | default=False
188 | )
189 |
190 | enable_additional_rearrange_tools: BoolProperty(
191 | name="Enable additional rearrange operators",
192 | description="Enable the 'Move to top' and 'Move to bottom' operator. This will make the rearrange panel more crowded",
193 | default=False
194 | )
195 |
196 | auto_check_update: BoolProperty(
197 | name="Auto-check for Update",
198 | description="If enabled, auto-check for updates using an interval",
199 | default=True,
200 | )
201 | updater_intrval_months: IntProperty(
202 | name="Months",
203 | description="Number of months between checking for updates",
204 | default=0,
205 | min=0
206 | )
207 | updater_intrval_days: IntProperty(
208 | name="Days",
209 | description="Number of days between checking for updates",
210 | default=7,
211 | min=0,
212 | max=31
213 | )
214 | updater_intrval_hours: IntProperty(
215 | name="Hours",
216 | description="Number of hours between checking for updates",
217 | default=0,
218 | min=0,
219 | max=23
220 | )
221 | updater_intrval_minutes: IntProperty(
222 | name="Minutes",
223 | description="Number of minutes between checking for updates",
224 | default=0,
225 | min=0,
226 | max=59
227 | )
228 |
229 | def draw(self, context: Context):
230 | layout: UILayout = self.layout
231 |
232 | # Layout Tabs to switch between Settings Tabs.
233 | row = layout.row(align=True)
234 | row.scale_y = 1.3
235 | row.prop(self, "layout_tab", expand=True)
236 |
237 | if self.layout_tab == "misc_settings":
238 | self.draw_misc_settings(context, layout)
239 |
240 | if self.layout_tab == "folder_structure_sets":
241 | self.draw_folder_structure_sets(context, layout)
242 |
243 | if self.layout_tab == "updater":
244 | # updater draw function
245 | # could also pass in col as third arg
246 | addon_updater_ops.update_settings_ui(self, context)
247 |
248 | # Display warning, if a backup of a corrupted file is in the Addons Data directory
249 | addons_data_dir = p.join(p.expanduser(
250 | "~"), "Blender Addons Data", "blender-project-starter")
251 | corrupted_files = [file for file in os.listdir(
252 | addons_data_dir) if re.match("BPS\.\d\d\d\d-\d\d-\d\d\.json", file)]
253 | if len(corrupted_files) > 0:
254 | layout.separator()
255 |
256 | box = layout.box()
257 | box.label(
258 | text="Warning: Corrupted Addon Data files detected", icon="ERROR")
259 | box.label(
260 | text="Click 'Open directory' below to view the corrupted files or click 'Support' for help on Discord.")
261 | box.label(text="Corrupted files:")
262 | for f in corrupted_files:
263 | row = box.row()
264 | row.scale_y = 0.4
265 | row.label(text=f)
266 |
267 | box.operator(
268 | "wm.path_open", text="Open directory").filepath = addons_data_dir
269 |
270 | # Support URL
271 | layout.separator()
272 | col = layout.column()
273 | op = col.operator("wm.url_open", text="Support", icon="URL")
274 | op.url = "https://bd-links.netlify.app/discord-spm"
275 |
276 | def draw_misc_settings(self, context: Context, layout: UILayout):
277 | layout.label(text="Default Project Location")
278 | layout.prop(self, "default_project_location", text="")
279 | layout.separator(factor=0.4)
280 |
281 | layout.prop(self, "prefix_with_project_name",
282 | text="Add the Project Name as Folder Prefix")
283 | layout.separator(factor=0.4)
284 |
285 | layout.prop(self, "auto_set_render_outputpath",
286 | text="Automatically set the render output path")
287 | layout.separator(factor=0.4)
288 |
289 | layout.prop(self, "enable_additional_rearrange_tools")
290 |
291 | def draw_folder_structure_sets(self, context: Context, layout: UILayout):
292 | layout.label(text="Folder Structure Set")
293 |
294 | row = layout.row(align=True)
295 | row.prop(self, "folder_structure_sets", text="")
296 | row.operator("super_project_manager.add_structure_set",
297 | text="", icon="ADD")
298 | op = row.operator(
299 | "super_project_manager.remove_structure_set", text="", icon="REMOVE")
300 | op.structure_set = self.previous_set
301 |
302 | # Layout the Box containing the folder structure properties.
303 | box = layout.box()
304 | box.separator(factor=FOLDER_BOX_PADDING_Y)
305 |
306 | compiled_preview_string = ""
307 | for index, folder in enumerate(self.automatic_folders):
308 | self.draw_folder_props(index, folder, box)
309 | compiled_preview_string += "((" + folder.folder_name + "))++"
310 |
311 | # Add folder button
312 | box.separator(factor=ADD_FOLDER_BUTTON_MARGIN_TOP)
313 |
314 | row = box.row()
315 | row.split(factor=FOLDER_BOX_PADDING_X) # Padding left
316 |
317 | op = row.operator("super_project_manager.add_folder",
318 | icon="PLUS")
319 | op.coming_from = "prefs"
320 | row.split(factor=FOLDER_BOX_PADDING_X) # Padding right
321 |
322 | box.separator(factor=FOLDER_BOX_PADDING_Y) # Padding bottom
323 |
324 | # Expand/Collapse Preview of compiled subfolders
325 | row = box.row()
326 | row.alignment = "LEFT"
327 | icon = "TRIA_DOWN" if self.preview_subfolders else "TRIA_RIGHT"
328 | row.prop(self, "preview_subfolders",
329 | emboss=False, icon=icon, text="Preview")
330 |
331 | # Preview complete folder structure
332 | if self.preview_subfolders:
333 |
334 | prefix = ""
335 | if self.prefix_with_project_name:
336 | prefix = "Project_Name_"
337 |
338 | for line in str(Subfolders(compiled_preview_string, prefix)).split("\n"):
339 | row = box.row()
340 | row.split(factor=FOLDER_BOX_PADDING_X)
341 | row.scale_y = 0.3
342 | row.label(text=line)
343 |
344 | def draw_folder_props(self, index: int, folder: 'project_folder_props', layout: UILayout):
345 | render_outpath_active = True in [
346 | e.render_outputpath for e in self.automatic_folders]
347 |
348 | row = layout.row()
349 | row.split(factor=FOLDER_BOX_PADDING_X) # Padding left
350 |
351 | # # split.label(text="Folder {}".format(index + 1))
352 |
353 | # Folder Name/Path Property
354 | row.prop(folder, "folder_name", text="")
355 |
356 | # Render Output Path
357 | if self.auto_set_render_outputpath:
358 | col = row.column()
359 | col.enabled = folder.render_outputpath or not render_outpath_active
360 | col.prop(folder, "render_outputpath",
361 | text="", icon="OUTPUT", emboss=folder.render_outputpath)
362 |
363 | # Remove Icon
364 | op = row.operator("super_project_manager.remove_folder",
365 | text="",
366 | emboss=False,
367 | icon="PANEL_CLOSE")
368 | op.index = index
369 | op.coming_from = "prefs"
370 |
371 | row.split(factor=FOLDER_BOX_PADDING_X) # Padding right
372 |
373 | for warning in Subfolders(folder.folder_name).warnings:
374 | row = layout.row()
375 | row.split(factor=FOLDER_BOX_PADDING_X) # Padding left
376 |
377 | row.label(text=warning, icon="ERROR")
378 | layout.separator(factor=WARNING_MARGIN_BOTTOM)
379 |
380 | row.split(factor=FOLDER_BOX_PADDING_X) # Padding right
381 |
382 |
383 | classes = (
384 | project_folder_props,
385 | FilebrowserEntry,
386 | SUPER_PROJECT_MANAGER_APT_Preferences
387 | )
388 |
389 |
390 | def register():
391 | for cls in classes:
392 | bpy.utils.register_class(cls)
393 |
394 |
395 | def legacy_register(bl_info):
396 | # addon updater code and configurations
397 | # in case of broken version, try to register the updater first
398 | # so that users can revert back to a working version
399 | addon_updater_ops.register(bl_info)
400 |
401 | # register the example panel, to show updater buttons
402 | for cls in classes:
403 | addon_updater_ops.make_annotations(cls) # avoid blender 2.8 warnings
404 | bpy.utils.register_class(cls)
405 |
406 |
407 | def unregister():
408 | # addon updater unregister
409 | addon_updater_ops.unregister()
410 |
411 | # register the example panel, to show updater buttons
412 | for cls in reversed(classes):
413 | bpy.utils.unregister_class(cls)
414 |
--------------------------------------------------------------------------------
/tests/test_path_generator.py:
--------------------------------------------------------------------------------
1 | # ##### BEGIN GPL LICENSE BLOCK #####
2 | #
3 | #
4 | # Copyright (C) <2023>
5 | #
6 | # This program is free software; you can redistribute it and/or
7 | # modify it under the terms of the GNU General Public License
8 | # as published by the Free Software Foundation; either version 3
9 | # of the License, or (at your option) any later version.
10 | #
11 | # This program is distributed in the hope that it will be useful,
12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 | # GNU General Public License for more details.
15 | #
16 | # You should have received a copy of the GNU General Public License
17 | # along with this program; if not, write to the Free Software Foundation,
18 | # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19 | #
20 | # ##### END GPL LICENSE BLOCK #####
21 |
22 | import unittest
23 |
24 | from os import path as p
25 |
26 | import sys
27 | sys.path.append(p.dirname(p.dirname(__file__)))
28 |
29 | if True:
30 | from objects.path_generator import Subfolders, Token
31 |
32 |
33 | class TestSubfolders(unittest.TestCase):
34 | def test_compile_paths(self):
35 | # Test a simple folder path with subfolders. Shouldn't result in a warning.
36 | test_case = Subfolders("Folder>>Subfolder>>Subsubfolder")
37 | self.assertEqual(test_case.compile_paths(), [
38 | "Folder",
39 | p.join("Folder", "Subfolder"),
40 | p.join("Folder", "Subfolder", "Subsubfolder")
41 | ])
42 | self.assertEqual(test_case.warnings, [])
43 |
44 | # Test a simple folder path with a prefix. Shouldn't result in a warning.
45 | test_case = Subfolders(
46 | "Folder>>Subfolder>>Subsubfolder", "Test_Prefix_")
47 | self.assertEqual(test_case.compile_paths(), [
48 | "Test_Prefix_Folder",
49 | p.join("Test_Prefix_Folder", "Test_Prefix_Subfolder"),
50 | p.join("Test_Prefix_Folder", "Test_Prefix_Subfolder",
51 | "Test_Prefix_Subsubfolder")
52 | ])
53 | self.assertEqual(test_case.warnings, [])
54 |
55 | # Test a folder path with multiple subfolders in a Folder.
56 | # Shouldn't result in a warning.
57 | test_case = Subfolders(
58 | "Folder>>Subfolder1++Subfolder2>>Subsubfolder")
59 | self.assertEqual(test_case.compile_paths(), [
60 | "Folder",
61 | p.join("Folder", "Subfolder1"),
62 | p.join("Folder", "Subfolder2"),
63 | p.join("Folder", "Subfolder2", "Subsubfolder")
64 | ])
65 | self.assertEqual(test_case.warnings, [])
66 |
67 | # Test a simple folder path with subfolders and '++' following '>>'.
68 | # Shouldn't result in a warning, because '>>' is preferred over '++'.
69 | test_case = Subfolders("Folder>>Subfolder1>>++Subfolder2")
70 | self.assertEqual(test_case.compile_paths(), [
71 | "Folder",
72 | p.join("Folder", "Subfolder1"),
73 | p.join("Folder", "Subfolder1", "Subfolder2")
74 | ])
75 | self.assertEqual(test_case.warnings, [])
76 |
77 | # Test a simple folder path with subfolders and '>>' following '++'.
78 | # Shouldn't result in a warning, because '>>' is preferred over '++'.
79 | test_case = Subfolders("Folder++Folder2++>>Test>>Amazing")
80 | self.assertEqual(test_case.compile_paths(), [
81 | "Folder",
82 | "Folder2",
83 | p.join("Folder2", "Test"),
84 | p.join("Folder2", "Test", "Amazing")
85 | ])
86 | self.assertEqual(test_case.warnings, [])
87 |
88 | # Test a simple folder path that ends with '>>'. Should result in a warning.
89 | test_case = Subfolders(
90 | "Folder>>Subfolder1++Subfolder2>>")
91 | self.assertEqual(test_case.compile_paths(), [
92 | "Folder",
93 | p.join("Folder", "Subfolder1"),
94 | p.join("Folder", "Subfolder2")
95 | ])
96 | self.assertEqual(test_case.warnings, [
97 | "A folder path should not end with '>>'!"
98 | ])
99 |
100 | # Test a folder path with brackets. Shouldn't result in a warning.
101 | test_case = Subfolders(
102 | "Folder>>Subfolder++((Subfolder2>>Subsubfolder))++Subfolder3")
103 | self.assertEqual(test_case.compile_paths(), [
104 | "Folder",
105 | p.join("Folder", "Subfolder"),
106 | p.join("Folder", "Subfolder2"),
107 | p.join("Folder", "Subfolder2", "Subsubfolder"),
108 | p.join("Folder", "Subfolder3")
109 | ])
110 | self.assertEqual(test_case.warnings, [])
111 |
112 | # Test a folder path with unmatched brackets and a misplaced '>>'.
113 | # Should result in two warnings.
114 | test_case = Subfolders(
115 | "Folder((>>Subfolder>>Subsubfolder1++Subfolder2")
116 | self.assertEqual(test_case.compile_paths(), [
117 | "Folder",
118 | "Subfolder",
119 | p.join("Subfolder", "Subsubfolder1"),
120 | p.join("Subfolder", "Subfolder2")
121 | ])
122 | self.assertEqual(test_case.warnings, [
123 | "Unmatched Brackets detected! This might lead to unexpected behaviour when compiling paths!",
124 | "A '>>' can't be used until at least one Folder name is specified! This rule also applies for subfolders.",
125 | ])
126 |
127 | # Test a folder path with unmatched opening brackets.
128 | # Should result in a warning.
129 | test_case = Subfolders(
130 | "Folder((++Subfolder>>Subsubfolder1++Subfolder2")
131 | self.assertEqual(test_case.compile_paths(), [
132 | "Folder",
133 | "Subfolder",
134 | p.join("Subfolder", "Subsubfolder1"),
135 | p.join("Subfolder", "Subfolder2")
136 | ])
137 | self.assertEqual(test_case.warnings, [
138 | "Unmatched Brackets detected! This might lead to unexpected behaviour when compiling paths!"
139 | ])
140 |
141 | # Test a folder path with unmatched closing brackets.
142 | # Should result in a warning.
143 | test_case = Subfolders(
144 | "Folder>>Subfolder>>Subsubfolder1))++Subfolder2")
145 | self.assertEqual(test_case.compile_paths(), [
146 | "Folder",
147 | p.join("Folder", "Subfolder"),
148 | p.join("Folder", "Subfolder", "Subsubfolder1")
149 | ])
150 | self.assertEqual(test_case.warnings, [
151 | "Unmatched Brackets detected! This might lead to unexpected behaviour when compiling paths!"
152 | ])
153 |
154 | # Test a folder path with brackets and a misplaced '>>'.
155 | # Should result in a warning.
156 | test_case = Subfolders(
157 | "((Folder>>Subfolder++Subfolder2))>>Subsubfolder1++Subsubfolder2")
158 | self.assertEqual(test_case.compile_paths(), [
159 | "Folder",
160 | p.join("Folder", "Subfolder"),
161 | p.join("Folder", "Subfolder2"),
162 | "Subsubfolder1",
163 | "Subsubfolder2"
164 | ])
165 | self.assertEqual(test_case.warnings, [
166 | "A '>>' can't be used until at least one Folder name is specified! This rule also applies for subfolders."])
167 |
168 | def test_tokenize(self):
169 | test_obj = Subfolders("Placeholder")
170 | self.assertEqual(test_obj.tokenize("Test"), [Token("Test")])
171 | self.assertEqual(test_obj.tokenize("Test>>++((Test)))"), [Token("Test"), Token(
172 | ">"), Token("+"), Token("("), Token("Test"), Token(")"), Token(")")])
173 |
174 |
175 | def main():
176 | unittest.main()
177 |
178 |
179 | if __name__ == "__main__":
180 | main()
181 |
--------------------------------------------------------------------------------
/tests/test_token.py:
--------------------------------------------------------------------------------
1 | # ##### BEGIN GPL LICENSE BLOCK #####
2 | #
3 | #
4 | # Copyright (C) <2023>
5 | #
6 | # This program is free software; you can redistribute it and/or
7 | # modify it under the terms of the GNU General Public License
8 | # as published by the Free Software Foundation; either version 3
9 | # of the License, or (at your option) any later version.
10 | #
11 | # This program is distributed in the hope that it will be useful,
12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 | # GNU General Public License for more details.
15 | #
16 | # You should have received a copy of the GNU General Public License
17 | # along with this program; if not, write to the Free Software Foundation,
18 | # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19 | #
20 | # ##### END GPL LICENSE BLOCK #####
21 |
22 |
23 | from os import path as p
24 |
25 | import sys
26 | sys.path.append(p.dirname(p.dirname(__file__)))
27 |
28 | if True:
29 | from objects.token import Token
30 |
31 |
32 | def test_token_type():
33 | assert Token("+").is_add()
34 | assert Token(">").is_branch_down()
35 | assert Token("(").is_bracket_open()
36 | assert Token(")").is_bracket_close()
37 | assert Token("Test String").is_string()
38 |
39 | assert not Token("String").is_add()
40 | assert not Token("String").is_branch_down()
41 | assert not Token("String").is_bracket_open()
42 | assert not Token("String").is_bracket_close()
43 | assert not Token(">").is_string()
44 |
45 |
46 | def test_is_valid_closing_token():
47 | assert not Token("+").is_valid_closing_token()
48 | assert not Token(">").is_valid_closing_token()
49 | assert not Token("(").is_valid_closing_token()
50 |
51 | assert Token("Test").is_valid_closing_token()
52 | assert Token(")").is_valid_closing_token()
53 |
54 |
55 | def test_to_str():
56 | assert str(Token("Test")) == "Test"
57 | assert str(Token("+")) == "+"
58 |
59 |
60 | def test_equal_comparison():
61 | assert Token("+") == Token("+")
62 | assert not Token("+") == Token(">")
63 | assert not Token("+") == Token("++")
64 |
65 | assert Token("Test") == Token("Test")
66 | assert not Token("Test") == Token("NoTest")
67 |
--------------------------------------------------------------------------------