sigal 2.3__py3-none-any.whl → 2.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. sigal/__init__.py +2 -285
  2. sigal/__main__.py +312 -0
  3. sigal/gallery.py +188 -158
  4. sigal/image.py +113 -115
  5. sigal/log.py +11 -11
  6. sigal/plugins/adjust.py +4 -4
  7. sigal/plugins/compress_assets.py +26 -25
  8. sigal/plugins/copyright.py +8 -8
  9. sigal/plugins/encrypt/encrypt.py +7 -7
  10. sigal/plugins/encrypt/endec.py +2 -2
  11. sigal/plugins/extended_caching.py +26 -22
  12. sigal/plugins/feeds.py +19 -21
  13. sigal/plugins/media_page.py +1 -1
  14. sigal/plugins/nomedia.py +1 -1
  15. sigal/plugins/nonmedia_files.py +59 -93
  16. sigal/plugins/titleregexp.py +98 -0
  17. sigal/plugins/watermark.py +13 -13
  18. sigal/plugins/zip_gallery.py +17 -8
  19. sigal/settings.py +92 -78
  20. sigal/signals.py +10 -10
  21. sigal/templates/sigal.conf.py +18 -14
  22. sigal/themes/default/templates/decrypt.html +1 -0
  23. sigal/themes/default/templates/description.html +29 -0
  24. sigal/themes/default/templates/footer.html +3 -0
  25. sigal/themes/galleria/templates/album_items.html +4 -23
  26. sigal/themes/photoswipe/static/photoswipe-dynamic-caption-plugin.esm.js +414 -0
  27. sigal/themes/photoswipe/static/photoswipe-dynamic-caption-plugin.esm.min.js +5 -0
  28. sigal/themes/photoswipe/static/photoswipe-fullscreen.esm.js +129 -0
  29. sigal/themes/photoswipe/static/photoswipe-fullscreen.esm.min.js +8 -0
  30. sigal/themes/photoswipe/static/photoswipe-lightbox.esm.js +1960 -0
  31. sigal/themes/photoswipe/static/photoswipe-lightbox.esm.js.map +1 -0
  32. sigal/themes/photoswipe/static/photoswipe-lightbox.esm.min.js +5 -0
  33. sigal/themes/photoswipe/static/photoswipe-video-plugin.esm.js +257 -0
  34. sigal/themes/photoswipe/static/photoswipe-video-plugin.esm.min.js +1 -0
  35. sigal/themes/photoswipe/static/photoswipe.css +385 -140
  36. sigal/themes/photoswipe/static/photoswipe.esm.js +7081 -0
  37. sigal/themes/photoswipe/static/photoswipe.esm.js.map +1 -0
  38. sigal/themes/photoswipe/static/photoswipe.esm.min.js +5 -0
  39. sigal/themes/photoswipe/static/styles.css +53 -0
  40. sigal/themes/photoswipe/templates/album.html +69 -74
  41. sigal/utils.py +80 -12
  42. sigal/version.py +20 -4
  43. sigal/video.py +43 -24
  44. sigal/writer.py +26 -8
  45. {sigal-2.3.dist-info → sigal-2.5.dist-info}/LICENSE +1 -1
  46. {sigal-2.3.dist-info → sigal-2.5.dist-info}/METADATA +23 -30
  47. {sigal-2.3.dist-info → sigal-2.5.dist-info}/RECORD +50 -50
  48. {sigal-2.3.dist-info → sigal-2.5.dist-info}/WHEEL +1 -1
  49. sigal-2.5.dist-info/entry_points.txt +2 -0
  50. sigal/plugins/upload_s3.py +0 -106
  51. sigal/themes/photoswipe/static/app.js +0 -214
  52. sigal/themes/photoswipe/static/default-skin/default-skin.css +0 -485
  53. sigal/themes/photoswipe/static/default-skin/default-skin.css.map +0 -10
  54. sigal/themes/photoswipe/static/default-skin/default-skin.png +0 -0
  55. sigal/themes/photoswipe/static/default-skin/default-skin.svg +0 -36
  56. sigal/themes/photoswipe/static/default-skin/preloader.gif +0 -0
  57. sigal/themes/photoswipe/static/echo/blank.gif +0 -0
  58. sigal/themes/photoswipe/static/echo/echo.js +0 -135
  59. sigal/themes/photoswipe/static/echo/echo.min.js +0 -2
  60. sigal/themes/photoswipe/static/photoswipe-ui-default.js +0 -871
  61. sigal/themes/photoswipe/static/photoswipe-ui-default.min.js +0 -1
  62. sigal/themes/photoswipe/static/photoswipe.css.map +0 -10
  63. sigal/themes/photoswipe/static/photoswipe.js +0 -3592
  64. sigal/themes/photoswipe/static/photoswipe.min.js +0 -1
  65. sigal-2.3.dist-info/entry_points.txt +0 -2
  66. {sigal-2.3.dist-info → sigal-2.5.dist-info}/top_level.txt +0 -0
sigal/__init__.py CHANGED
@@ -1,4 +1,4 @@
1
- # Copyright (c) 2009-2020 - Simon Conseil
1
+ # Copyright (c) 2009-2023 - Simon Conseil
2
2
 
3
3
  # Permission is hereby granted, free of charge, to any person obtaining a copy
4
4
  # of this software and associated documentation files (the "Software"), to
@@ -18,22 +18,6 @@
18
18
  # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
19
19
  # IN THE SOFTWARE.
20
20
 
21
- import importlib
22
- import locale
23
- import logging
24
- import os
25
- import socketserver
26
- import sys
27
- import time
28
- from http import server
29
-
30
- import click
31
- from click import argument, option
32
-
33
- from .gallery import Gallery
34
- from .log import init_logging
35
- from .settings import read_settings
36
- from .utils import copy
37
21
 
38
22
  try:
39
23
  from .version import __version__
@@ -41,271 +25,4 @@ except ImportError:
41
25
  # package is not installed
42
26
  __version__ = None
43
27
 
44
- __url__ = 'https://github.com/saimn/sigal'
45
-
46
- _DEFAULT_CONFIG_FILE = 'sigal.conf.py'
47
-
48
-
49
- @click.group()
50
- @click.version_option(version=__version__)
51
- def main():
52
- """Sigal - Simple Static Gallery Generator.
53
-
54
- Sigal is yet another python script to prepare a static gallery of images:
55
- resize images, create thumbnails with some options, generate html pages.
56
-
57
- """
58
- pass # pragma: no cover
59
-
60
-
61
- @main.command()
62
- @argument('path', default=_DEFAULT_CONFIG_FILE)
63
- def init(path):
64
- """Copy a sample config file in the current directory (default to
65
- 'sigal.conf.py'), or use the provided 'path'."""
66
-
67
- if os.path.isfile(path):
68
- print("Found an existing config file, will abort to keep it safe.")
69
- sys.exit(1)
70
-
71
- from pkg_resources import resource_string
72
-
73
- conf = resource_string(__name__, 'templates/sigal.conf.py')
74
-
75
- with open(path, 'w', encoding='utf-8') as f:
76
- f.write(conf.decode('utf8'))
77
- print(f"Sample config file created: {path}")
78
-
79
-
80
- @main.command()
81
- @argument('source', required=False)
82
- @argument('destination', required=False)
83
- @option('-f', '--force', is_flag=True, help="Force the reprocessing of existing images")
84
- @option('-v', '--verbose', is_flag=True, help="Show all messages")
85
- @option(
86
- '-d',
87
- '--debug',
88
- is_flag=True,
89
- help=(
90
- "Show all messages, including debug messages. Also raise "
91
- "exception if an error happen when processing files."
92
- ),
93
- )
94
- @option('-q', '--quiet', is_flag=True, help="Show only error messages")
95
- @option(
96
- '-c',
97
- '--config',
98
- default=_DEFAULT_CONFIG_FILE,
99
- show_default=True,
100
- help="Configuration file",
101
- )
102
- @option(
103
- '-t',
104
- '--theme',
105
- help=(
106
- "Specify a theme directory, or a theme name for the themes included with Sigal"
107
- ),
108
- )
109
- @option('--title', help="Title of the gallery (overrides the title setting.")
110
- @option('-n', '--ncpu', help="Number of cpu to use (default: all)")
111
- def build(
112
- source, destination, debug, verbose, quiet, force, config, theme, title, ncpu
113
- ):
114
- """Run sigal to process a directory.
115
-
116
- If provided, 'source', 'destination' and 'theme' will override the
117
- corresponding values from the settings file.
118
-
119
- """
120
- if sum([debug, verbose, quiet]) > 1:
121
- sys.exit('Only one option of debug, verbose and quiet should be used')
122
-
123
- if debug:
124
- level = logging.DEBUG
125
- elif verbose:
126
- level = logging.INFO
127
- elif quiet:
128
- level = logging.ERROR
129
- else:
130
- level = logging.WARNING
131
-
132
- init_logging(__name__, level=level)
133
- logger = logging.getLogger(__name__)
134
-
135
- if not os.path.isfile(config):
136
- logger.error("Settings file not found: %s", config)
137
- sys.exit(1)
138
-
139
- start_time = time.time()
140
- settings = read_settings(config)
141
-
142
- for key in ('source', 'destination', 'theme'):
143
- arg = locals()[key]
144
- if arg is not None:
145
- settings[key] = os.path.abspath(arg)
146
- logger.info("%12s : %s", key.capitalize(), settings[key])
147
-
148
- if not settings['source'] or not os.path.isdir(settings['source']):
149
- logger.error("Input directory not found: %s", settings['source'])
150
- sys.exit(1)
151
-
152
- # on windows os.path.relpath raises a ValueError if the two paths are on
153
- # different drives, in that case we just ignore the exception as the two
154
- # paths are anyway not relative
155
- relative_check = True
156
- try:
157
- relative_check = os.path.relpath(
158
- settings['destination'], settings['source']
159
- ).startswith('..')
160
- except ValueError:
161
- pass
162
-
163
- if not relative_check:
164
- logger.error("Output directory should be outside of the input directory.")
165
- sys.exit(1)
166
-
167
- if title:
168
- settings['title'] = title
169
-
170
- locale.setlocale(locale.LC_ALL, settings['locale'])
171
- init_plugins(settings)
172
-
173
- gal = Gallery(settings, ncpu=ncpu, quiet=quiet)
174
- gal.build(force=force)
175
-
176
- # copy extra files
177
- for src, dst in settings['files_to_copy']:
178
- src = os.path.join(settings['source'], src)
179
- dst = os.path.join(settings['destination'], dst)
180
- logger.debug('Copy %s to %s', src, dst)
181
- copy(src, dst, symlink=settings['orig_link'], rellink=settings['rel_link'])
182
-
183
- stats = gal.stats
184
-
185
- def format_stats(_type):
186
- opt = [
187
- "{} {}".format(stats[_type + '_' + subtype], subtype)
188
- for subtype in ('skipped', 'failed')
189
- if stats[_type + '_' + subtype] > 0
190
- ]
191
- opt = ' ({})'.format(', '.join(opt)) if opt else ''
192
- return f'{stats[_type]} {_type}s{opt}'
193
-
194
- if not quiet:
195
- stats_str = ''
196
- types = sorted({t.rsplit('_', 1)[0] for t in stats})
197
- for t in types[:-1]:
198
- stats_str += f'{format_stats(t)} and '
199
- stats_str += f'{format_stats(types[-1])}'
200
- end_time = time.time() - start_time
201
- print(f'Done, processed {stats_str} in {end_time:.2f} seconds.')
202
-
203
-
204
- def init_plugins(settings):
205
- """Load plugins and call register()."""
206
-
207
- logger = logging.getLogger(__name__)
208
- logger.debug('Plugin paths: %s', settings['plugin_paths'])
209
-
210
- for path in settings['plugin_paths']:
211
- sys.path.insert(0, path)
212
-
213
- for plugin in settings['plugins']:
214
- try:
215
- if isinstance(plugin, str):
216
- mod = importlib.import_module(plugin)
217
- mod.register(settings)
218
- else:
219
- plugin.register(settings)
220
- logger.debug('Registered plugin %s', plugin)
221
- except Exception as e:
222
- logger.error('Failed to load plugin %s: %r', plugin, e)
223
-
224
- for path in settings['plugin_paths']:
225
- sys.path.remove(path)
226
-
227
-
228
- @main.command()
229
- @argument('destination', default='_build')
230
- @option('-p', '--port', help="Port to use", default=8000)
231
- @option(
232
- '-c',
233
- '--config',
234
- default=_DEFAULT_CONFIG_FILE,
235
- show_default=True,
236
- help='Configuration file',
237
- )
238
- def serve(destination, port, config):
239
- """Run a simple web server."""
240
- if os.path.exists(destination):
241
- pass
242
- elif os.path.exists(config):
243
- settings = read_settings(config)
244
- destination = settings.get('destination')
245
- if not os.path.exists(destination):
246
- sys.stderr.write(
247
- f"The '{destination}' directory doesn't exist, maybe try building"
248
- " first?\n"
249
- )
250
- sys.exit(1)
251
- else:
252
- sys.stderr.write(
253
- f"The {destination} directory doesn't exist "
254
- f"and the config file ({config}) could not be read.\n"
255
- )
256
- sys.exit(2)
257
-
258
- print(f'DESTINATION : {destination}')
259
- os.chdir(destination)
260
- Handler = server.SimpleHTTPRequestHandler
261
- httpd = socketserver.TCPServer(("", port), Handler, False)
262
- print(f" * Running on http://127.0.0.1:{port}/")
263
-
264
- try:
265
- httpd.allow_reuse_address = True
266
- httpd.server_bind()
267
- httpd.server_activate()
268
- httpd.serve_forever()
269
- except KeyboardInterrupt:
270
- print('\nAll done!')
271
-
272
-
273
- @main.command()
274
- @argument('target')
275
- @argument('keys', nargs=-1)
276
- @option(
277
- '-o', '--overwrite', default=False, is_flag=True, help='Overwrite existing .md file'
278
- )
279
- def set_meta(target, keys, overwrite=False):
280
- """Write metadata keys to .md file.
281
-
282
- TARGET can be a media file or an album directory. KEYS are key/value pairs.
283
-
284
- Ex, to set the title of test.jpg to "My test image":
285
-
286
- sigal set_meta test.jpg title "My test image"
287
- """
288
-
289
- if not os.path.exists(target):
290
- sys.stderr.write(f"The target {target} does not exist.\n")
291
- sys.exit(1)
292
- if len(keys) < 2 or len(keys) % 2 > 0:
293
- sys.stderr.write("Need an even number of arguments.\n")
294
- sys.exit(1)
295
-
296
- if os.path.isdir(target):
297
- descfile = os.path.join(target, 'index.md')
298
- else:
299
- descfile = os.path.splitext(target)[0] + '.md'
300
- if os.path.exists(descfile) and not overwrite:
301
- sys.stderr.write(
302
- f"Description file '{descfile}' already exists. "
303
- "Use --overwrite to overwrite it.\n"
304
- )
305
- sys.exit(2)
306
-
307
- with open(descfile, "w") as fp:
308
- for i in range(len(keys) // 2):
309
- k, v = keys[i * 2 : (i + 1) * 2]
310
- fp.write(f"{k.capitalize()}: {v}\n")
311
- print(f"{len(keys) // 2} metadata key(s) written to {descfile}")
28
+ __url__ = "https://github.com/saimn/sigal"
sigal/__main__.py ADDED
@@ -0,0 +1,312 @@
1
+ # Copyright (c) 2009-2023 - Simon Conseil
2
+
3
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ # of this software and associated documentation files (the "Software"), to
5
+ # deal in the Software without restriction, including without limitation the
6
+ # rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
7
+ # sell copies of the Software, and to permit persons to whom the Software is
8
+ # furnished to do so, subject to the following conditions:
9
+
10
+ # The above copyright notice and this permission notice shall be included in
11
+ # all copies or substantial portions of the Software.
12
+
13
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
18
+ # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
19
+ # IN THE SOFTWARE.
20
+
21
+ import locale
22
+ import logging
23
+ import os
24
+ import pathlib
25
+ import socketserver
26
+ import sys
27
+ import time
28
+ import webbrowser
29
+ from http import server
30
+
31
+ import click
32
+ from click import argument, option
33
+
34
+ from .gallery import Gallery
35
+ from .log import init_logging
36
+ from .settings import read_settings
37
+ from .utils import copy, init_plugins
38
+
39
+ try:
40
+ from .version import __version__
41
+ except ImportError:
42
+ # package is not installed
43
+ __version__ = None
44
+
45
+ _DEFAULT_CONFIG_FILE = "sigal.conf.py"
46
+
47
+
48
+ @click.group()
49
+ @click.version_option(version=__version__)
50
+ def main():
51
+ """Sigal - Simple Static Gallery Generator.
52
+
53
+ Sigal is yet another python script to prepare a static gallery of images:
54
+ resize images, create thumbnails with some options, generate html pages.
55
+
56
+ """
57
+
58
+
59
+ @main.command()
60
+ @argument("path", default=_DEFAULT_CONFIG_FILE)
61
+ def init(path):
62
+ """Copy a sample config file in the current directory (default to
63
+ 'sigal.conf.py'), or use the provided 'path'."""
64
+
65
+ path = pathlib.Path(path)
66
+ if path.exists():
67
+ print("Found an existing config file, will abort to keep it safe.")
68
+ sys.exit(1)
69
+
70
+ conf = pathlib.Path(__file__).parent / "templates" / "sigal.conf.py"
71
+ path.write_text(conf.read_text())
72
+ print(f"Sample config file created: {path}")
73
+
74
+
75
+ @main.command()
76
+ @argument("source", required=False)
77
+ @argument("destination", required=False)
78
+ @option("-f", "--force", is_flag=True, help="Force the reprocessing of existing images")
79
+ @option(
80
+ "-a",
81
+ "--force-album",
82
+ multiple=True,
83
+ help=(
84
+ "Force reprocessing of any album that matches the given pattern. "
85
+ "Patterns containing no wildcards will be matched against only "
86
+ "the album name. (-a 'My Pictures/* Pics' -a 'Festival')"
87
+ ),
88
+ )
89
+ @option("-v", "--verbose", is_flag=True, help="Show all messages")
90
+ @option(
91
+ "-d",
92
+ "--debug",
93
+ is_flag=True,
94
+ help=(
95
+ "Show all messages, including debug messages. Also raise "
96
+ "exception if an error happen when processing files."
97
+ ),
98
+ )
99
+ @option("-q", "--quiet", is_flag=True, help="Show only error messages")
100
+ @option(
101
+ "-c",
102
+ "--config",
103
+ default=_DEFAULT_CONFIG_FILE,
104
+ show_default=True,
105
+ help="Configuration file",
106
+ )
107
+ @option(
108
+ "-t",
109
+ "--theme",
110
+ help=(
111
+ "Specify a theme directory, or a theme name for the themes included with Sigal"
112
+ ),
113
+ )
114
+ @option("--title", help="Title of the gallery (overrides the title setting.")
115
+ @option("-n", "--ncpu", help="Number of cpu to use (default: all)")
116
+ def build(
117
+ source,
118
+ destination,
119
+ debug,
120
+ verbose,
121
+ quiet,
122
+ force,
123
+ force_album,
124
+ config,
125
+ theme,
126
+ title,
127
+ ncpu,
128
+ ):
129
+ """Run sigal to process a directory.
130
+
131
+ If provided, 'source', 'destination' and 'theme' will override the
132
+ corresponding values from the settings file.
133
+
134
+ """
135
+ if sum([debug, verbose, quiet]) > 1:
136
+ sys.exit("Only one option of debug, verbose and quiet should be used")
137
+
138
+ show_progress = False
139
+ if debug:
140
+ level = logging.DEBUG
141
+ elif verbose:
142
+ level = logging.INFO
143
+ elif quiet:
144
+ level = logging.ERROR
145
+ else:
146
+ level = logging.WARNING
147
+ show_progress = True
148
+
149
+ init_logging("sigal", level=level)
150
+ logger = logging.getLogger(__name__)
151
+
152
+ if not os.path.isfile(config):
153
+ logger.error("Settings file not found: %s", config)
154
+ sys.exit(1)
155
+
156
+ start_time = time.time()
157
+ settings = read_settings(config)
158
+
159
+ for key in ("source", "destination", "theme"):
160
+ arg = locals()[key]
161
+ if arg is not None:
162
+ settings[key] = os.path.abspath(arg)
163
+ logger.info("%12s : %s", key.capitalize(), settings[key])
164
+
165
+ if not settings["source"] or not os.path.isdir(settings["source"]):
166
+ logger.error("Input directory not found: %s", settings["source"])
167
+ sys.exit(1)
168
+
169
+ # on windows os.path.relpath raises a ValueError if the two paths are on
170
+ # different drives, in that case we just ignore the exception as the two
171
+ # paths are anyway not relative
172
+ relative_check = True
173
+ try:
174
+ relative_check = os.path.relpath(
175
+ settings["destination"], settings["source"]
176
+ ).startswith("..")
177
+ except ValueError:
178
+ pass
179
+
180
+ if not relative_check:
181
+ logger.error("Output directory should be outside of the input directory.")
182
+ sys.exit(1)
183
+
184
+ if title:
185
+ settings["title"] = title
186
+
187
+ locale.setlocale(locale.LC_ALL, settings["locale"])
188
+ init_plugins(settings)
189
+
190
+ gal = Gallery(settings, ncpu=ncpu, show_progress=show_progress)
191
+ gal.build(force=force_album if len(force_album) else force)
192
+
193
+ # copy extra files
194
+ for src, dst in settings["files_to_copy"]:
195
+ src = os.path.join(settings["source"], src)
196
+ dst = os.path.join(settings["destination"], dst)
197
+ logger.debug("Copy %s to %s", src, dst)
198
+ copy(src, dst, symlink=settings["orig_link"], rellink=settings["rel_link"])
199
+
200
+ stats = gal.stats
201
+
202
+ def format_stats(_type):
203
+ opt = [
204
+ "{} {}".format(stats[_type + "_" + subtype], subtype)
205
+ for subtype in ("skipped", "failed")
206
+ if stats[_type + "_" + subtype] > 0
207
+ ]
208
+ opt = " ({})".format(", ".join(opt)) if opt else ""
209
+ return f"{stats[_type]} {_type}s{opt}"
210
+
211
+ if not quiet and len(stats) > 0:
212
+ stats_str = ""
213
+ types = sorted({t.rsplit("_", 1)[0] for t in stats})
214
+ for t in types[:-1]:
215
+ stats_str += f"{format_stats(t)} and "
216
+ stats_str += f"{format_stats(types[-1])}"
217
+ end_time = time.time() - start_time
218
+ print(f"Done, processed {stats_str} in {end_time:.2f} seconds.")
219
+
220
+
221
+ @main.command()
222
+ @argument("destination", default="_build")
223
+ @option("-p", "--port", help="Port to use", default=8000)
224
+ @option(
225
+ "-c",
226
+ "--config",
227
+ default=_DEFAULT_CONFIG_FILE,
228
+ show_default=True,
229
+ help="Configuration file",
230
+ )
231
+ @option("-b", "--browser", is_flag=True, help="Open in your default browser")
232
+ def serve(destination, port, config, browser):
233
+ """Run a simple web server."""
234
+ if os.path.exists(destination):
235
+ pass
236
+ elif os.path.exists(config):
237
+ settings = read_settings(config)
238
+ destination = settings.get("destination")
239
+ if not os.path.exists(destination):
240
+ sys.stderr.write(
241
+ f"The '{destination}' directory doesn't exist, maybe try building"
242
+ " first?\n"
243
+ )
244
+ sys.exit(1)
245
+ else:
246
+ sys.stderr.write(
247
+ f"The {destination} directory doesn't exist "
248
+ f"and the config file ({config}) could not be read.\n"
249
+ )
250
+ sys.exit(2)
251
+
252
+ print(f"DESTINATION : {destination}")
253
+ os.chdir(destination)
254
+ Handler = server.SimpleHTTPRequestHandler
255
+ httpd = socketserver.TCPServer(("", port), Handler, False)
256
+ print(f" * Running on http://127.0.0.1:{port}/")
257
+
258
+ if browser:
259
+ webbrowser.open(f"http://127.0.0.1:{port}/")
260
+
261
+ try:
262
+ httpd.allow_reuse_address = True
263
+ httpd.server_bind()
264
+ httpd.server_activate()
265
+ httpd.serve_forever()
266
+ except KeyboardInterrupt:
267
+ print("\nAll done!")
268
+
269
+
270
+ @main.command()
271
+ @argument("target")
272
+ @argument("keys", nargs=-1)
273
+ @option(
274
+ "-o", "--overwrite", default=False, is_flag=True, help="Overwrite existing .md file"
275
+ )
276
+ def set_meta(target, keys, overwrite=False):
277
+ """Write metadata keys to .md file.
278
+
279
+ TARGET can be a media file or an album directory. KEYS are key/value pairs.
280
+
281
+ Ex, to set the title of test.jpg to "My test image":
282
+
283
+ sigal set_meta test.jpg title "My test image"
284
+ """
285
+
286
+ if not os.path.exists(target):
287
+ sys.stderr.write(f"The target {target} does not exist.\n")
288
+ sys.exit(1)
289
+ if len(keys) < 2 or len(keys) % 2 > 0:
290
+ sys.stderr.write("Need an even number of arguments.\n")
291
+ sys.exit(1)
292
+
293
+ if os.path.isdir(target):
294
+ descfile = os.path.join(target, "index.md")
295
+ else:
296
+ descfile = os.path.splitext(target)[0] + ".md"
297
+ if os.path.exists(descfile) and not overwrite:
298
+ sys.stderr.write(
299
+ f"Description file '{descfile}' already exists. "
300
+ "Use --overwrite to overwrite it.\n"
301
+ )
302
+ sys.exit(2)
303
+
304
+ with open(descfile, "w") as fp:
305
+ for i in range(len(keys) // 2):
306
+ k, v = keys[i * 2 : (i + 1) * 2]
307
+ fp.write(f"{k.capitalize()}: {v}\n")
308
+ print(f"{len(keys) // 2} metadata key(s) written to {descfile}")
309
+
310
+
311
+ if __name__ == "__main__":
312
+ main()