sebsync 1.0b1__tar.gz → 1.0.2__tar.gz

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.
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: sebsync
3
- Version: 1.0b1
3
+ Version: 1.0.2
4
4
  Summary: Synchronize Standard Ebooks catalog with local EPUB collection.
5
5
  Home-page: https://github.com/pbryan/sebsync/
6
6
  License: MIT
7
7
  Keywords: standard,ebook,book,synchronization,sync,download
8
8
  Author: sebsync authors
9
9
  Requires-Python: >=3.10,<4.0
10
- Classifier: Development Status :: 4 - Beta
10
+ Classifier: Development Status :: 5 - Production/Stable
11
11
  Classifier: Environment :: Console
12
12
  Classifier: Intended Audience :: End Users/Desktop
13
13
  Classifier: License :: OSI Approved :: MIT License
@@ -8,7 +8,7 @@ target-version = ['py312']
8
8
 
9
9
  [tool.poetry]
10
10
  name = "sebsync"
11
- version = "1.0b1"
11
+ version = "1.0.2"
12
12
  description = "Synchronize Standard Ebooks catalog with local EPUB collection."
13
13
  readme = "README.md"
14
14
  authors = ["sebsync authors"]
@@ -16,7 +16,7 @@ homepage = "https://github.com/pbryan/sebsync/"
16
16
  license = "MIT"
17
17
  keywords = ["standard", "ebook", "book", "synchronization", "sync", "download"]
