wrolpi/modules/archive/models.py
Roland 5615ce542d
Making example label a tag.
Tags can be created and deleted in UI.

Adding tag_file using id to avoid session error.

Using UTC in test.

Matching tag_names style in test.

Tag icon is displayed on file rows.

Archives, Videos and Files can be filtered by their tags.

Tags can be added and removed from UI.

Tags are displayed in VideoPlayer.

The tags of a file are returned in it's JSON.

Video favorites are converted to Favorite tag on channel config import.

Updating tests to match frontend usage.

PDF creator and modification time are displayed.

Reworking file search to use Video.__json__ for complex video relations.

Archives can be viewed.

Removing ebook.title.

Reworking alembic migration to be an upgrade rather than a replace.

Skipping test on circleci.

Clearing out video caption index only if extract_subtitles if off.

Channel tests pass.

Some video tests pass.

Missing videos can be found.  A video can be downloaded.

Orphaned files can be found.

Videos can be tagged.

Videos can be searched.

Videos can be modeled.

Converting some tests to use FileGroup.primary_path.

Adding archive and domains to alembic migration.

Testing cascading Archive titles.

Archives now uses FileGroup.

archive.lib tests pass.

Archive is partially converted to FileGroup.

Calculating FileGroup.primary file on refresh, rather than on select.

wrolpi tests pass.

Using FilesType to convert between str/pathlib.Path for FileGroup.files.

Using list of files in FileGroup.files instead of dictionary.

Storing files in dedicated FileGroup.files to avoid overwriting them.

Adding comment explaining CASE.

Not overwriting FileGroup.data on refresh if no change has been detected.

Removing File model in favor of FileGroup.data.

Adding typing to Files methods.

Files are indexed using the primary mimetype of the FileGroup.

files test_lib tests pass.

Removing file_group_file in favor of simple one-to-many relationship.

PDF files are indexed during refresh.

Adding Tag and TagFile to tag files with specific tags.

Only iterating when full_stem_ids has contents.

Testing that FileGroups are related to their Files.

Files and file_groups are created and linked.

