Auxiliary tools for users of pixiv_down
Revision | 019d96c167ce1ca2e26231d1818442eb269ae86e (tree) |
---|---|
Time | 2023-01-06 19:48:18 |
Author | nemophila <stigma@disr...> |
Commiter | nemophila |
initial commit
@@ -0,0 +1,11 @@ | ||
1 | +# dub | |
2 | +.dub/ | |
3 | + | |
4 | +# mlib | |
5 | +.a | |
6 | + | |
7 | +# Executables | |
8 | +pixiv_remove_unfollowed | |
9 | + | |
10 | +# Logs | |
11 | +.log |
@@ -0,0 +1,26 @@ | ||
1 | +Copyright 2023 mio <stigma@disroot.org> | |
2 | + | |
3 | +Redistribution and use in source and binary forms, with or without | |
4 | +modification, are permitted provided that the following conditions | |
5 | +are met: | |
6 | + | |
7 | + 1. Redistributions of source code must retain the above copyright | |
8 | + notice, this list of conditions and the following disclaimer. | |
9 | + | |
10 | + 2. Redistributions in binary form must reproduce the above | |
11 | + copyright notice, this list of conditions and the following | |
12 | + disclaimer in the documentation and/or other materials | |
13 | + provided with the distribution. | |
14 | + | |
15 | +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS | |
16 | +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT | |
17 | +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS | |
18 | +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE | |
19 | +COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, | |
20 | +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, | |
21 | +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; | |
22 | +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER | |
23 | +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT | |
24 | +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN | |
25 | +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE | |
26 | +POSSIBILITY OF SUCH DAMAGE. |
@@ -0,0 +1,24 @@ | ||
1 | +- pixivd_tools - | |
2 | + | |
3 | +Auxiliary tools for [pixiv_down]. | |
4 | + | |
5 | +[pixiv_down]: https://yume-neru.neocities.org/p/pixiv_down.html | |
6 | + | |
7 | + | |
8 | += Requirements = | |
9 | + | |
10 | +* A D compiler | |
11 | +* dub | |
12 | +* [pixivd] | |
13 | + | |
14 | +You'll need pixivd downloaded. By defaut these tools will look for the | |
15 | +directory at "../pixivd" (i.e. they'll look for "pixivd" in the parent | |
16 | +directory). You can change this in the "dub.sdl" file. | |
17 | + | |
18 | +[pixivd]: https://yume-neru.neocities.org/p/pixivd.html | |
19 | + | |
20 | + | |
21 | += License = | |
22 | + | |
23 | +Each tool is licensed under the BSD 2-Clause License, which | |
24 | +you can read in the LICENSE file. |
@@ -0,0 +1,14 @@ | ||
1 | +name "pixivd-tools" | |
2 | +description "Collection of extra utility programs utilising pixivd" | |
3 | +license "BSD 2-clause" | |
4 | + | |
5 | +# Change the path here for pixivd if required. | |
6 | +dependency "pixivd" path="../pixivd" | |
7 | + | |
8 | +dependency "mlib" path="mlib" | |
9 | + | |
10 | +configuration "remove_unfollowed" { | |
11 | + sourceFiles "pixiv_remove_unfollowed.d" | |
12 | + targetType "executable" | |
13 | + targetName "pixiv_remove_unfollowed" | |
14 | +} |
@@ -0,0 +1,6 @@ | ||
1 | +{ | |
2 | + "fileVersion": 1, | |
3 | + "versions": { | |
4 | + "mlib": {"path":"mlib"} | |
5 | + } | |
6 | +} |
@@ -0,0 +1,644 @@ | ||
1 | +/* | |
2 | + * Copyright (C) 2021, 2022 mio <stigma@disroot.org> | |
3 | + * | |
4 | + * Permission to use, copy, modify, and/or distribute this software for any | |
5 | + * purpose with or without fee is herby granted. | |
6 | + * | |
7 | + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES | |
8 | + * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF | |
9 | + * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY | |
10 | + * SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES | |
11 | + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION | |
12 | + * OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN | |
13 | + * CONNECTION WITH THE USE OR PEFORMANCE OF THIS SOFTWARE. | |
14 | + */ | |
15 | + | |
16 | + | |
17 | +/** | |
18 | + * An incomplete single-file INI parser for D. | |
19 | + * | |
20 | + * The API should be similar to python's configparse module. Internally it | |
21 | + * uses the standard D associative array. | |
22 | + * | |
23 | + * Example: | |
24 | + * --- | |
25 | + * import configparser; | |
26 | + * | |
27 | + * auto config = new ConfigParser(); | |
28 | + * // no sections initially | |
29 | + * assert(config.sections.length == 0); | |
30 | + * // Section names are case-sensitive | |
31 | + * conf.addSection("Default"); | |
32 | + * // option names (pythonpath) are case-insensitive (converted to lowercase) | |
33 | + * conf.set("Default", "pythonpath", "/usr/bin/python3"); | |
34 | + * --- | |
35 | + * | |
36 | + * License: 0BSD | |
37 | + * Version: 0.2 | |
38 | + * History: | |
39 | + * 0.2 Add .getBool() | |
40 | + * 0.1 Initial release | |
41 | + */ | |
42 | +module mlib.configparser; | |
43 | + | |
44 | +private | |
45 | +{ | |
46 | + import std.conv : ConvException; | |
47 | + import std.stdio : File; | |
48 | +} | |
49 | + | |
50 | +public class DuplicateSectionException : Exception | |
51 | +{ | |
52 | + private string m_section; | |
53 | + | |
54 | + this(string section) | |
55 | + { | |
56 | + string msg = "Section " ~ section ~ " already exists."; | |
57 | + m_section = section; | |
58 | + super(msg); | |
59 | + } | |
60 | + | |
61 | + string section() | |
62 | + { | |
63 | + return m_section; | |
64 | + } | |
65 | +} | |
66 | + | |
67 | +public class NoSectionException : Exception | |
68 | +{ | |
69 | + private string m_section; | |
70 | + | |
71 | + this(string section) | |
72 | + { | |
73 | + string msg = "Section '" ~ section ~ "' does not exist."; | |
74 | + m_section = section; | |
75 | + super(msg); | |
76 | + } | |
77 | + | |
78 | + string section() | |
79 | + { | |
80 | + return m_section; | |
81 | + } | |
82 | +} | |
83 | + | |
84 | +public class NoOptionException : Exception | |
85 | +{ | |
86 | + private string m_section; | |
87 | + private string m_option; | |
88 | + | |
89 | + this(string section, string option) | |
90 | + { | |
91 | + string msg = "Section '" ~ section ~ "' does not have option '" ~ | |
92 | + option ~ "'."; | |
93 | + m_section = section; | |
94 | + m_option = option; | |
95 | + super(msg); | |
96 | + } | |
97 | + | |
98 | + string section() { return m_section; } | |
99 | + string option() { return m_option; } | |
100 | +} | |
101 | + | |
102 | +/** | |
103 | + * The main configuration parser. | |
104 | + */ | |
105 | +public class ConfigParser | |
106 | +{ | |
107 | + private char[] m_delimiters; | |
108 | + private char[] m_commentPrefixes; | |
109 | + | |
110 | + /** current section for parsing */ | |
111 | + private string m_currentSection; | |
112 | + private string[string][string] m_sections; | |
113 | + | |
114 | + /** | |
115 | + * Creates a new instance of ConfigParser. | |
116 | + */ | |
117 | + this(char[] delimiters = ['=', ':'], char[] commentPrefixes = ['#', ';']) | |
118 | + { | |
119 | + m_delimiters = delimiters; | |
120 | + m_commentPrefixes = commentPrefixes; | |
121 | + } | |
122 | + | |
123 | + /** | |
124 | + * Return an array containing the available sections. | |
125 | + */ | |
126 | + string[] sections() | |
127 | + { | |
128 | + return m_sections.keys(); | |
129 | + } | |
130 | + | |
131 | + /// | |
132 | + unittest | |
133 | + { | |
134 | + auto conf = new ConfigParser(); | |
135 | + | |
136 | + assert(0 == conf.sections().length); | |
137 | + | |
138 | + conf.addSection("Section"); | |
139 | + | |
140 | + assert(1 == conf.sections().length); | |
141 | + } | |
142 | + | |
143 | + /** | |
144 | + * Add a section named `section` to the instance. | |
145 | + * | |
146 | + * Throws: | |
147 | + * - DuplicateSectionError if a section by the given name already | |
148 | + * exists. | |
149 | + */ | |
150 | + void addSection(string section) | |
151 | + { | |
152 | + if (section in m_sections) | |
153 | + throw new DuplicateSectionException(section); | |
154 | + m_sections[section] = null; | |
155 | + } | |
156 | + | |
157 | + /// | |
158 | + unittest | |
159 | + { | |
160 | + import std.exception : assertNotThrown, assertThrown; | |
161 | + | |
162 | + auto conf = new ConfigParser(); | |
163 | + | |
164 | + /* doesn't yet exist */ | |
165 | + assertNotThrown!DuplicateSectionException(conf.addSection("sample")); | |
166 | + /* already exists */ | |
167 | + assertThrown!DuplicateSectionException(conf.addSection("sample")); | |
168 | + } | |
169 | + | |
170 | + /** | |
171 | + * Indicates whether the named `section` is present in the configuration. | |
172 | + * | |
173 | + * Params: | |
174 | + * section = The section to check for in the configuration. | |
175 | + * | |
176 | + * Returns: `true` if the section exists, `false` otherwise. | |
177 | + */ | |
178 | + bool hasSection(string section) | |
179 | + { | |
180 | + auto exists = (section in m_sections); | |
181 | + return (exists !is null); | |
182 | + } | |
183 | + | |
184 | + /// | |
185 | + unittest | |
186 | + { | |
187 | + auto conf = new ConfigParser(); | |
188 | + conf.addSection("nExt"); | |
189 | + assert(true == conf.hasSection("nExt"), "Close the world."); | |
190 | + assert(false == conf.hasSection("world"), "Open the nExt."); | |
191 | + } | |
192 | + | |
193 | + string[] options(string section) | |
194 | + { | |
195 | + if (false == this.hasSection(section)) | |
196 | + throw new NoSectionException(section); | |
197 | + return m_sections[section].keys(); | |
198 | + } | |
199 | + | |
200 | + /// | |
201 | + unittest | |
202 | + { | |
203 | + import std.exception : assertNotThrown, assertThrown; | |
204 | + | |
205 | + auto conf = new ConfigParser(); | |
206 | + | |
207 | + conf.addSection("Settings"); | |
208 | + | |
209 | + assertNotThrown!NoSectionException(conf.options("Settings")); | |
210 | + assertThrown!NoSectionException(conf.options("void")); | |
211 | + | |
212 | + string[] options = conf.options("Settings"); | |
213 | + assert(0 == options.length, "More keys than we need"); | |
214 | + } | |
215 | + | |
216 | + bool hasOption(string section, string option) | |
217 | + { | |
218 | + if (false == this.hasSection(section)) | |
219 | + return false; | |
220 | + | |
221 | + auto exists = (option in m_sections[section]); | |
222 | + return (exists !is null); | |
223 | + } | |
224 | + /* | |
225 | + string[] read(string[] filenames) | |
226 | + { | |
227 | + return null; | |
228 | + }*/ | |
229 | + | |
230 | + void read(string filename) | |
231 | + { | |
232 | + import std.file : FileException, exists, isFile; | |
233 | + | |
234 | + if (false == exists(filename)) { | |
235 | + throw new FileException(filename); | |
236 | + } | |
237 | + | |
238 | + if (false == isFile(filename)) { | |
239 | + throw new FileException(filename); | |
240 | + } | |
241 | + | |
242 | + File file = File(filename, "r"); | |
243 | + scope(exit) { file.close(); } | |
244 | + read(file, false); | |
245 | + } | |
246 | + | |
247 | + /// | |
248 | + unittest | |
249 | + { | |
250 | + import std.file : remove; | |
251 | + import std.stdio : File; | |
252 | + | |
253 | + auto configFile = File("test.conf", "w+"); | |
254 | + configFile.writeln("[Section 1]"); | |
255 | + configFile.writeln("key=value"); | |
256 | + configFile.writeln("\n[Section 2]"); | |
257 | + configFile.writeln("key2 = value"); | |
258 | + configFile.close(); | |
259 | + | |
260 | + auto conf = new ConfigParser(); | |
261 | + conf.read("test.conf"); | |
262 | + | |
263 | + assert(2 == conf.sections.length, "Incorrect Sections length"); | |
264 | + assert(true == conf.hasSection("Section 1"), | |
265 | + "Config file doesn't have Section 1"); | |
266 | + assert(true == conf.hasOption("Section 1", "key"), | |
267 | + "Config file doesn't have 'key' in 'Section 1'"); | |
268 | + | |
269 | + remove("test.conf"); | |
270 | + } | |
271 | + | |
272 | + /** | |
273 | + * Parse a config file. | |
274 | + * | |
275 | + * Params: | |
276 | + * file = Reference to the file from which to read. | |
277 | + * close = Close the file when finished parsing. | |
278 | + */ | |
279 | + void read(ref File file, bool close = true) | |
280 | + { | |
281 | + import std.array : array; | |
282 | + import std.algorithm.searching : canFind; | |
283 | + import std.string : strip; | |
284 | + | |
285 | + scope(exit) { if (close) file.close(); } | |
286 | + | |
287 | + string[] lines = file.byLineCopy.array; | |
288 | + | |
289 | + for (auto i = 0; i < lines.length; i++) { | |
290 | + string line = lines[i].strip(); | |
291 | + | |
292 | + if (line == "") | |
293 | + continue; | |
294 | + | |
295 | + if ('[' == lines[i][0]) { | |
296 | + parseSectionHeader(lines[i]); | |
297 | + } else if (false == canFind(m_commentPrefixes, lines[i][0])) { | |
298 | + parseLine(lines[i]); | |
299 | + } | |
300 | + /* ignore comments */ | |
301 | + } | |
302 | + } | |
303 | + | |
304 | + // void readString(string str) | |
305 | + // { | |
306 | + // } | |
307 | + | |
308 | + /** | |
309 | + * Get an `option` value for the named `section`. | |
310 | + * | |
311 | + * Params: | |
312 | + * section = The section to look for the given `option`. | |
313 | + * option = The option to return the value of | |
314 | + * fallback = Fallback value if the `option` is not found. Can be null. | |
315 | + * | |
316 | + * Returns: | |
317 | + * - The value for `option` if it is found. | |
318 | + * - `null` if the `option` is not found and `fallback` is not provided. | |
319 | + * - `fallback` if the `option` is not found and `fallback` is provided. | |
320 | + * | |
321 | + * Throws: | |
322 | + * - NoSectionException if the `section` does not exist and no fallback is provided. | |
323 | + * - NoOptionException if the `option` does not exist and no fallback is provided. | |
324 | + */ | |
325 | + string get(string section, string option) | |
326 | + { | |
327 | + if (false == this.hasSection(section)) | |
328 | + throw new NoSectionException(section); | |
329 | + | |
330 | + if (false == this.hasOption(section, option)) | |
331 | + throw new NoOptionException(section, option); | |
332 | + | |
333 | + return m_sections[section][option]; | |
334 | + } | |
335 | + | |
336 | + /// | |
337 | + unittest | |
338 | + { | |
339 | + import std.exception : assertThrown; | |
340 | + | |
341 | + auto conf = new ConfigParser(); | |
342 | + conf.addSection("Section"); | |
343 | + conf.set("Section", "option", "value"); | |
344 | + | |
345 | + assert(conf.get("Section", "option") == "value"); | |
346 | + assertThrown!NoSectionException(conf.get("section", "option")); | |
347 | + assertThrown!NoOptionException(conf.get("Section", "void")); | |
348 | + } | |
349 | + | |
350 | + /// Ditto | |
351 | + string get(string section, string option, string fallback) | |
352 | + { | |
353 | + string res = fallback; | |
354 | + | |
355 | + try { | |
356 | + res = get(section, option); | |
357 | + } catch (NoSectionException e) { | |
358 | + return res; | |
359 | + } catch (NoOptionException e) { | |
360 | + return res; | |
361 | + } | |
362 | + | |
363 | + return res; | |
364 | + } | |
365 | + | |
366 | + /// | |
367 | + unittest | |
368 | + { | |
369 | + import std.exception : assertThrown; | |
370 | + | |
371 | + auto conf = new ConfigParser(); | |
372 | + conf.addSection("Section"); | |
373 | + conf.set("Section", "option", "value"); | |
374 | + | |
375 | + assert("value" == conf.get("Section", "option")); | |
376 | + assert("fallback" == conf.get("section", "option", "fallback")); | |
377 | + assert("fallback" == conf.get("Section", "void", "fallback")); | |
378 | + | |
379 | + /* can use null for fallback */ | |
380 | + assert(null == conf.get("section", "option", null)); | |
381 | + assert(null == conf.get("Section", "void", null)); | |
382 | + } | |
383 | + | |
384 | + /** | |
385 | + * A convenience method which casts the value of `option` in `section` | |
386 | + * to an integer. | |
387 | + * | |
388 | + * Params: | |
389 | + * section = The section to look for the given `option`. | |
390 | + * option = The option to return the value for. | |
391 | + * fallback = The fallback value to use if `option` isn't found. | |
392 | + * | |
393 | + * Returns: | |
394 | + * | |
395 | + * | |
396 | + * Throws: | |
397 | + * - NoSectionFoundException if `section` doesn't exist. | |
398 | + * - NoOptionFoundException if the `section` doesn't contain `option`. | |
399 | + * - ConvException if it failed to parse the value to an int. | |
400 | + * - ConvOverflowException if the value would overflow an int. | |
401 | + * | |
402 | + * See_Also: get() | |
403 | + */ | |
404 | + int getInt(string section, string option) | |
405 | + { | |
406 | + import std.conv : parse; | |
407 | + | |
408 | + string res; | |
409 | + | |
410 | + res = get(section, option); | |
411 | + | |
412 | + return parse!int(res); | |
413 | + } | |
414 | + | |
415 | + /// Ditto | |
416 | + int getInt(string section, string option, int fallback) | |
417 | + { | |
418 | + int res = fallback; | |
419 | + | |
420 | + try { | |
421 | + res = getInt(section, option); | |
422 | + } catch (Exception e) { | |
423 | + return res; | |
424 | + } | |
425 | + | |
426 | + return res; | |
427 | + } | |
428 | + | |
429 | + /* | |
430 | + double getDouble(string section, string option) | |
431 | + { | |
432 | + } | |
433 | + | |
434 | + double getDouble(string section, string option, double fallback) | |
435 | + { | |
436 | + } | |
437 | + | |
438 | + float getFloat(string section, string option) | |
439 | + { | |
440 | + } | |
441 | + | |
442 | + float getFloat(string section, string option, float fallback) | |
443 | + { | |
444 | + }*/ | |
445 | + | |
446 | + /** | |
447 | + * A convenience method which coerces the $(I option) in the | |
448 | + * specified $(I section) to a boolean value. | |
449 | + * | |
450 | + * Note that the accepted values for the option are "1", "yes", | |
451 | + * "true", and "on", which cause this method to return `true`, and | |
452 | + * "0", "no", "false", and "off", which cause it to return `false`. | |
453 | + * | |
454 | + * These string values are checked in a case-insensitive manner. | |
455 | + * | |
456 | + * Params: | |
457 | + * section = The section to look for the given option. | |
458 | + * option = The option to return the value for. | |
459 | + * fallback = The fallback value to use if the option was not found. | |
460 | + * | |
461 | + * Throws: | |
462 | + * - NoSectionFoundException if `section` doesn't exist. | |
463 | + * - NoOptionFoundException if the `section` doesn't contain `option`. | |
464 | + * - ConvException if any other value was found. | |
465 | + */ | |
466 | + bool getBool(string section, string option) | |
467 | + { | |
468 | + import std.string : toLower; | |
469 | + | |
470 | + string value = get(section, option); | |
471 | + | |
472 | + switch (value.toLower) | |
473 | + { | |
474 | + case "1": | |
475 | + case "yes": | |
476 | + case "true": | |
477 | + case "on": | |
478 | + return true; | |
479 | + case "0": | |
480 | + case "no": | |
481 | + case "false": | |
482 | + case "off": | |
483 | + return false; | |
484 | + default: | |
485 | + throw new ConvException("No valid boolean value found"); | |
486 | + } | |
487 | + } | |
488 | + | |
489 | + /// Ditto | |
490 | + bool getBool(string section, string option, bool fallback) | |
491 | + { | |
492 | + try { | |
493 | + return getBool(section, option); | |
494 | + } catch (Exception e) { | |
495 | + return fallback; | |
496 | + } | |
497 | + } | |
498 | + | |
499 | + /* | |
500 | + string[string] items(string section) | |
501 | + { | |
502 | + }*/ | |
503 | + | |
504 | + /** | |
505 | + * Remove the specified `option` from the specified `section`. | |
506 | + * | |
507 | + * Params: | |
508 | + * section = The section to remove from. | |
509 | + * option = The option to remove from section. | |
510 | + * | |
511 | + * Retruns: | |
512 | + * `true` if option existed, false otherwise. | |
513 | + * | |
514 | + * Throws: | |
515 | + * - NoSectionException if the specified section doesn't exist. | |
516 | + */ | |
517 | + bool removeOption(string section, string option) | |
518 | + { | |
519 | + if ((section in m_sections) is null) { | |
520 | + throw new NoSectionException(section); | |
521 | + } | |
522 | + | |
523 | + if (option in m_sections[section]) { | |
524 | + m_sections[section].remove(option); | |
525 | + return true; | |
526 | + } | |
527 | + | |
528 | + return false; | |
529 | + } | |
530 | + | |
531 | + /// | |
532 | + unittest | |
533 | + { | |
534 | + import std.exception : assertThrown; | |
535 | + | |
536 | + auto conf = new ConfigParser(); | |
537 | + conf.addSection("Default"); | |
538 | + conf.set("Default", "exists", "true"); | |
539 | + | |
540 | + assertThrown!NoSectionException(conf.removeOption("void", "false")); | |
541 | + assert(false == conf.removeOption("Default", "void")); | |
542 | + assert(true == conf.removeOption("Default", "exists")); | |
543 | + } | |
544 | + | |
545 | + /** | |
546 | + * Remove the specified `section` from the config. | |
547 | + * | |
548 | + * Params: | |
549 | + * section = The section to remove. | |
550 | + * | |
551 | + * Returns: | |
552 | + * `true` if the section existed, `false` otherwise. | |
553 | + */ | |
554 | + bool removeSection(string section) | |
555 | + { | |
556 | + if (section in m_sections) { | |
557 | + m_sections.remove(section); | |
558 | + return true; | |
559 | + } | |
560 | + return false; | |
561 | + } | |
562 | + | |
563 | + /// | |
564 | + unittest | |
565 | + { | |
566 | + auto conf = new ConfigParser(); | |
567 | + conf.addSection("Exists"); | |
568 | + assert(false == conf.removeSection("DoesNotExist")); | |
569 | + assert(true == conf.removeSection("Exists")); | |
570 | + } | |
571 | + | |
572 | + void set(string section, string option, string value) | |
573 | + { | |
574 | + if (false == this.hasSection(section)) | |
575 | + throw new NoSectionException(section); | |
576 | + | |
577 | + m_sections[section][option] = value; | |
578 | + } | |
579 | + | |
580 | + /// | |
581 | + unittest | |
582 | + { | |
583 | + import std.exception : assertThrown; | |
584 | + | |
585 | + auto conf = new ConfigParser(); | |
586 | + | |
587 | + assertThrown!NoSectionException(conf.set("Section", "option", | |
588 | + "value")); | |
589 | + | |
590 | + conf.addSection("Section"); | |
591 | + conf.set("Section", "option", "value"); | |
592 | + assert(conf.get("Section", "option") == "value"); | |
593 | + } | |
594 | + | |
595 | + // void write(ref File file) | |
596 | + // { | |
597 | + // } | |
598 | + | |
599 | + private: | |
600 | + | |
601 | + void parseSectionHeader(ref string line) | |
602 | + { | |
603 | + import std.array : appender, assocArray; | |
604 | + | |
605 | + auto sectionHeader = appender!string; | |
606 | + /* presume that the last character is ] */ | |
607 | + sectionHeader.reserve(line.length - 1); | |
608 | + string popped = line[1 .. $]; | |
609 | + | |
610 | + foreach(c; popped) { | |
611 | + if (c != ']') | |
612 | + sectionHeader.put(c); | |
613 | + else | |
614 | + break; | |
615 | + } | |
616 | + | |
617 | + version (DigitalMars) { | |
618 | + m_currentSection = sectionHeader[]; | |
619 | + } else { | |
620 | + /* LDC / GNU */ | |
621 | + m_currentSection = sectionHeader.data; | |
622 | + } | |
623 | + | |
624 | + try { | |
625 | + this.addSection(m_currentSection); | |
626 | + } catch (DuplicateSectionException e) { | |
627 | + /* haven't checked what python would do */ | |
628 | + /* perhaps we should just merge the settings? */ | |
629 | + return; | |
630 | + } | |
631 | + } | |
632 | + | |
633 | + void parseLine(ref string line) | |
634 | + { | |
635 | + import std.string : indexOfAny, strip; | |
636 | + | |
637 | + ptrdiff_t idx = line.indexOfAny(m_delimiters); | |
638 | + if (-1 == idx) return; | |
639 | + string option = line[0 .. idx].dup.strip; | |
640 | + string value = line[idx + 1 .. $].dup.strip; | |
641 | + | |
642 | + m_sections[m_currentSection][option] = value; | |
643 | + } | |
644 | +} |
@@ -0,0 +1,317 @@ | ||
1 | +/* | |
2 | + * This is free and unencumbered software released into the public domain. | |
3 | + * | |
4 | + * Anyone is free to copy, modify, publish, use, compile, sell, or distribute this | |
5 | + * software, either in source code form or as a compiled binary, for any purpose, | |
6 | + * commercial or non-commercial, and by any means. | |
7 | + * | |
8 | + * In jurisdictions that recognize copyright laws, the author or authors of this | |
9 | + * software dedicate any and all copyright interest in the software to the public | |
10 | + * domain. We make this dedication for the benefit of the public at large and to | |
11 | + * the detriment of our heirs and successors. We intend this dedication to be an | |
12 | + * overt act of relinquishment in perpetuity of all present and future rights to | |
13 | + * this software under copyright law. | |
14 | + * | |
15 | + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |
16 | + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |
17 | + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |
18 | + * AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN | |
19 | + * ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION | |
20 | + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. | |
21 | + */ | |
22 | + | |
23 | +/** | |
24 | + * This module provides quick & easy access to the XDG folder locations on GNU/Linux. Currently, | |
25 | + * only POSIX (XDG Base Directory Specification) is supported. OS X (Standard Directories) and | |
26 | + * Windows (Known Folder) will be supported at a later date. | |
27 | + * | |
28 | + * The main goal of this module is to provide a minimal and simple API. | |
29 | + * | |
30 | + * API | |
31 | + * --- | |
32 | + * enum Directory | |
33 | + * { | |
34 | + * home, | |
35 | + * config, | |
36 | + * data, | |
37 | + * cache, | |
38 | + * } | |
39 | + * | |
40 | + * DirEntry open(Directory); | |
41 | + * --- | |
42 | + * | |
43 | + * This module supports D version greater than or equal to 2.068.0. | |
44 | + * | |
45 | + * Authors: mio <stigma@disroot.org> | |
46 | + * Bugs: Doesn't check if the environment variable value is empty. | |
47 | + * Date: June 12, 2021 | |
48 | + * License: public domain | |
49 | + * Standards: XDG Base Directory Specification 0.8 | |
50 | + * Version: 0.X.X | |
51 | + */ | |
52 | +module mlib.directories; | |
53 | + | |
54 | +import std.file : DirEntry; | |
55 | + | |
56 | +public enum Directory | |
57 | + { | |
58 | + home, | |
59 | + data, | |
60 | + config, | |
61 | + state, | |
62 | + // dataDirs, /* XDG */ | |
63 | + // configDirs, /* XDG */ | |
64 | + cache, | |
65 | + runtime, | |
66 | + } | |
67 | + | |
68 | +/++ | |
69 | + Return a DirEntry pointing to `dt`. | |
70 | + | |
71 | + If the folder couldn't be found, an empty DirEntry is returned. | |
72 | + | |
73 | + Examples: | |
74 | + ---- | |
75 | + import directories; | |
76 | + import std.stdio; | |
77 | + | |
78 | + int main() | |
79 | + { | |
80 | + DirEntry userConfDir = open(Directory.config); | |
81 | + /* Handle error */ | |
82 | + if (0 == userConfDir.name.length) | |
83 | + { | |
84 | + stderr.writeln("Failed to find user config directory."); | |
85 | + return 1; | |
86 | + } | |
87 | + writefln("User config directory is %s", userConfDir.name); | |
88 | + return 0; | |
89 | + } | |
90 | + ---- | |
91 | ++/ | |
92 | +public DirEntry open(Directory dt) nothrow// @safe | |
93 | +{ | |
94 | + /* | |
95 | + * Posix covers a few operating systems, if yours has a alternate | |
96 | + * structure that is preferred over XDG, let me know. | |
97 | + */ | |
98 | + version (Posix) enum supported = true; | |
99 | + else enum supported = false; | |
100 | + /* More operating systems will be supported soon-ish. */ | |
101 | + | |
102 | + static if (false == supported) | |
103 | + { | |
104 | + import core.stdc.stdio : fprintf, stderr; | |
105 | + | |
106 | + fprintf(stderr, "*** error: The operating system you're running isn't supported. ***\n"); | |
107 | + // TODO: webpage for general contribution guide reporting. | |
108 | + assert(false, "Unsupported platform."); | |
109 | + } | |
110 | + | |
111 | + immutable string path = getPath(dt); | |
112 | + if (path is null) return DirEntry(); | |
113 | + try { | |
114 | + return DirEntry(path); | |
115 | + } catch (Exception e) { | |
116 | + return DirEntry(); | |
117 | + } | |
118 | +} | |
119 | + | |
120 | +/// | |
121 | +unittest | |
122 | +{ | |
123 | + import std.process : environment; | |
124 | + import std.path : buildPath; | |
125 | + | |
126 | + /* | |
127 | + * Environment Variables (for checking against) | |
128 | + * Currently only tests on Posix since that's the only supported | |
129 | + * platform. | |
130 | + */ | |
131 | + immutable string homeE = environment.get("HOME"); | |
132 | + immutable string configE = environment.get("XDG_CONFIG_HOME", | |
133 | + buildPath(homeE, ".config")); | |
134 | + immutable string dataE = environment.get("XDG_DATA_HOME", | |
135 | + buildPath(homeE, ".local", "share")); | |
136 | + immutable string cacheE = environment.get("XDG_CACHE_HOME", | |
137 | + buildPath(homeE, ".cache")); | |
138 | + | |
139 | + // Compare against folders.d (note that folders is cross-platform, so | |
140 | + // some errors may occur on non-POSIX) | |
141 | + assert(homeE == open(Directory.home)); | |
142 | + assert(configE == open(Directory.config)); | |
143 | + assert(dataE == open(Directory.data)); | |
144 | + assert(cacheE == open(Directory.cache)); | |
145 | + import std.stdio; | |
146 | + writeln(typeof(open(Directory.cache)).stringof); | |
147 | +} | |
148 | + | |
149 | +private: | |
150 | + | |
151 | +void errLog(string msg) nothrow @trusted | |
152 | +{ | |
153 | + import core.stdc.stdio : fprintf, stderr; | |
154 | + | |
155 | + // TODO: webpage for general issue reporting. | |
156 | + // fprintf(stderr, "** info: report bugs to https://yume-neru.neocities.org/bugs.html **\n"); | |
157 | + fprintf(stderr, "*** error: %s ***\n", msg.ptr); | |
158 | +} | |
159 | + | |
160 | +immutable(string) getPath(in Directory dt) nothrow @safe | |
161 | +{ | |
162 | + switch (dt) | |
163 | + { | |
164 | + case Directory.home: | |
165 | + return home(); | |
166 | + case Directory.data: | |
167 | + return data(); | |
168 | + case Directory.config: | |
169 | + return config(); | |
170 | + case Directory.cache: | |
171 | + return cache(); | |
172 | + default: | |
173 | + assert(false, "Unsupported Directory"); | |
174 | + } | |
175 | +} | |
176 | + | |
177 | +immutable(string) home() nothrow @trusted | |
178 | +{ | |
179 | + import std.path : isAbsolute; | |
180 | + import std.process : environment; | |
181 | + | |
182 | + string homeE; | |
183 | + | |
184 | + try { | |
185 | + homeE = environment.get("HOME"); | |
186 | + } catch (Exception e) { | |
187 | + homeE = null; | |
188 | + } | |
189 | + | |
190 | + if (homeE is null) { | |
191 | + import std.string : fromStringz; | |
192 | + const(char)* pwdHome = fallbackHome(); | |
193 | + if (pwdHome !is null) | |
194 | + homeE = cast(string)(pwdHome.fromStringz).dup; | |
195 | + if (false == homeE.isAbsolute) | |
196 | + homeE = null; | |
197 | + } | |
198 | + return homeE; | |
199 | +} | |
200 | + | |
201 | +immutable(string) data() nothrow @safe | |
202 | +{ | |
203 | + import std.path : buildPath, isAbsolute; | |
204 | + import std.process : environment; | |
205 | + | |
206 | + string dataE; | |
207 | + | |
208 | + try { | |
209 | + dataE = environment.get("XDG_DATA_HOME"); | |
210 | + } catch (Exception e) { | |
211 | + dataE = null; | |
212 | + } | |
213 | + | |
214 | + if (dataE is null || false == dataE.isAbsolute) | |
215 | + dataE = buildPath(home(), ".local", "share"); | |
216 | + | |
217 | + return dataE; | |
218 | +} | |
219 | + | |
220 | +immutable(string) config() nothrow @safe | |
221 | +{ | |
222 | + import std.path : buildPath, isAbsolute; | |
223 | + import std.process : environment; | |
224 | + | |
225 | + string configE; | |
226 | + | |
227 | + try { | |
228 | + configE = environment.get("XDG_CONFIG_HOME"); | |
229 | + } catch (Exception e) { | |
230 | + configE = null; | |
231 | + } | |
232 | + | |
233 | + if (configE is null || false == configE.isAbsolute) | |
234 | + configE = buildPath(home(), ".config"); | |
235 | + | |
236 | + return configE; | |
237 | +} | |
238 | + | |
239 | +/* TODO: xdgState() nothrow @safe */ | |
240 | + | |
241 | +immutable(string) cache() nothrow @safe | |
242 | +{ | |
243 | + import std.path : buildPath, isAbsolute; | |
244 | + import std.process : environment; | |
245 | + | |
246 | + string cacheE; | |
247 | + | |
248 | + try { | |
249 | + cacheE = environment.get("XDG_CACHE_HOME"); | |
250 | + } catch (Exception e) { | |
251 | + cacheE = null; | |
252 | + } | |
253 | + | |
254 | + if (cacheE is null || false == cacheE.isAbsolute) | |
255 | + cacheE = buildPath(home(), ".cache"); | |
256 | + | |
257 | + return cacheE; | |
258 | +} | |
259 | + | |
260 | +/* Helpers */ | |
261 | + | |
262 | +const(char)* fallbackHome() nothrow @trusted | |
263 | +{ | |
264 | + import core.stdc.string : strdup; | |
265 | + | |
266 | + passwd* pw; | |
267 | + char* home; | |
268 | + | |
269 | + setpwent(); | |
270 | + pw = getpwuid(getuid()); | |
271 | + endpwent(); | |
272 | + | |
273 | + if (pw is null || pw.pw_dir is null) | |
274 | + return null; | |
275 | + | |
276 | + home = strdup(pw.pw_dir); | |
277 | + return home; | |
278 | +} | |
279 | + | |
280 | +@system @nogc extern(C) nothrow | |
281 | +{ | |
282 | + /* <bits/types.h> */ | |
283 | + alias gid_t = uint; | |
284 | + alias uid_t = uint; | |
285 | + | |
286 | + /* <pwd.h> */ | |
287 | + struct passwd | |
288 | + { | |
289 | + /// Username | |
290 | + char* pw_name; | |
291 | + /// Hashed passphrase, if shadow database is not in use | |
292 | + char* pw_password; | |
293 | + /// User ID | |
294 | + uid_t pw_uid; | |
295 | + /// Group ID | |
296 | + gid_t pw_gid; | |
297 | + /// "Real" name | |
298 | + char* pw_gecos; | |
299 | + /// Home directory | |
300 | + char* pw_dir; | |
301 | + /// Shell program | |
302 | + char* pw_shell; | |
303 | + } | |
304 | + | |
305 | + /// Rewind the user database stream | |
306 | + extern void setpwent(); | |
307 | + | |
308 | + /// Close the user database stream | |
309 | + extern void endpwent(); | |
310 | + | |
311 | + /// Retrieve the user database entry for the given user ID | |
312 | + extern passwd* getpwuid(uid_t uid); | |
313 | + | |
314 | + /* <unistd.h> */ | |
315 | + /// Returns the real user ID of the calling process. | |
316 | + extern uid_t getuid(); | |
317 | +} |
@@ -0,0 +1,5 @@ | ||
1 | +name "mlib" | |
2 | +description "A collection of miscellaneous D files." | |
3 | +license "public domain" | |
4 | + | |
5 | +sourcePaths "." |
@@ -0,0 +1,202 @@ | ||
1 | +/** | |
2 | + * A simple tool to help identify which pixiv accounts you've | |
3 | + * downloaded media from, have since unfollowed, but haven't removed | |
4 | + * the directory. | |
5 | + * | |
6 | + * Bugs: | |
7 | + * If you encounter any errors, please let me know. | |
8 | + * I don't include any form of automated error reporting, | |
9 | + * so please consider attaching the "pixiv_remove_unfollowed.log" | |
10 | + * file. | |
11 | + */ | |
12 | +module app; | |
13 | + | |
14 | +import core.stdc.stdlib : exit; | |
15 | + | |
16 | +import std.experimental.logger : FileLogger; | |
17 | + | |
18 | +import std.container : DList; | |
19 | +import std.file; | |
20 | +import std.path; | |
21 | +import std.stdio; | |
22 | +import std.string; | |
23 | + | |
24 | +import mlib.configparser; | |
25 | +import mlib.directories; | |
26 | + | |
27 | +import pixivd; | |
28 | +import pixivd.types; | |
29 | + | |
30 | +struct Context | |
31 | +{ | |
32 | + string phpsessid; | |
33 | + string pixivBaseDir; | |
34 | +} | |
35 | + | |
36 | +private FileLogger logger; | |
37 | + | |
38 | +// Initialize a Context. | |
39 | +// Could exit the program if it failed to find the user configuration directory, | |
40 | +// or if the user doesn't enter a PHPSESSID value when prompted. | |
41 | +Context fetchContext() | |
42 | +{ | |
43 | + Context ctx; | |
44 | + | |
45 | + DirEntry configDir = open(Directory.config); | |
46 | + if (configDir.name.length == 0) { | |
47 | + logger.critical("directories.d open(Directory.config) failed to return a valid DirEntry."); | |
48 | + stderr.writeln("Failed to determine your configuration directory, please consider setting the" ~ | |
49 | + " XDG_CONFIG_HOME environment variable."); | |
50 | + exit(1); | |
51 | + } | |
52 | + | |
53 | + auto phpsessidPath = buildPath(configDir.name, "pixivd", "phpsessid.txt"); | |
54 | + string phpsessid; | |
55 | + | |
56 | + if (false == exists(phpsessidPath)) { | |
57 | + mkdirRecurse(buildPath(configDir.name, "pixivd")); | |
58 | + writeln("PHPSESSID not found, please enter:"); | |
59 | + phpsessid = readln().strip(); | |
60 | + if (phpsessid.empty) { | |
61 | + stderr.writeln("No PHPSESSID entered, exiting."); | |
62 | + exit(1); | |
63 | + } | |
64 | + auto f = File(phpsessidPath, "w"); | |
65 | + f.writeln(phpsessid); | |
66 | + f.close(); | |
67 | + | |
68 | + ctx.phpsessid = phpsessid; | |
69 | + return ctx; | |
70 | + } else { | |
71 | + auto phpsessidFile = File(phpsessidPath, "r"); | |
72 | + phpsessid = phpsessidFile.readln().strip(); | |
73 | + | |
74 | + if (phpsessid.empty) { | |
75 | + writeln("PHPSESSID not found, please enter:"); | |
76 | + phpsessid = readln().strip(); | |
77 | + if (phpsessid.empty) { | |
78 | + stderr.writeln("No PHPSESSID entered, exiting."); | |
79 | + exit(1); | |
80 | + } | |
81 | + phpsessidFile.writeln(phpsessid); | |
82 | + } | |
83 | + phpsessidFile.close(); | |
84 | + } | |
85 | + | |
86 | + ctx.phpsessid = phpsessid; | |
87 | + return ctx; | |
88 | +} | |
89 | + | |
90 | +string getPixivPictureDir() | |
91 | +{ | |
92 | + auto configDirPath = open(Directory.config); | |
93 | + auto configFilePath = buildPath(configDirPath.name, "pixiv_down", "settings.conf"); | |
94 | + | |
95 | + if (false == exists(configFilePath)) { | |
96 | + logger.warningf("\"%s\" does not exist.", configFilePath); | |
97 | + return ""; | |
98 | + } | |
99 | + | |
100 | + scope configparser = new ConfigParser(); | |
101 | + configparser.read(configFilePath); | |
102 | + | |
103 | + return configparser.get("output", "base_folder"); | |
104 | +} | |
105 | + | |
106 | +DList!string processDirs(string pixivDirPath) | |
107 | +{ | |
108 | + import std.ascii : isDigit; | |
109 | + | |
110 | + auto dirs = dirEntries(pixivDirPath, SpanMode.shallow); | |
111 | + auto uids = DList!string(); | |
112 | + | |
113 | + foreach(dir; dirs) { | |
114 | + auto bname = baseName(dir); | |
115 | + if (bname.length == 0 || false == isDigit(bname[0])) { | |
116 | + continue; | |
117 | + } | |
118 | + auto uid = bname.split('_')[0]; | |
119 | + uids.insert(uid); | |
120 | + } | |
121 | + | |
122 | + return uids; | |
123 | +} | |
124 | + | |
125 | +void removeUserId(in ref Context ctx, string id) { | |
126 | + foreach(dir; dirEntries(ctx.pixivBaseDir, SpanMode.shallow)) { | |
127 | + string dirname = baseName(dir.name); | |
128 | + if (dirname.startsWith(id)) { | |
129 | + rmdirRecurse(dir); | |
130 | + writefln("Deleted directory: %s", dirname); | |
131 | + } | |
132 | + } | |
133 | +} | |
134 | + | |
135 | +void removeUnfollowed(in ref Context ctx, DList!string uids) | |
136 | +{ | |
137 | + import core.thread : Thread; | |
138 | + import core.time : seconds; | |
139 | + | |
140 | + auto client = new Client(ctx.phpsessid); | |
141 | + | |
142 | + foreach (id; uids) { | |
143 | + FullUser user; | |
144 | + try { | |
145 | + user = client.fetchUser(id); | |
146 | + } catch (PixivJSONException pje) { | |
147 | + logger.warningf("(PixivJSONException) %s", pje.msg); | |
148 | + writefln("User ID %s has either left pixiv, or does not exist.", id); | |
149 | + write("Do you want to remove the related directories? [y/N]: "); | |
150 | + string input = readln().strip().toLower(); | |
151 | + | |
152 | + if (input == "y" || input == "yes") { | |
153 | + removeUserId(ctx, id); | |
154 | + } | |
155 | + continue; | |
156 | + } catch (Exception e) { | |
157 | + logger.critical(e.msg); | |
158 | + | |
159 | + stderr.writefln("Error encountered fetching user %s. No files were modified.", id); | |
160 | + continue; | |
161 | + } | |
162 | + | |
163 | + if (false == user.following) { | |
164 | + writefln("Found user not followed: %s (%s)", user.userName, user.userId); | |
165 | + write("Do you want to remove the related directories? [y/N]: "); | |
166 | + string input = readln().strip().toLower(); | |
167 | + | |
168 | + if (input == "y" || input == "yes") { | |
169 | + writeln("Will remove user data."); | |
170 | + removeUserId(ctx, id); | |
171 | + } | |
172 | + } else { | |
173 | + writefln("Found followed user: %s", user.userName); | |
174 | + } | |
175 | + writeln("Sleeping 10 seconds"); | |
176 | + Thread.sleep(10.seconds); | |
177 | + } | |
178 | +} | |
179 | + | |
180 | + | |
181 | +int main(string[] args) | |
182 | +{ | |
183 | + logger = new FileLogger("pixiv_remove_unfollowed.log"); | |
184 | + | |
185 | + Context ctx = fetchContext(); | |
186 | + | |
187 | + ctx.pixivBaseDir = getPixivPictureDir(); | |
188 | + if (0 == ctx.pixivBaseDir.length) { | |
189 | + stderr.writeln("Error: Could not retrieve pixiv_down configuration file. " ~ | |
190 | + "Possibly because pixiv_down hasn't been used before."); | |
191 | + return 1; | |
192 | + } | |
193 | + | |
194 | + DList!string uids = processDirs(ctx.pixivBaseDir); | |
195 | + if (uids.empty) { | |
196 | + writefln("No users found in %s!", ctx.pixivBaseDir); | |
197 | + return 0; | |
198 | + } | |
199 | + | |
200 | + removeUnfollowed(ctx, uids); | |
201 | + return 0; | |
202 | +} |