18
18
  classifiers = [
19
- "Development Status :: 4 - Beta",
19
+ "Development Status :: 5 - Production/Stable",
20
20
  "Environment :: Console",
21
21
  "Intended Audience :: End Users/Desktop",
22
22
  "Topic :: System :: Archiving :: Mirroring",
@@ -28,9 +28,9 @@ click = "^8.1"
28
28
  requests = "^2.32"
29
29
 
30
30
  [tool.poetry.dev-dependencies]
31
- black = "^24.4"
31
+ black = "^24.8"
32
32
  isort = "^5.13"
33
- pre-commit = "^3.7"
33
+ pre-commit = "^3.8"
34
34
  pytest = "^8.3"
35
35
  pytest-cov = "^5.0"
36
36
 
@@ -44,3 +44,4 @@ no_sections = true
44
44
 
45
45
  [tool.poetry.scripts]
46
46
  sebsync = "sebsync:main"
47
+
@@ -9,6 +9,7 @@ from dataclasses import dataclass
9
9
  from datetime import datetime, timezone
10
10
  from pathlib import Path
11
11
  from requests.auth import HTTPBasicAuth
12
+ from urllib.parse import urlparse
12
13
 
13
14
 
14
15
  _dry_run: bool = False
@@ -20,18 +21,23 @@ class Status:
20
21
  NEW = click.style("N", fg="green")
21
22
  UPDATE = click.style("U", fg="blue")
22
23
  EXTRA = click.style("X", fg="yellow")
24
+ UNKNOWN = click.style("?", fg="yellow")
23
25
 
24
26
 
25
27
  _epilog = f"""
26
- The following statuses are reported:
28
+ \b
29
+ Download naming conventions:
30
+ • standard: Standard Ebooks' naming (e.g. "edwin-a-abbott_flatland.epub")
31
+ • sortable: sortable author/title (e.g. "Abbott, Edwin A. - Flatland.epub")
27
32
 
28
33
  \b
29
- {Status.NEW}: new (downloads the new ebook to downloads directory)
30
- {Status.UPDATE}: update (overwrites the existing local ebook with new version)
31
- {Status.EXTRA}: extraneous (local ebook was not found in Standard Ebooks catalog)
34
+ Reported file statuses:
35
+ • {Status.NEW}: new (downloads the new ebook to downloads directory)
36
+ • {Status.UPDATE}: update (overwrites the existing local ebook with new version)
37
+ • {Status.EXTRA}: extraneous (local ebook not found in Standard Ebooks catalog)
38
+ • {Status.UNKNOWN}: unknown (local ebook could not be processed)
32
39
 
33
- An extraneous ebook can occur when Standard Ebooks changes the identifier of a previously
34
- published ebook. It's a rare occurrence, and it's generally safe to delete such files.
40
+ See https://github.com/pbryan/sebsync/ for updates, bug reports and answers.
35
41
  """
36
42
 
37
43
 
@@ -99,6 +105,8 @@ def get_remote_ebooks(opds_url: str, email: str, type: str) -> dict[str, Standar
99
105
  updated=fromisoformat(entry.find("atom:updated", ns).text),
100
106
  )
101
107
  ebooks[ebook.id] = ebook
108
+ if not ebooks:
109
+ raise click.ClickException("OPDS catalog download failed. Is email address correct?")
102
110
  return ebooks
103
111
 
104
112
 
@@ -106,50 +114,76 @@ def get_local_ebooks(dir: Path) -> dict[str, LocalEbook]:
106
114
  """Return metadata of Standard EPUBs in the specified directory and subdirectories."""
107
115
  ebooks = {}
108
116
  for path in dir.glob("**/*.epub"):
109
- with zipfile.ZipFile(path) as zip:
110
- with zip.open("META-INF/container.xml") as file:
111
- root = ElementTree.parse(file)
112
- ns = {"container": "urn:oasis:names:tc:opendocument:xmlns:container"}
113
- rootfile = root.find(".//container:rootfile", ns).attrib["full-path"]
114
- with zip.open(rootfile) as file:
115
- root = ElementTree.parse(file)
116
- ns = {
117
- "opf": "http://www.idpf.org/2007/opf",
118
- "dc": "http://purl.org/dc/elements/1.1/",
119
- }
120
- metadata = root.find("opf:metadata", ns)
121
- id = metadata.find("dc:identifier", ns)
122
- if id is None or "standardebooks.org" not in id.text:
123
- continue
124
- modified = metadata.find(".//opf:meta[@property='dcterms:modified']", ns)
125
- ebook = LocalEbook(
126
- id=id.text,
127
- title=metadata.find(".//dc:title", ns).text,
128
- path=path,
129
- modified=fromisoformat(modified.text),
130
- )
131
- ebooks[ebook.id] = ebook
117
+ if not path.is_file():
118
+ continue
119
+ try:
120
+ with zipfile.ZipFile(path) as zip:
121
+ with zip.open("META-INF/container.xml") as file:
122
+ root = ElementTree.parse(file)
123
+ ns = {"container": "urn:oasis:names:tc:opendocument:xmlns:container"}
124
+ rootfile = root.find(".//container:rootfile", ns).attrib["full-path"]
125
+ with zip.open(rootfile) as file:
126
+ root = ElementTree.parse(file)
127
+ ns = {
128
+ "opf": "http://www.idpf.org/2007/opf",
129
+ "dc": "http://purl.org/dc/elements/1.1/",
130
+ }
131
+ metadata = root.find("opf:metadata", ns)
132
+ id = metadata.find("dc:identifier", ns)
133
+ if id is None or "standardebooks.org" not in id.text:
134
+ continue
135
+ modified = metadata.find(".//opf:meta[@property='dcterms:modified']", ns)
136
+ ebook = LocalEbook(
137
+ id=id.text,
138
+ title=metadata.find(".//dc:title", ns).text,
139
+ path=path,
140
+ modified=fromisoformat(modified.text),
141
+ )
142
+ ebooks[ebook.id] = ebook
143
+ except:
144
+ echo_status(path, Status.UNKNOWN)
132
145
  return ebooks
133
146
 
134
147
 
135
148
  def download_ebook(url: str, path: Path) -> None:
136
149
  """Download the ebook at the specified URL into the specified path."""
137
- if not _dry_run:
138
- response = requests.get(url, stream=True)
139
- with path.open("wb") as file:
140
- for chunk in response.iter_content(chunk_size=1 * 1024 * 1024):
141
- file.write(chunk)
150
+ if _dry_run:
151
+ return
152
+ download = path.with_suffix(".sebsync")
153
+ response = requests.get(url, stream=True)
154
+ with download.open("wb") as file:
155
+ for chunk in response.iter_content(chunk_size=1 * 1024 * 1024):
156
+ file.write(chunk)
157
+ download.replace(path)
158
+
159
+
160
+ def sortable_author(author: str) -> str:
161
+ """Return the sortable name of the given author."""
162
+ suffixes = {"Jr.", "Sr.", "Esq.", "PhD"}
163
+ split = author.split()
164
+ if len(split) < 2:
165
+ return author
166
+ last = split.pop().rstrip(",")
167
+ suffix = None
168
+ if last in suffixes:
169
+ suffix = last
170
+ last = split.pop()
171
+ result = last
172
+ if split:
173
+ result += f", {' '.join(split)}"
174
+ if suffix:
175
+ result += f", {suffix}"
176
+ return result
142
177
 
143
178
 
144
179
  def ebook_filename(ebook: StandardEbook) -> str:
145
- """Return an appropriate EPUB file name for the given ebook author and title."""
180
+ """Return an EPUB file name for Standard ebook."""
146
181
  replace = {"/": "-", "‘": "'", "’": "'", '"': "'", "“": "'", "”": "'"}
147
- author = ebook.author
148
- title = ebook.title
149
- names = author.split()
150
- if len(names) > 1:
151
- author = f"{names[-1]}, {' '.join(names[:-1])}"
152
- result = f"{author} - {title}.epub"
182
+ match _naming:
183
+ case "standard":
184
+ result = Path(urlparse(ebook.href).path).name
185
+ case "sortable":
186
+ result = f"{sortable_author(ebook.author)} - {ebook.title}.epub"
153
187
  for k, v in replace.items():
154
188
  result = result.replace(k, v)
155
189
  return result
@@ -168,6 +202,11 @@ def ebook_filename(ebook: StandardEbook) -> str:
168
202
  type=click.Path(exists=True, file_okay=False, dir_okay=True, writable=True, path_type=Path),
169
203
  default=if_exists(Path.home() / "Downloads"),
170
204
  )
205
+ @click.option(
206
+ "--force-update",
207
+ help="Force update of all local ebooks.",
208
+ is_flag=True,
209
+ )
171
210
  @click.option(
172
211
  "--dry-run",
173
212
  help="Perform a trial run with no changes made.",
@@ -179,6 +218,12 @@ def ebook_filename(ebook: StandardEbook) -> str:
179
218
  required=True,
180
219
  )
181
220
  @click.help_option()
221
+ @click.option(
222
+ "--naming",
223
+ type=click.Choice(["standard", "sortable"]),
224
+ help="Download file naming convention.",
225
+ default="standard",
226
+ )
182
227
  @click.option(
183
228
  "--opds",
184
229
  help="URL of Standard Ebooks OPDS catalog.",
@@ -191,7 +236,7 @@ def ebook_filename(ebook: StandardEbook) -> str:
191
236
  )
192
237
  @click.option(
193
238
  "--type",
194
- type=click.Choice(type_selector.keys()),
239
+ type=click.Choice(list(type_selector.keys())),
195
240
  help="EPUB type to download.",
196
241
  default="compatible",
197
242
  )
@@ -202,10 +247,12 @@ def ebook_filename(ebook: StandardEbook) -> str:
202
247
  )
203
248
  @click.version_option(package_name="sebsync")
204
249
  def sebsync(
205
- books: str,
206
- downloads: str,
250
+ books: Path,
251
+ downloads: Path,
207
252
  dry_run: bool,
208
253
  email: str,
254
+ force_update: bool,
255
+ naming: str,
209
256
  opds: str,
210
257
  quiet: bool,
211
258
  type: str,
@@ -214,14 +261,14 @@ def sebsync(
214
261
  global _dry_run
215
262
  global _verbose
216
263
  global _quiet
264
+ global _naming
217
265
 
218
266
  _dry_run = dry_run
219
- _verbose = verbose and not quiet # quiet wins
267
+ _naming = naming
220
268
  _quiet = quiet
269
+ _verbose = verbose and not quiet # quiet wins
221
270
 
222
271
  remote_ebooks = get_remote_ebooks(opds, email, type)
223
- if not remote_ebooks:
224
- raise click.ClickException("OPDS download failed. Is email address correct?")
225
272
  if _verbose:
226
273
  click.echo(f"Found {len(remote_ebooks)} remote ebooks.")
227
274
 
@@ -231,7 +278,7 @@ def sebsync(
231
278
 
232
279
  for remote_ebook in remote_ebooks.values():
233
280
  if local_ebook := local_ebooks.get(remote_ebook.id):
234
- if remote_ebook.updated != local_ebook.modified:
281
+ if remote_ebook.updated != local_ebook.modified or force_update:
235
282
  echo_status(local_ebook.path, Status.UPDATE)
236
283
  download_ebook(remote_ebook.href, local_ebook.path)
237
284
  else:
@@ -245,7 +292,7 @@ def sebsync(
245
292
 
246
293
 
247
294
  def main():
248
- sebsync(auto_envvar_prefix="SEBSYNC", show_default=True)
295
+ sebsync(show_default=True)
249
296
 
250
297
 
251
298
  if __name__ == "__main__":
File without changes
File without changes
File without changes