Bump webpack from 5.75.0 to 5.76.1 in /app (#160)

Bumps [webpack](https://github.com/webpack/webpack) from 5.75.0 to 5.76.1.
- [Release notes](https://github.com/webpack/webpack/releases)
- [Commits](https://github.com/webpack/webpack/compare/v5.75.0...v5.76.1)

---
updated-dependencies:
- dependency-name: webpack
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-03-25 17:54:41 -06:00

279 lines
10 KiB
Python

import datetime
import json
import pathlib
import re
from typing import Iterable, List, Optional
import pytz
from sqlalchemy import Column, Integer, String, ForeignKey, BigInteger
from sqlalchemy.orm import relationship, Session, validates
from sqlalchemy.orm.collections import InstrumentedList
from wrolpi.common import ModelHelper, Base, logger
from wrolpi.dates import TZDateTime
from wrolpi.errors import InvalidArchive
from wrolpi.files.models import FileGroup
from wrolpi.media_path import MediaPathType
from wrolpi.vars import PYTEST
logger = logger.getChild(__name__)
__all__ = ['Archive', 'Domain']
MATCH_URL = re.compile(r'^\s+?url:\s+?(http.*)', re.MULTILINE)
MATCH_DATE = re.compile(r'^\s+?saved date:\s+?(.*)', re.MULTILINE)
class Archive(Base, ModelHelper):
__tablename__ = 'archive'
id = Column(Integer, primary_key=True)
archive_datetime = Column(TZDateTime)
url = Column(String)
domain_id = Column(Integer, ForeignKey('domains.id'))
domain = relationship('Domain', primaryjoin='Archive.domain_id==Domain.id')
file_group_id = Column(BigInteger, ForeignKey('file_group.id', ondelete='CASCADE'), unique=True, nullable=False)
file_group: FileGroup = relationship('FileGroup')
def __repr__(self):
if self.domain:
return f'<Archive id={self.id} url={self.url} singlefile={repr(str(self.singlefile_path))} ' \
f'domain={self.domain.domain}>'
return f'<Archive id={self.id} url={self.url} singlefile={repr(str(self.singlefile_path))}>'
def my_paths(self, *mimetypes: str) -> List[pathlib.Path]:
return self.file_group.my_paths(*mimetypes)
def my_files(self, *mimetypes: str) -> List[dict]:
return self.file_group.my_files(*mimetypes)
def unlink(self):
"""
Remove any files in this archive.
"""
# Don't fail if a file is missing, it could have been deleted manually.
for path in self.my_paths():
path.unlink(missing_ok=True)
def __gt__(self, other) -> bool:
"""
Compare Archives according to their archive datetime.
"""
if not isinstance(other, Archive):
raise ValueError(f'Cannot compare {type(other)} to Archive!')
return self.archive_datetime > other.archive_datetime
def delete(self):
self.unlink()
session = Session.object_session(self)
session.delete(self.file_group)
session.delete(self)
if self.domain:
domain_archives = [i.id for i in self.domain.archives]
if not domain_archives:
self.domain.delete()
@property
def history(self) -> Iterable[Base]:
"""Get a list of Archives that share my URL."""
session = Session.object_session(self)
history = list(session.query(Archive).filter(
Archive.id != self.id,
Archive.url == self.url,
Archive.url != None, # noqa
).order_by(Archive.archive_datetime))
return history
@property
def singlefile_file(self) -> Optional[dict]:
from modules.archive.lib import is_singlefile_file
files = self.file_group.my_files('text/html')
for file in files:
if is_singlefile_file(file['path']):
return file
@property
def singlefile_path(self) -> Optional[pathlib.Path]:
if singlefile_file := self.singlefile_file:
return singlefile_file['path']
@property
def readability_file(self) -> Optional[dict]:
files = self.file_group.my_files('text/html')
for file in files:
if file['path'].name.endswith('.readability.html'):
return file
@property
def readability_path(self) -> Optional[pathlib.Path]:
if readability_file := self.readability_file:
return readability_file['path']
@property
def readability_txt_file(self) -> Optional[dict]:
files = self.file_group.my_files('text/plain', 'text/html')
for file in files:
if file['path'].name.endswith('.readability.txt'):
return file
@property
def readability_txt_path(self) -> Optional[pathlib.Path]:
if readability_txt_file := self.readability_txt_file:
return readability_txt_file['path']
@property
def readability_json_file(self) -> Optional[dict]:
files = self.file_group.my_files()
for file in files:
if file['path'].name.endswith('.readability.json'):
return file
@property
def readability_json_path(self) -> Optional[pathlib.Path]:
if readability_json_file := self.readability_json_file:
return readability_json_file['path']
@property
def screenshot_file(self) -> Optional[dict]:
files = self.file_group.my_files('image/')
for file in files:
return file
@property
def screenshot_path(self) -> Optional[pathlib.Path]:
if screenshot_file := self.screenshot_file:
return screenshot_file['path']
def read_readability_data(self):
"""Read the Readability JSON file, apply its contents to this record."""
readability_json_path = self.readability_json_path
if not readability_json_path:
logger.warning(f'{self.singlefile_path} does not have an info json file')
return
if not readability_json_path.is_file():
error = f'Cannot read data from {readability_json_path} because it not exist.'
if PYTEST:
raise ValueError(error)
logger.error(error)
return
try:
with readability_json_path.open() as fh:
json_contents = json.load(fh)
url = json_contents.get('url')
title = json_contents.get('title')
except Exception as e:
raise InvalidArchive() from e
# Readability is most trusted, it should overwrite any previous data.
self.url = url or self.url
self.file_group.title = title or self.file_group.title
def read_singlefile_data(self):
"""Read the start of the singlefile (if any) and extract any Archive information."""
path = self.singlefile_path
if not path:
# Can't read contents of nothing.
raise ValueError('Cannot read singlefile data when this has no files')
if self.url and self.archive_datetime:
# This data has already been read.
return
with path.open('rt') as fh:
head = fh.read(1000)
if 'Page saved with SingleFile' not in head:
logger.error(f'Could not find SingleFile header in {self}')
return
if not self.url:
try:
if match := MATCH_URL.findall(head):
self.url = match[0].strip()
except Exception as e:
logger.error(f'Could not get URL from singlefile {path}', exc_info=e)
if not self.archive_datetime:
try:
if match := MATCH_DATE.findall(head):
dt = match[0].strip()
dt = ' '.join(dt.split(' ')[:5])
# SingleFile uses GMT.
dt = datetime.datetime.strptime(
dt,
'%a %b %d %Y %H:%M:%S' # Fri Jun 17 2022 19:24:52
).replace(tzinfo=pytz.timezone('GMT'))
self.archive_datetime = dt
except Exception as e:
logger.error(f'Could not get archive date from singlefile {path}', exc_info=e)
def apply_domain(self):
"""Get the domain from the URL."""
from modules.archive.lib import get_or_create_domain
domain = None
if self.url:
session = Session.object_session(self)
if not session:
raise ValueError('No session found!')
domain = get_or_create_domain(session, self.url)
# Clear domain if the URL is missing.
self.domain_id = domain.id if domain else None
def apply_singlefile_title(self):
"""Get the title from the Singlefile, if its missing."""
from modules.archive.lib import get_title_from_html
if self.singlefile_path and not self.file_group.title:
self.file_group.title = get_title_from_html(self.singlefile_path.read_text())
def validate(self):
"""Fill in any missing data about this Archive from it's files."""
try:
self.read_readability_data()
self.read_singlefile_data()
self.apply_domain()
self.apply_singlefile_title()
except Exception as e:
logger.warning(f'Unable to validate {self}', exc_info=e)
if PYTEST:
raise
@staticmethod
def from_paths(session: Session, *paths: pathlib.Path) -> 'Archive':
"""Create a new Archive and FileGroup from the provided paths.
The files will be read and Archive data extracted."""
from modules.archive import model_archive
file_group = FileGroup.from_paths(session, *paths)
archive = model_archive(file_group, session)
return archive
class Domain(Base, ModelHelper):
__tablename__ = 'domains' # plural to avoid conflict
id = Column(Integer, primary_key=True)
domain = Column(String, nullable=False)
directory = Column(MediaPathType)
archives: InstrumentedList = relationship('Archive', primaryjoin='Archive.domain_id==Domain.id')
def __repr__(self):
return f'<Domain id={self.id} domain={self.domain} directory={self.directory}>'
def delete(self):
session = Session.object_session(self)
session.execute('DELETE FROM archive WHERE domain_id=:id', dict(id=self.id))
session.query(Domain).filter_by(id=self.id).delete()
@validates('domain')
def validate_domain(self, key, value: str):
if not isinstance(value, str):
raise ValueError('Domain must be a string')
if len(value.split('.')) < 2:
raise ValueError(f'Domain must contain at least one "." domain={repr(value)}')
return value