mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
Merge pull request #93 from lrnselfreliance/feature/archive-name-change
Feature/archive name change Signed-off-by: Roland <roland@learningselfreliance.com>
This commit is contained in:
commit
d84db4e7a0
4 changed files with 243 additions and 98 deletions
|
|
@ -5,8 +5,9 @@ import pathlib
|
|||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from itertools import groupby
|
||||
from typing import Iterator
|
||||
from typing import Iterator, Optional
|
||||
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
|
|
@ -14,8 +15,8 @@ from selenium import webdriver
|
|||
from sqlalchemy.orm import Session
|
||||
|
||||
from modules.archive.models import Domain, Archive
|
||||
from wrolpi.common import get_media_directory, logger, chunks, extract_domain, chdir
|
||||
from wrolpi.dates import now, strptime_ms, strftime_ms, Seconds
|
||||
from wrolpi.common import get_media_directory, logger, chunks, extract_domain, chdir, escape_file_name, walk
|
||||
from wrolpi.dates import now, Seconds, local_timezone
|
||||
from wrolpi.db import get_db_session, get_db_curs, get_ranked_models
|
||||
from wrolpi.errors import InvalidDomain, UnknownURL, InvalidArchive
|
||||
from wrolpi.vars import DOCKERIZED, PYTEST
|
||||
|
|
@ -47,18 +48,20 @@ def get_domain_directory(url: str) -> pathlib.Path:
|
|||
return directory
|
||||
|
||||
|
||||
def get_new_archive_files(url: str):
|
||||
def get_new_archive_files(url: str, title: Optional[str]):
|
||||
"""
|
||||
Create a list of archive files using a shared name schema. Raise an error if any of them exist.
|
||||
"""
|
||||
directory = get_domain_directory(url)
|
||||
dt = strftime_ms(now())
|
||||
dt = now().strftime('%Y-%m-%d-%H-%M-%S')
|
||||
|
||||
singlefile_path = directory / f'{dt}.html'
|
||||
readability_path = directory / f'{dt}-readability.html'
|
||||
readability_txt_path = directory / f'{dt}-readability.txt'
|
||||
readability_json_path = directory / f'{dt}-readability.json'
|
||||
screenshot_path = directory / f'{dt}.png'
|
||||
title = escape_file_name(title or 'NA')
|
||||
prefix = f'{dt}_{title}'
|
||||
singlefile_path = directory / f'{prefix}.html'
|
||||
readability_path = directory / f'{prefix}-readability.html'
|
||||
readability_txt_path = directory / f'{prefix}-readability.txt'
|
||||
readability_json_path = directory / f'{prefix}-readability.json'
|
||||
screenshot_path = directory / f'{prefix}.png'
|
||||
|
||||
ret = (singlefile_path, readability_path, readability_txt_path, readability_json_path, screenshot_path)
|
||||
|
||||
|
|
@ -196,8 +199,6 @@ def do_archive(url: str) -> Archive:
|
|||
record in the DB. Create Domain/URL if missing.
|
||||
"""
|
||||
logger.info(f'Archiving {url}')
|
||||
singlefile_path, readability_path, readability_txt_path, readability_json_path, screenshot_path = \
|
||||
get_new_archive_files(url)
|
||||
|
||||
if DOCKERIZED or PYTEST:
|
||||
# Perform the archive in the Archive docker container. (Typically in the development environment).
|
||||
|
|
@ -206,6 +207,30 @@ def do_archive(url: str) -> Archive:
|
|||
# Perform the archive using locally installed executables.
|
||||
singlefile, readability, screenshot = local_archive(url)
|
||||
|
||||
# First try to get the title from Readability.
|
||||
title = readability.get('title') if readability else None
|
||||
|
||||
if not title and singlefile:
|
||||
# Try to get the title ourselves from the HTML.
|
||||
title = get_title_from_html(singlefile, url=url)
|
||||
if readability:
|
||||
# Readability could not find title, lets use ours.
|
||||
readability['title'] = title
|
||||
|
||||
singlefile_path, readability_path, readability_txt_path, readability_json_path, screenshot_path = \
|
||||
get_new_archive_files(url, title)
|
||||
|
||||
if readability:
|
||||
# Write the readability parts to their own files. Write what is left after pops to the JSON file.
|
||||
with readability_path.open('wt') as fh:
|
||||
fh.write(readability.pop('content'))
|
||||
with readability_txt_path.open('wt') as fh:
|
||||
readability_txt = readability.pop('textContent')
|
||||
fh.write(readability_txt)
|
||||
else:
|
||||
# No readability was returned, so there are no files.
|
||||
readability_txt = readability_path = readability_txt_path = None
|
||||
|
||||
# Store the single-file HTML in its own file.
|
||||
with singlefile_path.open('wt') as fh:
|
||||
fh.write(singlefile)
|
||||
|
|
@ -215,27 +240,8 @@ def do_archive(url: str) -> Archive:
|
|||
else:
|
||||
screenshot_path = None
|
||||
|
||||
# Store the Readability into separate files. This allows the user to view text-only or html articles.
|
||||
title = None
|
||||
readability_txt = None
|
||||
if readability:
|
||||
title = readability.get('title')
|
||||
|
||||
# Write the readability parts to their own files. Write what is left after pops to the JSON file.
|
||||
with readability_path.open('wt') as fh:
|
||||
fh.write(readability.pop('content'))
|
||||
with readability_txt_path.open('wt') as fh:
|
||||
readability_txt = readability.pop('textContent')
|
||||
fh.write(readability_txt)
|
||||
else:
|
||||
readability_path = readability_txt_path = None
|
||||
|
||||
# Always write a JSON file that contains at least the URL.
|
||||
readability = readability or {}
|
||||
# Use the Readability title, or try and extract one from singlefile.
|
||||
if not title and singlefile:
|
||||
title = get_title_from_html(singlefile, url=url)
|
||||
readability['title'] = title
|
||||
readability['url'] = url
|
||||
with readability_json_path.open('wt') as fh:
|
||||
fh.write(json.dumps(readability))
|
||||
|
|
@ -304,6 +310,13 @@ def delete_archive(archive_id: int):
|
|||
archive.delete()
|
||||
|
||||
|
||||
def archive_strptime(dt: str) -> datetime:
|
||||
try:
|
||||
return local_timezone(datetime.strptime(dt, '%Y-%m-%d-%H-%M-%S'))
|
||||
except ValueError:
|
||||
return local_timezone(datetime.strptime(dt, '%Y-%m-%d %H:%M:%S'))
|
||||
|
||||
|
||||
def group_archive_files(files: Iterator[pathlib.Path]) -> groupby:
|
||||
"""
|
||||
Group archive files by their timestamp.
|
||||
|
|
@ -311,10 +324,10 @@ def group_archive_files(files: Iterator[pathlib.Path]) -> groupby:
|
|||
# groupby requires the files to be sorted.
|
||||
files = sorted(files)
|
||||
# Group archive files by their datetime at the beginning of the file.
|
||||
groups = groupby(files, key=lambda i: i.name[:26])
|
||||
groups = groupby(files, key=lambda i: i.name[:19])
|
||||
for dt, files in groups:
|
||||
try:
|
||||
dt = strptime_ms(dt)
|
||||
dt = archive_strptime(dt)
|
||||
except ValueError:
|
||||
logger.info(f'Ignoring invalid archives of {dt=}')
|
||||
continue
|
||||
|
|
@ -345,15 +358,22 @@ def group_archive_files(files: Iterator[pathlib.Path]) -> groupby:
|
|||
|
||||
|
||||
ARCHIVE_MATCHER = re.compile(r'\d{4}-\d\d-\d\d (\d\d:){2}\d\d\.\d{6}.*$')
|
||||
NEW_ARCHIVE_MATCHER = re.compile(r'\d{4}-(\d{2}-){4}\d\d_.*$')
|
||||
ARCHIVE_SUFFIXES = {'.txt', '.html', '.json', '.png', '.jpg', '.jpeg'}
|
||||
|
||||
|
||||
def is_archive_file(path: pathlib.Path) -> bool:
|
||||
"""
|
||||
Archive files are expected to start with the following: %Y-%m-%d %H:%M:%S.%f
|
||||
Archive files are expected to start with the following: %Y-%m-%d-%H-%M-%S
|
||||
they must have one of the following suffixes: .txt, .html, .json, .png, .jpg, .jpeg
|
||||
"""
|
||||
return path.is_file() and path.suffix.lower() in ARCHIVE_SUFFIXES and bool(ARCHIVE_MATCHER.match(path.name))
|
||||
if not path.is_file():
|
||||
return False
|
||||
if path.suffix.lower() not in ARCHIVE_SUFFIXES:
|
||||
return False
|
||||
if bool(ARCHIVE_MATCHER.match(path.name)) or bool(NEW_ARCHIVE_MATCHER.match(path.name)):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _refresh_archives():
|
||||
|
|
@ -366,18 +386,18 @@ def _refresh_archives():
|
|||
singlefile_paths = set()
|
||||
for domain_directory in filter(lambda i: i.is_dir(), archive_directory.iterdir()):
|
||||
logger.debug(f'Refreshing directory: {domain_directory}')
|
||||
archives_files = filter(is_archive_file, domain_directory.iterdir())
|
||||
archives_files = filter(is_archive_file, walk(domain_directory))
|
||||
archive_groups = group_archive_files(archives_files)
|
||||
archive_count = 0
|
||||
for chunk in chunks(archive_groups, 20):
|
||||
archive_count += 1
|
||||
with get_db_session(commit=True) as session:
|
||||
for dt, files in chunk:
|
||||
archive_count += 1
|
||||
singlefile_paths.add(str(files[0]))
|
||||
upsert_archive(dt, files, session)
|
||||
|
||||
if archive_count:
|
||||
logger.info(f'Inserted/updated {archive_count} archives')
|
||||
logger.info(f'Inserted/updated {archive_count} archives in {domain_directory}')
|
||||
|
||||
singlefile_paths = list(singlefile_paths)
|
||||
with get_db_curs(commit=True) as curs:
|
||||
|
|
|
|||
|
|
@ -5,13 +5,13 @@ from datetime import datetime
|
|||
import mock
|
||||
|
||||
from modules.archive.lib import get_or_create_domain, _refresh_archives, \
|
||||
get_new_archive_files, delete_archive, do_archive, get_domains
|
||||
get_new_archive_files, delete_archive, do_archive, get_domains, get_archive_directory, group_archive_files
|
||||
from modules.archive.models import Archive, Domain
|
||||
from wrolpi.common import get_media_directory
|
||||
from wrolpi.dates import local_timezone
|
||||
from wrolpi.db import get_db_session
|
||||
from wrolpi.media_path import MediaPath
|
||||
from wrolpi.root_api import CustomJSONEncoder
|
||||
from wrolpi.test.common import TestAPI, wrap_test_db, wrap_media_directory
|
||||
from wrolpi.test.common import TestAPI, wrap_test_db
|
||||
|
||||
|
||||
def make_fake_request_archive(readability=True, screenshot=True, title=True):
|
||||
|
|
@ -34,23 +34,6 @@ class TestArchive(TestAPI):
|
|||
super().setUp()
|
||||
(pathlib.Path(self.tmp_dir.name) / 'archive').mkdir(exist_ok=True)
|
||||
|
||||
@mock.patch('modules.archive.lib.now', lambda: datetime(2001, 1, 1))
|
||||
def test_get_new_archive_files(self):
|
||||
s, r, t, j, c = map(str, get_new_archive_files('https://example.com/two'))
|
||||
assert str(s).endswith('archive/example.com/2001-01-01 00:00:00.000000.html')
|
||||
assert str(r).endswith('archive/example.com/2001-01-01 00:00:00.000000-readability.html')
|
||||
assert str(t).endswith('archive/example.com/2001-01-01 00:00:00.000000-readability.txt')
|
||||
assert str(j).endswith('archive/example.com/2001-01-01 00:00:00.000000-readability.json')
|
||||
assert str(c).endswith('archive/example.com/2001-01-01 00:00:00.000000.png')
|
||||
|
||||
s, r, t, j, c = get_new_archive_files('https://www.example.com/one')
|
||||
# Leading www. is removed.
|
||||
assert str(s).endswith('archive/example.com/2001-01-01 00:00:00.000000.html')
|
||||
assert str(r).endswith('archive/example.com/2001-01-01 00:00:00.000000-readability.html')
|
||||
assert str(t).endswith('archive/example.com/2001-01-01 00:00:00.000000-readability.txt')
|
||||
assert str(j).endswith('archive/example.com/2001-01-01 00:00:00.000000-readability.json')
|
||||
assert str(c).endswith('archive/example.com/2001-01-01 00:00:00.000000.png')
|
||||
|
||||
@wrap_test_db
|
||||
def test_no_screenshot(self):
|
||||
with mock.patch('modules.archive.lib.request_archive', make_fake_request_archive(screenshot=False)):
|
||||
|
|
@ -103,42 +86,6 @@ class TestArchive(TestAPI):
|
|||
except ValueError as e:
|
||||
self.assertIn('relative', str(e), f'Relative path error was not raised')
|
||||
|
||||
@wrap_test_db
|
||||
def test_refresh_archives(self):
|
||||
with wrap_media_directory():
|
||||
archive_directory = get_media_directory() / 'archive'
|
||||
archive_directory.mkdir()
|
||||
|
||||
# Make some test files to refresh.
|
||||
example = archive_directory / 'example.com'
|
||||
example.mkdir()
|
||||
(example / '2021-10-05 16:20:10.346823.html').touch()
|
||||
(example / '2021-10-05 16:20:10.346823.png').touch()
|
||||
(example / '2021-10-05 16:20:10.346823-readability.txt').touch()
|
||||
(example / '2021-10-05 16:20:10.346823-readability.json').touch()
|
||||
(example / '2021-10-05 16:20:10.346823-readability.html').touch()
|
||||
with (example / '2021-10-05 16:20:10.346823-readability.json').open('wt') as fh:
|
||||
json.dump({'url': 'foo'}, fh)
|
||||
|
||||
# These should log an error because they are missing the singlefile path.
|
||||
(example / '2021-10-05 16:20:10.346824-readability.html').touch()
|
||||
(example / '2021-10-05 16:20:10.346825-readability.json').touch()
|
||||
# These are not archives and should be ignored.
|
||||
(example / '2021-10-05 16:20:10.346826-something.html').touch()
|
||||
(example / 'random file').touch()
|
||||
(example / '2021-10-05 16:20:10.346827-something.html').mkdir()
|
||||
(example / '2021-10-05 16:20:10 invalid date').touch()
|
||||
|
||||
# This single archive is found.
|
||||
_refresh_archives()
|
||||
with get_db_session() as session:
|
||||
self.assertEqual(session.query(Archive).count(), 1)
|
||||
|
||||
# Running the refresh does not result in a new archive.
|
||||
_refresh_archives()
|
||||
with get_db_session() as session:
|
||||
self.assertEqual(session.query(Archive).count(), 1)
|
||||
|
||||
|
||||
def test_archive_refresh_deleted_archive(test_session, archive_directory, archive_factory):
|
||||
"""
|
||||
|
|
@ -191,9 +138,7 @@ def test_archive_refresh_deleted_archive(test_session, archive_directory, archiv
|
|||
|
||||
|
||||
def test_refresh_archives_fills_contents(test_session, archive_factory, test_client):
|
||||
"""
|
||||
Refreshing archives fills in any missing contents.
|
||||
"""
|
||||
"""Refreshing archives fills in any missing contents."""
|
||||
archive1 = archive_factory('example.com', 'https://example.com/one')
|
||||
archive2 = archive_factory('example.com', 'https://example.com/one')
|
||||
archive3 = archive_factory('example.org')
|
||||
|
|
@ -328,3 +273,153 @@ def test_get_title_from_html(test_session, fake_now):
|
|||
with mock.patch('modules.archive.lib.request_archive', fake_request_archive):
|
||||
archive = do_archive('example.com')
|
||||
assert archive.title is None
|
||||
|
||||
|
||||
def test_get_new_archive_files(fake_now):
|
||||
"""Archive files have a specific format so they are sorted by datetime, and are near each other."""
|
||||
fake_now(datetime(2001, 1, 1))
|
||||
s, r, t, j, c = map(str, get_new_archive_files('https://example.com/two', None))
|
||||
assert str(s).endswith('archive/example.com/2001-01-01-00-00-00_NA.html')
|
||||
assert str(r).endswith('archive/example.com/2001-01-01-00-00-00_NA-readability.html')
|
||||
assert str(t).endswith('archive/example.com/2001-01-01-00-00-00_NA-readability.txt')
|
||||
assert str(j).endswith('archive/example.com/2001-01-01-00-00-00_NA-readability.json')
|
||||
assert str(c).endswith('archive/example.com/2001-01-01-00-00-00_NA.png')
|
||||
|
||||
s, r, t, j, c = get_new_archive_files('https://www.example.com/one', 'Title')
|
||||
# Leading www. is removed.
|
||||
assert str(s).endswith('archive/example.com/2001-01-01-00-00-00_Title.html')
|
||||
assert str(r).endswith('archive/example.com/2001-01-01-00-00-00_Title-readability.html')
|
||||
assert str(t).endswith('archive/example.com/2001-01-01-00-00-00_Title-readability.txt')
|
||||
assert str(j).endswith('archive/example.com/2001-01-01-00-00-00_Title-readability.json')
|
||||
assert str(c).endswith('archive/example.com/2001-01-01-00-00-00_Title.png')
|
||||
|
||||
|
||||
def test_title_in_filename(test_session, fake_now, test_directory):
|
||||
"""
|
||||
The Archive files have the title in the path.
|
||||
"""
|
||||
fake_now(datetime(2000, 1, 1))
|
||||
|
||||
with mock.patch('modules.archive.lib.request_archive', make_fake_request_archive()):
|
||||
archive1 = do_archive('example.com')
|
||||
|
||||
assert archive1.title == 'ジにてこちら'
|
||||
|
||||
assert str(archive1.singlefile_path.path.relative_to(test_directory)) == \
|
||||
'archive/2000-01-01-00-00-00_ジにてこちら.html'
|
||||
assert str(archive1.readability_path.path.relative_to(test_directory)) == \
|
||||
'archive/2000-01-01-00-00-00_ジにてこちら-readability.html'
|
||||
assert str(archive1.readability_txt_path.path.relative_to(test_directory)) == \
|
||||
'archive/2000-01-01-00-00-00_ジにてこちら-readability.txt'
|
||||
assert str(archive1.readability_json_path.path.relative_to(test_directory)) == \
|
||||
'archive/2000-01-01-00-00-00_ジにてこちら-readability.json'
|
||||
assert str(archive1.screenshot_path.path.relative_to(test_directory)) == \
|
||||
'archive/2000-01-01-00-00-00_ジにてこちら.png'
|
||||
|
||||
def fake_request_archive(_):
|
||||
singlefile = '<html>\ntest single-file\nジにてこちら\n</html>' # no title in HTML
|
||||
r = dict(
|
||||
# No Title from Readability.
|
||||
content=f'<html>test readability content</html>',
|
||||
textContent='<html>test readability textContent</html>',
|
||||
)
|
||||
s = b'screenshot data'
|
||||
return singlefile, r, s
|
||||
|
||||
with mock.patch('modules.archive.lib.request_archive', fake_request_archive):
|
||||
archive2 = do_archive('example.com')
|
||||
|
||||
assert str(archive2.singlefile_path.path.relative_to(test_directory)) == \
|
||||
'archive/2000-01-01-00-00-00_NA.html'
|
||||
assert str(archive2.readability_path.path.relative_to(test_directory)) == \
|
||||
'archive/2000-01-01-00-00-00_NA-readability.html'
|
||||
assert str(archive2.readability_txt_path.path.relative_to(test_directory)) == \
|
||||
'archive/2000-01-01-00-00-00_NA-readability.txt'
|
||||
assert str(archive2.readability_json_path.path.relative_to(test_directory)) == \
|
||||
'archive/2000-01-01-00-00-00_NA-readability.json'
|
||||
assert str(archive2.screenshot_path.path.relative_to(test_directory)) == \
|
||||
'archive/2000-01-01-00-00-00_NA.png'
|
||||
|
||||
def fake_request_archive(_):
|
||||
singlefile = '<html>\ntest single-file\nジにてこちら\n<title>dangerous ;\\//_title</html></html>'
|
||||
r = dict(
|
||||
# No Title from Readability.
|
||||
content=f'<html>test readability content</html>',
|
||||
textContent='<html>test readability textContent</html>',
|
||||
)
|
||||
s = b'screenshot data'
|
||||
return singlefile, r, s
|
||||
|
||||
with mock.patch('modules.archive.lib.request_archive', fake_request_archive):
|
||||
archive3 = do_archive('example.com')
|
||||
|
||||
assert str(archive3.singlefile_path.path.relative_to(test_directory)) == \
|
||||
'archive/2000-01-01-00-00-00_dangerous ;_title.html'
|
||||
assert str(archive3.readability_path.path.relative_to(test_directory)) == \
|
||||
'archive/2000-01-01-00-00-00_dangerous ;_title-readability.html'
|
||||
assert str(archive3.readability_txt_path.path.relative_to(test_directory)) == \
|
||||
'archive/2000-01-01-00-00-00_dangerous ;_title-readability.txt'
|
||||
assert str(archive3.readability_json_path.path.relative_to(test_directory)) == \
|
||||
'archive/2000-01-01-00-00-00_dangerous ;_title-readability.json'
|
||||
assert str(archive3.screenshot_path.path.relative_to(test_directory)) == \
|
||||
'archive/2000-01-01-00-00-00_dangerous ;_title.png'
|
||||
|
||||
|
||||
def test_refresh_archives(test_session, test_directory):
|
||||
"""Archives can be found and put in the database. A single Archive will have multiple files."""
|
||||
archive_directory = get_archive_directory()
|
||||
|
||||
# Make some test files to refresh.
|
||||
example = archive_directory / 'example.com'
|
||||
example.mkdir()
|
||||
(example / '2021-10-05 16:20:10.346823.html').touch()
|
||||
(example / '2021-10-05 16:20:10.346823.png').touch()
|
||||
(example / '2021-10-05 16:20:10.346823-readability.txt').touch()
|
||||
(example / '2021-10-05 16:20:10.346823-readability.json').touch()
|
||||
(example / '2021-10-05 16:20:10.346823-readability.html').touch()
|
||||
with (example / '2021-10-05 16:20:10.346823-readability.json').open('wt') as fh:
|
||||
json.dump({'url': 'foo'}, fh)
|
||||
|
||||
# These should log an error because they are missing the singlefile path.
|
||||
(example / '2021-10-05 16:20:10-readability.html').touch()
|
||||
(example / '2021-10-05 16:20:11-readability.json').touch()
|
||||
(example / 'random file').touch()
|
||||
(example / '2021-10-05 16:20:10.346827-something.html').mkdir()
|
||||
(example / '2021-10-05 16:20:10 no extension').touch()
|
||||
|
||||
# This single archive is found.
|
||||
_refresh_archives()
|
||||
with get_db_session() as session:
|
||||
assert session.query(Archive).count() == 1
|
||||
|
||||
# Running the refresh does not result in a new archive.
|
||||
_refresh_archives()
|
||||
with get_db_session() as session:
|
||||
assert session.query(Archive).count() == 1
|
||||
|
||||
# Archives file format was changed, lets check the new formats are found.
|
||||
(example / '2021-10-05-16-20-10_The Title.html').touch()
|
||||
(example / '2021-10-05-16-20-10_The Title-readability.json').write_text(json.dumps({'url': 'bar'}))
|
||||
_refresh_archives()
|
||||
with get_db_session() as session:
|
||||
assert session.query(Archive).count() == 2
|
||||
|
||||
|
||||
def test_group_archive_files(test_directory):
|
||||
files = [
|
||||
pathlib.Path('2021-10-05 16:20:10.346823.html'),
|
||||
pathlib.Path('2021-10-05 16:20:10.346823-readability.json'),
|
||||
pathlib.Path('2000-01-01-00-00-00_Title.html'),
|
||||
pathlib.Path('2000-01-01-00-00-00_Title-readability.json'),
|
||||
]
|
||||
assert list(group_archive_files(files)) == [
|
||||
(local_timezone(datetime(2000, 1, 1, 0, 0, 0)),
|
||||
(
|
||||
pathlib.Path('2000-01-01-00-00-00_Title.html'), None, None,
|
||||
pathlib.Path('2000-01-01-00-00-00_Title-readability.json'), None,
|
||||
)),
|
||||
(local_timezone(datetime(2021, 10, 5, 16, 20, 10)),
|
||||
(
|
||||
pathlib.Path('2021-10-05 16:20:10.346823.html'), None, None,
|
||||
pathlib.Path('2021-10-05 16:20:10.346823-readability.json'), None)),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -646,3 +646,12 @@ def walk(path: Path) -> Generator[Path, None, None]:
|
|||
yield path
|
||||
if path.is_dir():
|
||||
yield from walk(path)
|
||||
|
||||
|
||||
# These characters are invalid in Windows or Linux.
|
||||
INVALID_FILE_CHARS = re.compile(r'[/<>:\|"\\\?\*]')
|
||||
|
||||
|
||||
def escape_file_name(name: str) -> str:
|
||||
"""Replace any invalid characters in a file name with "_"."""
|
||||
return INVALID_FILE_CHARS.sub('', name)
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@ from decimal import Decimal
|
|||
|
||||
import pytest
|
||||
|
||||
from wrolpi.common import combine_dicts, insert_parameter, date_range, api_param_limiter, chdir, zig_zag
|
||||
from wrolpi.common import combine_dicts, insert_parameter, date_range, api_param_limiter, chdir, zig_zag, \
|
||||
escape_file_name
|
||||
from wrolpi.dates import set_timezone, now
|
||||
from wrolpi.errors import InvalidTimezone
|
||||
from wrolpi.test.common import build_test_directories, wrap_media_directory
|
||||
|
|
@ -315,3 +316,23 @@ def test_zig_zag(low, high, expected):
|
|||
result = next(zagger)
|
||||
assert result == i
|
||||
assert low <= result < high
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'name,expected', [
|
||||
('', ''),
|
||||
('foo', 'foo'),
|
||||
('foo\\', 'foo'),
|
||||
('foo/', 'foo'),
|
||||
('foo<', 'foo'),
|
||||
('foo>', 'foo'),
|
||||
('foo:', 'foo'),
|
||||
('foo|', 'foo'),
|
||||
('foo"', 'foo'),
|
||||
('foo?', 'foo'),
|
||||
('foo*', 'foo'),
|
||||
('foo&', 'foo&'),
|
||||
]
|
||||
)
|
||||
def test_escape_file_name(name, expected):
|
||||
assert escape_file_name(name) == expected
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue