Zim/Kiwix (Wikipedia) feature
* A new zim module has been added, this module allows several things to interact with Zim files. * Allow searching of Zim files using libzim. * Allow downloading of Kiwix projects (wikipedia, wikibooks, etc) on a schedule which replaces the outdated files. * Creation of a new zim docker container which allows viewing of the Zim file contents. * This commit also adds move/rename/mkdir features in the File Browser. * File tags are preserved when moving/renaming.
|
|
@ -1,17 +1,17 @@
|
|||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="Python tests in wrolpi" type="tests" factoryName="Autodetect" nameIsGenerated="true">
|
||||
<configuration default="false" name="Python WROLPi & Modules Tests" type="tests" factoryName="Autodetect">
|
||||
<module name="wrolpi" />
|
||||
<option name="INTERPRETER_OPTIONS" value="" />
|
||||
<option name="PARENT_ENVS" value="true" />
|
||||
<option name="SDK_HOME" value="" />
|
||||
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/wrolpi" />
|
||||
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
|
||||
<option name="IS_MODULE_SDK" value="true" />
|
||||
<option name="ADD_CONTENT_ROOTS" value="true" />
|
||||
<option name="ADD_SOURCE_ROOTS" value="true" />
|
||||
<EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" />
|
||||
<option name="_new_additionalArguments" value="""" />
|
||||
<option name="_new_target" value=""$PROJECT_DIR$/wrolpi"" />
|
||||
<option name="_new_targetType" value=""PATH"" />
|
||||
<option name="_new_additionalArguments" value=""wrolpi modules"" />
|
||||
<option name="_new_target" value="""" />
|
||||
<option name="_new_targetType" value=""CUSTOM"" />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
||||
20
50x.html
|
|
@ -37,12 +37,23 @@
|
|||
<h2>Can't start the services?</h2>
|
||||
<p>You can view the media directory by <a id="media_link" href="/media">navigating to /media</a></p>
|
||||
|
||||
<p>The API may be available <a id="api_link" href="#">by navigating to port 8081</a></p>
|
||||
|
||||
<p>The map may be available <a id="map_link" href="#">by navigating to port 8084</a></p>
|
||||
|
||||
<p>Kiwix may be available <a id="kiwix_link" href="#">by navigating to port 8085</a></p>
|
||||
|
||||
<script>
|
||||
// Get the current hostname (probably an IP) link to my :8084 above.
|
||||
// Get the current hostname (probably an IP) link to my other ports above.
|
||||
let hostname = window.location.hostname;
|
||||
|
||||
let api_a = document.getElementById('map_link');
|
||||
if (api_a) {
|
||||
let api_url = `http://${hostname}:8081`;
|
||||
api_a.setAttribute('href', api_url);
|
||||
api_a.textContent = `by navigating to ${api_url}`;
|
||||
}
|
||||
|
||||
let media_a = document.getElementById('media_link');
|
||||
if (media_a) {
|
||||
let media_url = `http://${hostname}/media`;
|
||||
|
|
@ -56,6 +67,13 @@
|
|||
map_a.setAttribute('href', map_url);
|
||||
map_a.textContent = `by navigating to ${map_url}`;
|
||||
}
|
||||
|
||||
let kiwix_a = document.getElementById('map_link');
|
||||
if (kiwix_a) {
|
||||
let kiwix_url = `http://${hostname}:8085`;
|
||||
kiwix_a.setAttribute('href', kiwix_url);
|
||||
kiwix_a.textContent = `by navigating to ${kiwix_url}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -122,6 +122,12 @@ Install WROLPi onto a fresh Raspberry Pi.
|
|||
|
||||
# Charter
|
||||
|
||||
## Guiding Principals
|
||||
|
||||
1. Storage is cheaper than power.
|
||||
2. Two is one, one is none.
|
||||
3. In WROL mode, run silently.
|
||||
|
||||
## Physical properties
|
||||
|
||||
1. A WROLPi instance should be capable of running with a minimal of hardware:
|
||||
|
|
@ -129,7 +135,7 @@ Install WROLPi onto a fresh Raspberry Pi.
|
|||
* SD card
|
||||
* External USB hard drive
|
||||
* Power supply and cables
|
||||
* WiFi USER device such as a phone, tablet, or laptop.
|
||||
* Wi-Fi USER device such as a phone, tablet, or laptop.
|
||||
2. A WROLPi instance should consume a minimal amount of electricity during WROL event. It is expected power will be
|
||||
scarce when WROLPi is needed most.
|
||||
|
||||
|
|
|
|||
|
|
@ -76,9 +76,12 @@ def upgrade():
|
|||
) STORED
|
||||
)''')
|
||||
|
||||
session.execute('ALTER TABLE video ADD COLUMN file_group_id BIGINT REFERENCES file_group(id) ON DELETE CASCADE NOT NULL UNIQUE')
|
||||
session.execute('ALTER TABLE ebook ADD COLUMN file_group_id BIGINT REFERENCES file_group(id) ON DELETE CASCADE NOT NULL UNIQUE')
|
||||
session.execute('ALTER TABLE archive ADD COLUMN file_group_id BIGINT REFERENCES file_group(id) ON DELETE CASCADE NOT NULL UNIQUE')
|
||||
session.execute(
|
||||
'ALTER TABLE video ADD COLUMN file_group_id BIGINT REFERENCES file_group(id) ON DELETE CASCADE NOT NULL UNIQUE')
|
||||
session.execute(
|
||||
'ALTER TABLE ebook ADD COLUMN file_group_id BIGINT REFERENCES file_group(id) ON DELETE CASCADE NOT NULL UNIQUE')
|
||||
session.execute(
|
||||
'ALTER TABLE archive ADD COLUMN file_group_id BIGINT REFERENCES file_group(id) ON DELETE CASCADE NOT NULL UNIQUE')
|
||||
|
||||
session.execute('''
|
||||
CREATE TABLE tag (
|
||||
|
|
|
|||
67
alembic/versions/751aecf46b88_zim.py
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
"""Zim
|
||||
|
||||
Revision ID: 751aecf46b88
|
||||
Revises: 75d34a2b442d
|
||||
Create Date: 2023-06-04 19:48:41.305111
|
||||
|
||||
"""
|
||||
import os
|
||||
|
||||
from alembic import op
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '751aecf46b88'
|
||||
down_revision = '75d34a2b442d'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
DOCKERIZED = True if os.environ.get('DOCKER', '').lower().startswith('t') else False
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
session = Session(bind=bind)
|
||||
|
||||
session.execute('''
|
||||
CREATE TABLE zim (
|
||||
id SERIAL PRIMARY KEY,
|
||||
path TEXT NOT NULL UNIQUE,
|
||||
file_group_id BIGINT REFERENCES file_group(id) ON DELETE CASCADE NOT NULL
|
||||
)''')
|
||||
|
||||
session.execute('''
|
||||
CREATE TABLE tag_zim (
|
||||
tag_id INTEGER REFERENCES tag(id),
|
||||
zim_id INTEGER REFERENCES zim(id) ON DELETE CASCADE,
|
||||
zim_entry TEXT NOT NULL,
|
||||
created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (tag_id, zim_id, zim_entry)
|
||||
)''')
|
||||
|
||||
session.execute('''
|
||||
CREATE TABLE zim_subscription (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
language TEXT NOT NULL,
|
||||
download_id INTEGER NOT NULL REFERENCES download(id) ON DELETE CASCADE
|
||||
)
|
||||
''')
|
||||
|
||||
# We want to index all Zim files using `zim_modeler`
|
||||
session.execute("UPDATE file_group SET indexed=false WHERE primary_path ILIKE '%.zim'")
|
||||
|
||||
if not DOCKERIZED:
|
||||
session.execute('ALTER TABLE public.zim OWNER TO wrolpi')
|
||||
session.execute('ALTER TABLE public.tag_zim OWNER TO wrolpi')
|
||||
session.execute('ALTER TABLE public.zim_subscription OWNER TO wrolpi')
|
||||
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
session = Session(bind=bind)
|
||||
|
||||
session.execute('DROP TABLE IF EXISTS zim_subscription')
|
||||
session.execute('DROP TABLE IF EXISTS tag_zim')
|
||||
session.execute('DROP TABLE IF EXISTS zim')
|
||||
session.execute("UPDATE file_group SET indexed=false WHERE primary_path ILIKE '%.zim'")
|
||||
|
|
@ -6,10 +6,10 @@ Create Date: 2021-12-27 16:42:32.099796
|
|||
|
||||
"""
|
||||
import os
|
||||
|
||||
from alembic import op
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '84b6993a6091'
|
||||
down_revision = '8df411232c17'
|
||||
|
|
|
|||
|
|
@ -6,10 +6,10 @@ Create Date: 2022-06-22 17:34:33.635528
|
|||
|
||||
"""
|
||||
import os
|
||||
|
||||
from alembic import op
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'aac864072193'
|
||||
down_revision = '467235201c70'
|
||||
|
|
|
|||
|
|
@ -6,10 +6,10 @@ Create Date: 2022-01-25 08:00:26.944183
|
|||
|
||||
"""
|
||||
import os
|
||||
|
||||
from alembic import op
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'b97c1313f986'
|
||||
down_revision = '3c33b1dae069'
|
||||
|
|
|
|||
33
alembic/versions/bf704baa185d_zim_flag.py
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
"""Zim flag
|
||||
|
||||
Revision ID: bf704baa185d
|
||||
Revises: 751aecf46b88
|
||||
Create Date: 2023-07-01 20:00:35.601752
|
||||
|
||||
"""
|
||||
import os
|
||||
|
||||
from alembic import op
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'bf704baa185d'
|
||||
down_revision = '751aecf46b88'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
DOCKERIZED = True if os.environ.get('DOCKER', '').lower().startswith('t') else False
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
session = Session(bind=bind)
|
||||
|
||||
session.execute('ALTER TABLE wrolpi_flag ADD COLUMN outdated_zims BOOLEAN DEFAULT FALSE')
|
||||
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
session = Session(bind=bind)
|
||||
|
||||
session.execute('ALTER TABLE wrolpi_flag DROP COLUMN outdated_zims')
|
||||
|
|
@ -6,10 +6,10 @@ Create Date: 2022-09-28 15:14:43.306819
|
|||
|
||||
"""
|
||||
import os
|
||||
|
||||
from alembic import op
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'd2eace02f5b9'
|
||||
down_revision = '70db5ba8763d'
|
||||
|
|
|
|||
|
|
@ -6,10 +6,10 @@ Create Date: 2022-01-10 08:27:39.082067
|
|||
|
||||
"""
|
||||
import os
|
||||
|
||||
from alembic import op
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'f2c6326cad29'
|
||||
down_revision = 'f8c65e7e6802'
|
||||
|
|
|
|||
|
|
@ -6,10 +6,10 @@ Create Date: 2022-01-07 14:28:21.787842
|
|||
|
||||
"""
|
||||
import os
|
||||
|
||||
from alembic import op
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'f8c65e7e6802'
|
||||
down_revision = '62acddc55091'
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ Create Date: 2023-04-18 16:58:40.043115
|
|||
|
||||
"""
|
||||
import os
|
||||
|
||||
from alembic import op
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
|
|
|||
BIN
app/public/android-chrome-192x192.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
app/public/android-chrome-512x512.png
Normal file
|
After Width: | Height: | Size: 101 KiB |
BIN
app/public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
|
|
@ -30,8 +30,8 @@ either expressed or implied, of the FreeBSD Project. -->
|
|||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"/>
|
||||
<script src="/jszip.min.js"></script>
|
||||
<script src="/epub.js"></script>
|
||||
<script src="/epub/jszip.min.js"></script>
|
||||
<script src="/epub/epub.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
BIN
app/public/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
app/public/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 127 KiB After Width: | Height: | Size: 12 KiB |
|
|
@ -2,14 +2,17 @@
|
|||
<html lang="en" style="height: 100%">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"/>
|
||||
<meta name="theme-color" content="#000000"/>
|
||||
<meta
|
||||
name="description"
|
||||
content="The WROLPI webapp"
|
||||
/>
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png"/>
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico"/>
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="%PUBLIC_URL%/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="%PUBLIC_URL%/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="%PUBLIC_URL%/favicon-16x16.png">
|
||||
<link rel="mask-icon" href="%PUBLIC_URL%/safari-pinned-tab.svg" color="#5bbad5">
|
||||
<!--
|
||||
manifest.json provides metadata used when your web app is installed on a
|
||||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||
|
|
|
|||
|
|
@ -3,19 +3,14 @@
|
|||
"name": "WROLPi App",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
"src": "/android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "logo192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "191x191"
|
||||
},
|
||||
{
|
||||
"src": "logo512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x509"
|
||||
"src": "/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
|
|
|
|||
BIN
app/public/mstile-150x150.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
|
|
@ -20,6 +20,7 @@ import {useEventsInterval} from "./Events";
|
|||
import {SemanticToastContainer} from "react-semantic-toasts-2";
|
||||
import {FilePreviewProvider} from "./components/FilePreview";
|
||||
import {TagsProvider} from "./Tags";
|
||||
import {ZimRoute} from "./components/Zim";
|
||||
|
||||
function PageNotFound() {
|
||||
const {t} = useContext(ThemeContext);
|
||||
|
|
@ -63,6 +64,7 @@ const router = createBrowserRouter(createRoutesFromElements(<Route
|
|||
errorElement={<PageNotFound/>}
|
||||
>
|
||||
<Route index element={<Dashboard/>}/>
|
||||
<Route path='search/*' element={<Dashboard/>}/>
|
||||
<Route path='donate' element={<Donate/>}/>
|
||||
<Route path='videos/video/:videoId' exact element={<VideoWrapper/>}/>
|
||||
<Route path='videos/channel/:channelId/video/:videoId' exact element={<VideoWrapper/>}/>
|
||||
|
|
@ -72,6 +74,7 @@ const router = createBrowserRouter(createRoutesFromElements(<Route
|
|||
<Route path="inventory/*" element={<InventoryRoute/>}/>
|
||||
<Route path='archive/*' element={<ArchiveRoute/>}/>
|
||||
<Route path='map/*' element={<MapRoute/>}/>
|
||||
<Route path='zim/*' element={<ZimRoute/>}/>
|
||||
<Route path='files/*' element={<FilesRoute/>}/>
|
||||
</Route>));
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
import {LoadStatistic, PageContainer, SearchInput, useTitle} from "./components/Common";
|
||||
import {useQuery} from "./hooks/customHooks";
|
||||
import React, {useContext, useState} from "react";
|
||||
import {StatusContext} from "./contexts/contexts";
|
||||
import {DownloadMenu} from "./components/Download";
|
||||
import {FilesSearchView} from "./components/Files";
|
||||
import {
|
||||
Button,
|
||||
Divider,
|
||||
|
|
@ -15,7 +13,7 @@ import {
|
|||
Statistic,
|
||||
StatisticGroup
|
||||
} from "./components/Theme";
|
||||
import {Link} from "react-router-dom";
|
||||
import {Link, useNavigate} from "react-router-dom";
|
||||
import {BandwidthProgressCombined, CPUUsageProgress} from "./components/admin/Status";
|
||||
import {ProgressPlaceholder} from "./components/Placeholder";
|
||||
import {GridColumn, GridRow, Icon, Message} from "semantic-ui-react";
|
||||
|
|
@ -24,8 +22,13 @@ import {refreshFiles} from "./api";
|
|||
import _ from "lodash";
|
||||
import {TagsDashboard} from "./Tags";
|
||||
import {Upload} from "./components/Upload";
|
||||
import {SearchView, useSearch} from "./components/Search";
|
||||
import {KiwixRestartMessage, OutdatedZimsMessage} from "./components/Zim";
|
||||
import {useSettings} from "./hooks/customHooks";
|
||||
|
||||
function FlagsMessages({flags}) {
|
||||
const {settings, fetchSettings} = useSettings();
|
||||
|
||||
if (!flags) {
|
||||
return <></>
|
||||
}
|
||||
|
|
@ -33,6 +36,7 @@ function FlagsMessages({flags}) {
|
|||
let refreshing;
|
||||
let refreshRequired;
|
||||
let dbDown;
|
||||
let kiwixRestart;
|
||||
|
||||
// Do not tell the maintainer to refresh the files if they are already refreshing.
|
||||
if (flags.indexOf('refreshing') >= 0) {
|
||||
|
|
@ -55,6 +59,10 @@ function FlagsMessages({flags}) {
|
|||
</Message>;
|
||||
}
|
||||
|
||||
if (flags.indexOf('kiwix_restart') >= 0) {
|
||||
kiwixRestart = <KiwixRestartMessage/>;
|
||||
}
|
||||
|
||||
if (flags.indexOf('db_up') === -1) {
|
||||
dbDown = <Message icon error>
|
||||
<Icon name='exclamation'/>
|
||||
|
|
@ -68,20 +76,18 @@ function FlagsMessages({flags}) {
|
|||
return <>
|
||||
{refreshing}
|
||||
{dbDown || refreshRequired}
|
||||
{settings && settings['ignore_outdated_zims'] === false && flags.indexOf('outdated_zims') >= 0 ?
|
||||
<OutdatedZimsMessage onClick={fetchSettings}/> : null}
|
||||
{kiwixRestart}
|
||||
</>
|
||||
}
|
||||
|
||||
export function Dashboard() {
|
||||
useTitle('Dashboard');
|
||||
|
||||
const {searchParams, updateQuery} = useQuery();
|
||||
const searchStr = searchParams.get('q');
|
||||
const activeTags = searchParams.getAll('tag');
|
||||
const setSearchStr = (value) => {
|
||||
updateQuery({q: value, o: null});
|
||||
}
|
||||
|
||||
const {searchStr, setSearchStr, activeTags} = useSearch();
|
||||
const {status} = useContext(StatusContext);
|
||||
const navigate = useNavigate();
|
||||
const wrol_mode = status ? status['wrol_mode'] : null;
|
||||
|
||||
// Getters are Downloads or Uploads.
|
||||
|
|
@ -141,7 +147,7 @@ export function Dashboard() {
|
|||
// Only show dashboard parts if not searching.
|
||||
let body;
|
||||
if (searchStr || (activeTags && activeTags.length > 0)) {
|
||||
body = <FilesSearchView/>;
|
||||
body = <SearchView/>;
|
||||
} else {
|
||||
body = <>
|
||||
{!wrol_mode && getter}
|
||||
|
|
@ -161,6 +167,7 @@ export function Dashboard() {
|
|||
size='large'
|
||||
placeholder='Search everywhere...'
|
||||
actionIcon='search'
|
||||
onClear={() => navigate('/')}
|
||||
style={{marginBottom: '2em'}}
|
||||
/>
|
||||
</Grid.Column>
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@ export function useTags() {
|
|||
}
|
||||
|
||||
const TagLabelLink = ({name, props}) => {
|
||||
const to = `/?tag=${name}`;
|
||||
const to = `/search?tag=${name}`;
|
||||
const style = {marginLeft: '0.3em', marginRight: '0.3em'};
|
||||
try {
|
||||
// We prefer to use Link to avoid reloading the page, check if React Router is available, so we can use it.
|
||||
|
|
@ -440,40 +440,6 @@ export const TagsDashboard = () => {
|
|||
</Segment>
|
||||
}
|
||||
|
||||
export const TagsDropdown = ({value = [], onChange, ...props}) => {
|
||||
const {tagNames} = React.useContext(TagsContext);
|
||||
const [activeTags, setActiveTags] = React.useState(value);
|
||||
|
||||
const handleChange = (e, {value}) => {
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
}
|
||||
if (!value) {
|
||||
setActiveTags([]);
|
||||
onChange([]);
|
||||
} else {
|
||||
setActiveTags(value);
|
||||
onChange(value);
|
||||
}
|
||||
}
|
||||
|
||||
let options = [{key: null, text: '', value: ''}];
|
||||
if (tagNames && tagNames.length > 0) {
|
||||
const tagOptions = tagNames.map(i => {
|
||||
return {key: i, text: i, value: i}
|
||||
})
|
||||
options = [...options, ...tagOptions];
|
||||
}
|
||||
|
||||
return <Dropdown multiple selection clearable fluid
|
||||
options={options}
|
||||
placeholder='Tags'
|
||||
onChange={handleChange}
|
||||
value={activeTags}
|
||||
{...props}
|
||||
/>
|
||||
}
|
||||
|
||||
export const TagsProvider = (props) => {
|
||||
const tagsValue = useTagsInterval();
|
||||
|
||||
|
|
|
|||
219
app/src/api.js
|
|
@ -1,4 +1,4 @@
|
|||
import {API_URI, ARCHIVES_API, DEFAULT_LIMIT, emptyToNull, OTP_API, VIDEOS_API} from "./components/Common";
|
||||
import {API_URI, ARCHIVES_API, DEFAULT_LIMIT, emptyToNull, OTP_API, VIDEOS_API, ZIM_API} from "./components/Common";
|
||||
import {toast} from "react-semantic-toasts-2";
|
||||
|
||||
function timeoutPromise(ms, promise) {
|
||||
|
|
@ -146,6 +146,15 @@ export async function getVideo(video_id) {
|
|||
}
|
||||
|
||||
export async function deleteVideos(videoIds) {
|
||||
if (!videoIds || videoIds.length === 0) {
|
||||
toast({
|
||||
type: 'error',
|
||||
title: 'Empty request',
|
||||
description: 'Unable to delete Videos because no IDs were passed.',
|
||||
time: 5000,
|
||||
});
|
||||
}
|
||||
console.debug(`Deleting Videos: ${videoIds}`);
|
||||
const i = videoIds.join(',');
|
||||
let response = await apiDelete(`${VIDEOS_API}/video/${i}`);
|
||||
if (response.status !== 204) {
|
||||
|
|
@ -342,6 +351,15 @@ export async function deleteItems(itemIds) {
|
|||
}
|
||||
|
||||
export async function deleteArchives(archiveIds) {
|
||||
if (!archiveIds || archiveIds.length === 0) {
|
||||
toast({
|
||||
type: 'error',
|
||||
title: 'Empty request',
|
||||
description: 'Unable to delete Archives because no IDs were passed.',
|
||||
time: 5000,
|
||||
});
|
||||
}
|
||||
console.debug(`Deleting Archives: ${archiveIds}`);
|
||||
let i = archiveIds.join(',');
|
||||
try {
|
||||
return await apiDelete(`${API_URI}/archive/${i}`);
|
||||
|
|
@ -521,8 +539,8 @@ export async function refreshFiles() {
|
|||
return await apiPost(`${API_URI}/files/refresh`);
|
||||
}
|
||||
|
||||
export async function refreshDirectoryFiles(directory) {
|
||||
let body = {paths: [directory]};
|
||||
export async function refreshDirectoryFiles(paths) {
|
||||
let body = {paths: paths};
|
||||
try {
|
||||
return await apiPost(`${API_URI}/files/refresh`, body);
|
||||
} catch (e) {
|
||||
|
|
@ -536,6 +554,21 @@ export async function refreshDirectoryFiles(directory) {
|
|||
}
|
||||
}
|
||||
|
||||
export async function makeDirectory(path) {
|
||||
const body = {path: path};
|
||||
try {
|
||||
await apiPost(`${API_URI}/files/directory`, body);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
toast({
|
||||
type: 'error',
|
||||
title: 'Unable to create directory',
|
||||
description: 'Failed to create directory. See server logs.',
|
||||
time: 5000,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function getFiles(directories) {
|
||||
console.debug(`getFiles ${directories}`);
|
||||
let body = {directories: directories || []};
|
||||
|
|
@ -544,9 +577,18 @@ export async function getFiles(directories) {
|
|||
return files;
|
||||
}
|
||||
|
||||
export async function deleteFile(file) {
|
||||
let body = {file: file};
|
||||
await apiPost(`${API_URI}/files/delete`, body);
|
||||
export async function deleteFile(paths) {
|
||||
let body = {paths: paths};
|
||||
const response = await apiPost(`${API_URI}/files/delete`, body);
|
||||
if (response.status === 409) {
|
||||
const content = await response.json();
|
||||
toast({
|
||||
type: 'error',
|
||||
title: 'Delete error',
|
||||
description: content['message'],
|
||||
time: 5000,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchFilesProgress() {
|
||||
|
|
@ -702,7 +744,7 @@ export async function getTags() {
|
|||
}
|
||||
}
|
||||
|
||||
export async function addTag(fileGroup, name) {
|
||||
export async function tagFileGroup(fileGroup, name) {
|
||||
const body = {tag_name: name};
|
||||
|
||||
const {id, primary_path, path} = fileGroup;
|
||||
|
|
@ -721,7 +763,7 @@ export async function addTag(fileGroup, name) {
|
|||
}
|
||||
}
|
||||
|
||||
export async function removeTag(fileGroup, name) {
|
||||
export async function untagFileGroup(fileGroup, name) {
|
||||
const body = {tag_name: name};
|
||||
|
||||
const {id, primary_path, path} = fileGroup;
|
||||
|
|
@ -817,13 +859,118 @@ export async function searchDirectories(name) {
|
|||
}
|
||||
}
|
||||
|
||||
export async function uploadFiles(files, destination) {
|
||||
const data = new FormData();
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
data.append('file', files[i]);
|
||||
export async function renamePath(path, newName) {
|
||||
const body = {path, new_name: newName};
|
||||
const response = await apiPost(`${API_URI}/files/rename`, body);
|
||||
if (response.status !== 204) {
|
||||
toast({type: 'error', title: 'Error', description: 'Failed to rename!', time: 5000});
|
||||
}
|
||||
data.append('destination', destination);
|
||||
console.log(data);
|
||||
}
|
||||
|
||||
export async function movePaths(destination, paths) {
|
||||
const body = {destination, paths};
|
||||
const response = await apiPost(`${API_URI}/files/move`, body);
|
||||
if (response.status !== 204) {
|
||||
const content = await response.json();
|
||||
toast({type: 'error', title: 'Error', description: content['api_error'], time: 5000});
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchZims() {
|
||||
const response = await apiGet(`${API_URI}/zim`);
|
||||
if (response.status === 200) {
|
||||
const content = await response.json();
|
||||
return {
|
||||
zims: content['zims'],
|
||||
}
|
||||
} else {
|
||||
toast({type: 'error', title: 'Error', description: 'Cannot fetch Zims', time: 5000});
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchZimSubscriptions() {
|
||||
const response = await apiGet(`${API_URI}/zim/subscribe`);
|
||||
if (response.status === 200) {
|
||||
const content = await response.json();
|
||||
return {
|
||||
subscriptions: content['subscriptions'],
|
||||
catalog: content['catalog'],
|
||||
iso_639_codes: content['iso_639_codes'],
|
||||
}
|
||||
} else {
|
||||
toast({type: 'error', title: 'Error', description: 'Cannot fetch Zim Subscriptions', time: 5000});
|
||||
}
|
||||
}
|
||||
|
||||
export async function searchZims(offset, limit, searchStr) {
|
||||
offset = parseInt(offset || 0);
|
||||
limit = parseInt(limit || DEFAULT_LIMIT);
|
||||
let body = {offset, limit};
|
||||
if (searchStr) {
|
||||
body['search_str'] = searchStr;
|
||||
}
|
||||
|
||||
console.debug('searching zims', body);
|
||||
let response = await apiPost(`${ZIM_API}/search`, body);
|
||||
if (response.status === 200) {
|
||||
let data = await response.json();
|
||||
return [data['zims'],];
|
||||
} else {
|
||||
toast({
|
||||
type: 'error',
|
||||
title: 'Unable to search zims',
|
||||
description: 'Cannot search zims. See server logs.',
|
||||
time: 5000,
|
||||
});
|
||||
return [[], 0];
|
||||
}
|
||||
}
|
||||
|
||||
export async function searchZim(offset, limit, searchStr, zimId, activeTags) {
|
||||
offset = parseInt(offset || 0);
|
||||
limit = parseInt(limit || DEFAULT_LIMIT);
|
||||
let body = {offset, limit, search_str: searchStr, tag_names: activeTags || []};
|
||||
|
||||
console.debug(`Searching Zim ${zimId} for: ${searchStr} tags: ${activeTags}`);
|
||||
let response = await apiPost(`${ZIM_API}/search/${zimId}`, body);
|
||||
if (response.status === 200) {
|
||||
const content = await response.json();
|
||||
const zim = content['zim'];
|
||||
const searchLength = zim['search'].length;
|
||||
console.debug(`Got ${searchLength} results for Zim ${zimId}`);
|
||||
return content['zim'];
|
||||
}
|
||||
}
|
||||
|
||||
export async function tagZimEntry(zim_id, zim_entry, name) {
|
||||
const body = {tag_name: name, zim_id: zim_id, zim_entry: zim_entry};
|
||||
|
||||
const uri = `${API_URI}/zim/tag`;
|
||||
let response = await apiPost(uri, body);
|
||||
if (response.status !== 201) {
|
||||
console.error('Failed to add tag');
|
||||
}
|
||||
}
|
||||
|
||||
export async function untagZimEntry(zim_id, zim_entry, name) {
|
||||
const body = {tag_name: name, zim_id: zim_id, zim_entry: zim_entry};
|
||||
|
||||
const uri = `${API_URI}/zim/untag`;
|
||||
let response = await apiPost(uri, body);
|
||||
if (response.status !== 201) {
|
||||
console.error('Failed to add tag');
|
||||
}
|
||||
}
|
||||
|
||||
export async function zimSubscribe(name, language) {
|
||||
const body = {name, language};
|
||||
const response = await apiPost(`${API_URI}/zim/subscribe`, body);
|
||||
return response.status === 201;
|
||||
}
|
||||
|
||||
export async function zimUnsubscribe(id) {
|
||||
const response = await apiDelete(`${API_URI}/zim/subscribe/${id}`);
|
||||
return response.status === 204;
|
||||
}
|
||||
|
||||
export async function fetchDecoded(vinNumber) {
|
||||
|
|
@ -837,3 +984,47 @@ export async function fetchDecoded(vinNumber) {
|
|||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function searchEstimate(search_str, tagNames) {
|
||||
const body = {search_str: search_str, tag_names: tagNames};
|
||||
const response = await apiPost(`${API_URI}/search_estimate`, body);
|
||||
if (response.status === 200) {
|
||||
const content = await response.json();
|
||||
const zims = content['zims'];
|
||||
let zimSum = 0;
|
||||
zims.map(i => zimSum += i['estimate']);
|
||||
return {
|
||||
file_groups: content['file_groups'],
|
||||
zims: zims,
|
||||
zimSum: zimSum,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function getOutdatedZims() {
|
||||
const response = await apiGet(`${API_URI}/zim/outdated`);
|
||||
if (response.status === 200) {
|
||||
const content = await response.json();
|
||||
return {
|
||||
outdated: content['outdated'],
|
||||
current: content['current'],
|
||||
}
|
||||
} else {
|
||||
toast({
|
||||
type: 'error',
|
||||
title: 'Unable to fetch',
|
||||
description: 'Cannot fetch outdated Zims. See server logs.',
|
||||
time: 5000,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteOutdatedZims() {
|
||||
const response = await apiDelete(`${API_URI}/zim/outdated`);
|
||||
return response.status === 204;
|
||||
}
|
||||
|
||||
export async function ignoreOutdatedZims() {
|
||||
const response = await apiPost(`${API_URI}/zim/outdated`);
|
||||
return response.status === 204;
|
||||
}
|
||||
|
|
@ -34,7 +34,7 @@ import {
|
|||
textEllipsis,
|
||||
useTitle
|
||||
} from "./Common";
|
||||
import {addTag, deleteArchives, postDownload, removeTag} from "../api";
|
||||
import {deleteArchives, postDownload, tagFileGroup, untagFileGroup} from "../api";
|
||||
import {Link, Route, Routes, useNavigate, useParams} from "react-router-dom";
|
||||
import Message from "semantic-ui-react/dist/commonjs/collections/Message";
|
||||
import {useArchive, useDomains, useSearchArchives, useSearchDomain} from "../hooks/customHooks";
|
||||
|
|
@ -146,12 +146,12 @@ function ArchivePage() {
|
|||
}
|
||||
|
||||
const localAddTag = async (name) => {
|
||||
await addTag(archiveFile, name);
|
||||
await tagFileGroup(archiveFile, name);
|
||||
await fetchArchive();
|
||||
}
|
||||
|
||||
const localRemoveTag = async (name) => {
|
||||
await removeTag(archiveFile, name);
|
||||
await untagFileGroup(archiveFile, name);
|
||||
await fetchArchive();
|
||||
}
|
||||
|
||||
|
|
@ -359,7 +359,8 @@ function Archives() {
|
|||
const onDelete = async (e) => {
|
||||
e.preventDefault();
|
||||
setDeleteOpen(false);
|
||||
const archiveIds = archives.filter(i => selectedArchives.indexOf(i['path']) >= 0).map(i => i['archive']['id']);
|
||||
const archiveIds = archives.filter(i => selectedArchives.indexOf(i['primary_path']) >= 0)
|
||||
.map(i => i['archive']['id']);
|
||||
await deleteArchives(archiveIds);
|
||||
await fetchArchives();
|
||||
setSelectedArchives([]);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React, {useContext, useEffect, useState} from "react";
|
||||
import {Button as SButton, ButtonGroup, Card, Container, IconGroup, Input, Pagination, Search} from 'semantic-ui-react';
|
||||
import {Link, NavLink, useNavigate} from "react-router-dom";
|
||||
import {ButtonGroup, Card, Container, IconGroup, Input, Label, Pagination, Search} from 'semantic-ui-react';
|
||||
import {Link, NavLink, useNavigate, useSearchParams} from "react-router-dom";
|
||||
import Message from "semantic-ui-react/dist/commonjs/collections/Message";
|
||||
import {
|
||||
useDirectories,
|
||||
|
|
@ -23,7 +23,8 @@ import {
|
|||
Modal,
|
||||
ModalActions,
|
||||
ModalContent,
|
||||
ModalDescription, ModalHeader,
|
||||
ModalDescription,
|
||||
ModalHeader,
|
||||
Popup,
|
||||
Statistic
|
||||
} from "./Theme";
|
||||
|
|
@ -36,6 +37,7 @@ export const API_URI = process.env && process.env.REACT_APP_API_URI ? process.en
|
|||
export const VIDEOS_API = `${API_URI}/videos`;
|
||||
export const ARCHIVES_API = `${API_URI}/archive`;
|
||||
export const OTP_API = `${API_URI}/otp`;
|
||||
export const ZIM_API = `${API_URI}/zim`;
|
||||
export const DEFAULT_LIMIT = 20;
|
||||
export const NAME = process.env && process.env.REACT_APP_NAME ? process.env.REACT_APP_NAME : null;
|
||||
|
||||
|
|
@ -215,7 +217,7 @@ export const rssFrequencyOptions = [{key: 'once', text: 'Once', value: 0}, {
|
|||
key: 'daily', text: 'Daily', value: 86400
|
||||
}, {key: 'weekly', text: 'Weekly', value: 604800}, {key: 'biweekly', text: 'Biweekly', value: 1209600}, {
|
||||
key: '30days', text: '30 Days', value: 2592000
|
||||
}, {key: '90days', text: '90 Days', value: 7776000},];
|
||||
}, {key: '90days', text: '90 Days', value: 7776000}, {key: '180days', text: '180 Days', value: 15552000}];
|
||||
|
||||
export function secondsToFrequency(seconds) {
|
||||
for (let i = 0; i < Object.keys(rssFrequencyOptions).length; i++) {
|
||||
|
|
@ -375,6 +377,7 @@ export function SearchInput({
|
|||
actionIcon,
|
||||
clearable,
|
||||
autoFocus = false,
|
||||
onClear = null,
|
||||
...props
|
||||
}) {
|
||||
let [value, setValue] = useState(searchStr || '');
|
||||
|
|
@ -384,6 +387,9 @@ export function SearchInput({
|
|||
// Clear the input when the "clear" button is clicked, search again.
|
||||
setValue('');
|
||||
onSubmit('');
|
||||
if (onClear) {
|
||||
onClear();
|
||||
}
|
||||
}
|
||||
|
||||
const localOnSubmit = (e) => {
|
||||
|
|
@ -437,9 +443,15 @@ export function textEllipsis(str, maxLength = 100, {side = "end", ellipsis = "..
|
|||
}
|
||||
|
||||
export function TabLinks({links}) {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const getTo = (to) => {
|
||||
return `${to}?${searchParams.toString()}`;
|
||||
}
|
||||
|
||||
return <Menu tabular>
|
||||
{links.map((link) => <NavLink
|
||||
to={link.to}
|
||||
to={getTo(link.to)}
|
||||
className='item'
|
||||
style={{padding: '1em'}}
|
||||
key={link.to}
|
||||
|
|
@ -771,6 +783,12 @@ export function mimetypeIconName(mimetype, lowerPath = '') {
|
|||
} else if (lowerPath.endsWith('.azw3')) {
|
||||
return 'book';
|
||||
}
|
||||
} else if (mimetype.startsWith('application/vnd.openxmlformats-officedocument.spreadsheetml.') || mimetype.startsWith('application/vnd.ms-excel') || mimetype.startsWith('application/vnd.oasis.opendocument.spreadsheet')) {
|
||||
return 'file excel'
|
||||
} else if (mimetype.startsWith('application/vnd.openxmlformats-officedocument.wordprocessingml.') || mimetype.startsWith('application/msword') || mimetype.startsWith('application/vnd.oasis.opendocument.text')) {
|
||||
return 'file word'
|
||||
} else if (mimetype.startsWith('application/vnd.openxmlformats-officedocument.presentationml.') || mimetype.startsWith('application/vnd.ms-powerpoint') || mimetype.startsWith('application/vnd.oasis.opendocument.presentation')) {
|
||||
return 'file powerpoint'
|
||||
}
|
||||
}
|
||||
if (lowerPath.endsWith('.pem')) {
|
||||
|
|
@ -1163,3 +1181,13 @@ export function SortButton({sorts = []}) {
|
|||
</ButtonGroup>
|
||||
</>
|
||||
}
|
||||
|
||||
export function TagIcon() {
|
||||
return <Label circular color='green' style={{padding: '0.5em', marginRight: '0.5em'}}>
|
||||
<Icon name='tag' style={{margin: 0}}/>
|
||||
</Label>
|
||||
}
|
||||
|
||||
export function normalizeEstimate(estimate) {
|
||||
return estimate > 999 ? '>999' : estimate;
|
||||
}
|
||||
|
|
@ -1,11 +1,23 @@
|
|||
import {Button, Icon, Placeholder} from "./Theme";
|
||||
import {Checkbox, Confirm, PlaceholderLine, TableCell, TableFooter, TableHeaderCell, TableRow} from "semantic-ui-react";
|
||||
import {FileIcon, humanFileSize} from "./Common";
|
||||
import React, {useEffect} from "react";
|
||||
import {deleteFile} from "../api";
|
||||
import {Button, Header, Icon, Modal, ModalActions, ModalContent, ModalHeader, Placeholder, Table} from "./Theme";
|
||||
import {
|
||||
Checkbox,
|
||||
Confirm,
|
||||
IconGroup,
|
||||
Input,
|
||||
PlaceholderLine,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableFooter,
|
||||
TableHeader,
|
||||
TableHeaderCell,
|
||||
TableRow
|
||||
} from "semantic-ui-react";
|
||||
import {DirectorySearch, FileIcon, humanFileSize} from "./Common";
|
||||
import React, {useEffect, useState} from "react";
|
||||
import {deleteFile, makeDirectory, movePaths, renamePath} from "../api";
|
||||
import _ from 'lodash';
|
||||
import {SortableTable} from "./SortableTable";
|
||||
import {useBrowseFiles} from "../hooks/customHooks";
|
||||
import {useBrowseFiles, useMediaDirectory} from "../hooks/customHooks";
|
||||
import {DirectoryRefreshButton, FileRowTagIcon, FilesRefreshButton} from "./Files";
|
||||
import {FilePreviewContext} from "./FilePreview";
|
||||
|
||||
|
|
@ -15,14 +27,40 @@ function depthIndentation(path) {
|
|||
return '\xa0\xa0\xa0\xa0'.repeat(depth);
|
||||
}
|
||||
|
||||
function Folder({folder, onFolderClick, sortData, selectedPath, onFileClick, onSelect}) {
|
||||
export function pathDirectory(path) {
|
||||
// Return the full path of the path's directory.
|
||||
// pathDirectory('/some/directory/file.txt') -> '/some/directory'
|
||||
return path.substring(0, path.lastIndexOf('/'));
|
||||
}
|
||||
|
||||
export function pathName(path) {
|
||||
// Return the file's name.
|
||||
// pathName('/some/directory/file.txt') -> 'file.txt'
|
||||
// pathName('/some/directory') -> 'directory'
|
||||
if (path.endsWith('/')) {
|
||||
// Path is a directory, return its name.
|
||||
path = path.substring(0, path.length - 1);
|
||||
}
|
||||
return path.substring(path.lastIndexOf('/') + 1);
|
||||
}
|
||||
|
||||
export function splitPathParentAndName(path) {
|
||||
if (path.endsWith('/')) {
|
||||
// Path is a directory, return its name.
|
||||
path = path.substring(0, path.length - 1);
|
||||
}
|
||||
const slashIndex = path.lastIndexOf('/');
|
||||
return [path.substring(0, slashIndex), path.substring(slashIndex + 1, path.length)];
|
||||
}
|
||||
|
||||
function Folder({folder, onFolderClick, sortData, selectedPaths, onFileClick, onSelect, disabled}) {
|
||||
// Creates a single table row for a folder, or a row for itself and indented rows for its children.
|
||||
let {path, children, is_empty} = folder;
|
||||
const pathWithNoTrailingSlash = path.substring(0, path.length - 1);
|
||||
const name = path.substring(pathWithNoTrailingSlash.lastIndexOf('/') + 1);
|
||||
const f = <TableRow key={path}>
|
||||
const f = <TableRow key={path} disabled={disabled}>
|
||||
<TableCell collapsing>
|
||||
<Checkbox checked={selectedPath === folder['path']} onChange={() => onSelect(folder['path'])}/>
|
||||
<Checkbox checked={selectedPaths.indexOf(folder['path']) >= 0} onChange={() => onSelect(folder['path'])}/>
|
||||
</TableCell>
|
||||
<TableCell onClick={() => onFolderClick(path)} className='file-path' colSpan={2} disabled={is_empty}>
|
||||
{depthIndentation(pathWithNoTrailingSlash)}
|
||||
|
|
@ -37,7 +75,7 @@ function Folder({folder, onFolderClick, sortData, selectedPath, onFileClick, onS
|
|||
_.forEach(children, (p, k) => {
|
||||
childPaths = [...childPaths,
|
||||
<Path key={k} path={p} onFolderClick={onFolderClick} sortData={sortData} onFileClick={onFileClick}
|
||||
selectedPath={selectedPath} onSelect={onSelect}/>];
|
||||
selectedPaths={selectedPaths} onSelect={onSelect} disabled={disabled}/>];
|
||||
});
|
||||
return <React.Fragment>
|
||||
{f}
|
||||
|
|
@ -49,19 +87,18 @@ function Folder({folder, onFolderClick, sortData, selectedPath, onFileClick, onS
|
|||
}
|
||||
}
|
||||
|
||||
function File({file, onFileClick, selectedPath, onSelect}) {
|
||||
function File({file, onFileClick, selectedPaths, onSelect, disabled}) {
|
||||
const {path, size} = file;
|
||||
const name = path.substring(path.lastIndexOf('/') + 1);
|
||||
return <TableRow key={path}>
|
||||
return <TableRow key={path} disabled={disabled}>
|
||||
<TableCell collapsing>
|
||||
<Checkbox checked={selectedPath === path} onChange={() => onSelect(path)}/>
|
||||
<Checkbox checked={selectedPaths.indexOf(path) >= 0} onChange={() => onSelect(path)}/>
|
||||
</TableCell>
|
||||
<TableCell onClick={() => onFileClick(file)} className='file-path'>
|
||||
{depthIndentation(path)}
|
||||
<FileRowTagIcon file={file}/>
|
||||
{/*null size to make icon the correct size*/}
|
||||
<FileIcon file={file} size={null}/>
|
||||
{name}
|
||||
{pathName(path)}
|
||||
</TableCell>
|
||||
<TableCell collapsing textAlign='right'>
|
||||
{humanFileSize(size)}
|
||||
|
|
@ -69,31 +106,37 @@ function File({file, onFileClick, selectedPath, onSelect}) {
|
|||
</TableRow>
|
||||
}
|
||||
|
||||
function Path({path, onFolderClick, onFileClick, sortData, selectedPath, onSelect}) {
|
||||
function Path({path, onFolderClick, onFileClick, sortData, selectedPaths, onSelect, disabled}) {
|
||||
if (path['path'].endsWith('/')) {
|
||||
return <Folder
|
||||
folder={path}
|
||||
onFolderClick={onFolderClick}
|
||||
sortData={sortData}
|
||||
selectedPath={selectedPath}
|
||||
selectedPaths={selectedPaths}
|
||||
onSelect={onSelect}
|
||||
onFileClick={onFileClick}
|
||||
disabled={disabled}
|
||||
/>
|
||||
} else {
|
||||
return <File
|
||||
file={path}
|
||||
onFileClick={onFileClick}
|
||||
selectedPath={selectedPath}
|
||||
selectedPaths={selectedPaths}
|
||||
onSelect={onSelect}
|
||||
disabled={disabled}
|
||||
/>
|
||||
}
|
||||
}
|
||||
|
||||
export function FileBrowser() {
|
||||
const [selectedPath, setSelectedPath] = React.useState(null);
|
||||
const [selectedPaths, setSelectedPaths] = React.useState([]);
|
||||
const [deleteOpen, setDeleteOpen] = React.useState(false);
|
||||
const [fileSelected, setFileSelected] = React.useState(false);
|
||||
const [folderSelected, setFolderSelected] = React.useState(false);
|
||||
const [renameOpen, setRenameOpen] = React.useState(false);
|
||||
const [moveOpen, setMoveOpen] = React.useState(false);
|
||||
const [makeDirectoryOpen, setMakeDirectoryOpen] = React.useState(false);
|
||||
const [pending, setPending] = React.useState(false);
|
||||
|
||||
const selectedPathsCount = selectedPaths ? selectedPaths.length : 0;
|
||||
|
||||
const {browseFiles, openFolders, setOpenFolders, fetchFiles} = useBrowseFiles();
|
||||
|
||||
|
|
@ -113,53 +156,97 @@ export function FileBrowser() {
|
|||
}, []);
|
||||
|
||||
const onSelect = (path) => {
|
||||
if (path.endsWith('/') && selectedPath === path) {
|
||||
// Deselect folder.
|
||||
setFileSelected(false);
|
||||
setFolderSelected(false);
|
||||
setSelectedPath(null);
|
||||
} else if (path.endsWith('/')) {
|
||||
// Select folder.
|
||||
setFileSelected(false);
|
||||
setFolderSelected(true);
|
||||
setSelectedPath(path);
|
||||
} else if (selectedPath === path) {
|
||||
// Deselect file.
|
||||
setFileSelected(false);
|
||||
setFolderSelected(false);
|
||||
setSelectedPath(null);
|
||||
let newSelectedPaths;
|
||||
// Select or deselect if the paths was previously selected.
|
||||
if (selectedPathsCount && selectedPaths.indexOf(path) >= 0) {
|
||||
newSelectedPaths = selectedPaths.filter(i => i !== path);
|
||||
} else {
|
||||
// Select file.
|
||||
setFileSelected(true);
|
||||
setFolderSelected(false);
|
||||
setSelectedPath(path);
|
||||
newSelectedPaths = [...selectedPaths, path];
|
||||
}
|
||||
setSelectedPaths(newSelectedPaths);
|
||||
console.debug('selectedPaths', newSelectedPaths);
|
||||
}
|
||||
|
||||
const onDelete = async () => {
|
||||
await deleteFile(selectedPath);
|
||||
setDeleteOpen(false);
|
||||
await fetchFiles();
|
||||
await deleteFile(selectedPaths);
|
||||
await reset();
|
||||
};
|
||||
|
||||
const handleMakeDirectory = async () => {
|
||||
setMakeDirectoryOpen(false);
|
||||
await reset();
|
||||
}
|
||||
|
||||
const reset = async () => {
|
||||
setDeleteOpen(false);
|
||||
setSelectedPaths([]);
|
||||
setPending(true);
|
||||
try {
|
||||
await fetchFiles();
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
const footer = <TableFooter fullWidth>
|
||||
<TableRow>
|
||||
<TableHeaderCell colSpan={3}>
|
||||
{folderSelected ? <DirectoryRefreshButton directory={selectedPath}/> : <FilesRefreshButton/>}
|
||||
<Button
|
||||
{selectedPathsCount ?
|
||||
<DirectoryRefreshButton paths={selectedPaths}/>
|
||||
: <FilesRefreshButton/>
|
||||
}
|
||||
<Button icon='trash'
|
||||
color='red'
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
disabled={!fileSelected}
|
||||
>
|
||||
<Icon name='close'/>
|
||||
Delete
|
||||
</Button>
|
||||
disabled={selectedPathsCount === 0}
|
||||
/>
|
||||
<Confirm
|
||||
open={deleteOpen}
|
||||
content='Are you sure you want to delete these files?'
|
||||
onConfirm={onDelete}
|
||||
onCancel={() => setDeleteOpen(false)}
|
||||
/>
|
||||
<Button icon='text cursor'
|
||||
color='yellow'
|
||||
onClick={() => setRenameOpen(true)}
|
||||
disabled={selectedPathsCount !== 1}
|
||||
/>
|
||||
{selectedPathsCount === 1 ?
|
||||
<RenameModal
|
||||
open={renameOpen}
|
||||
onClose={() => setRenameOpen(false)}
|
||||
onPending={setPending}
|
||||
path={selectedPaths[0]}
|
||||
onSubmit={reset}
|
||||
/> : null}
|
||||
<Button icon='move'
|
||||
color='violet'
|
||||
disabled={selectedPathsCount === 0}
|
||||
onClick={() => setMoveOpen(true)}
|
||||
/>
|
||||
{selectedPathsCount > 0 ?
|
||||
<MoveModal open={moveOpen}
|
||||
onClose={() => setMoveOpen(false)}
|
||||
paths={selectedPaths}
|
||||
onSubmit={reset}
|
||||
/> : null}
|
||||
<Button
|
||||
color='blue'
|
||||
onClick={() => setMakeDirectoryOpen(true)}
|
||||
disabled={selectedPaths && selectedPaths.length > 1}
|
||||
style={{paddingLeft: '1em', paddingRight: '0.8em'}}
|
||||
>
|
||||
<IconGroup>
|
||||
<Icon name='folder'/>
|
||||
<Icon corner name='add'/>
|
||||
</IconGroup>
|
||||
</Button>
|
||||
<MakeDirectoryModal
|
||||
open={makeDirectoryOpen}
|
||||
onClose={() => setMakeDirectoryOpen(false)}
|
||||
parent={selectedPaths.length ? selectedPaths[0] : null}
|
||||
onSubmit={handleMakeDirectory}
|
||||
/>
|
||||
</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableFooter>;
|
||||
|
|
@ -197,10 +284,187 @@ export function FileBrowser() {
|
|||
onFolderClick={onFolderClick}
|
||||
onFileClick={(i) => setPreviewFile(i)}
|
||||
sortData={sortData}
|
||||
selectedPath={selectedPath}
|
||||
selectedPaths={selectedPaths}
|
||||
onSelect={onSelect}
|
||||
disabled={pending}
|
||||
/>}
|
||||
footer={footer}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
|
||||
export function RenameModal({open, onClose, path, onSubmit, onPending}) {
|
||||
const [parent, name] = splitPathParentAndName(path);
|
||||
const [value, setValue] = React.useState(name);
|
||||
const disabled = value === name;
|
||||
|
||||
const handleInputChange = (e, props) => {
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
}
|
||||
setValue(props.value);
|
||||
}
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
}
|
||||
try {
|
||||
console.log(`Renaming: ${path} to ${value}`);
|
||||
onPending(true);
|
||||
await renamePath(path, value);
|
||||
} finally {
|
||||
onClose();
|
||||
onPending(false);
|
||||
if (onSubmit) {
|
||||
await onSubmit();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setValue(pathName(path));
|
||||
}, [path]);
|
||||
|
||||
return <Modal closeIcon
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
>
|
||||
<ModalHeader>Rename: {name}</ModalHeader>
|
||||
<ModalContent>
|
||||
<Header as='h4'>New Name</Header>
|
||||
<pre>{parent ? parent + '/' : null}{value}</pre>
|
||||
|
||||
<Input fluid
|
||||
label='Name'
|
||||
value={value}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</ModalContent>
|
||||
<ModalActions>
|
||||
<Button
|
||||
color='violet'
|
||||
disabled={disabled}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
Rename
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setValue(name)}
|
||||
floated='left'
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</ModalActions>
|
||||
</Modal>
|
||||
}
|
||||
|
||||
export function MoveModal({open, paths, onClose, onSubmit}) {
|
||||
const [destination, setDestination] = React.useState('');
|
||||
const [pending, setPending] = React.useState(false);
|
||||
|
||||
const handleDirectory = (value) => {
|
||||
setDestination(value);
|
||||
}
|
||||
|
||||
const handleMove = async () => {
|
||||
try {
|
||||
setPending(true);
|
||||
await movePaths(destination, paths);
|
||||
onClose();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setPending(false);
|
||||
}
|
||||
if (onSubmit) {
|
||||
await onSubmit();
|
||||
}
|
||||
}
|
||||
|
||||
const fileCell = i => <TableCell>
|
||||
<pre style={{marginTop: 0, marginBottom: 0}}>{i}</pre>
|
||||
</TableCell>;
|
||||
|
||||
const destinationRows = paths.map(i => <TableRow key={i}>
|
||||
{fileCell(i)}
|
||||
{destination ? fileCell(destination + '/' + pathName(i)) : fileCell(pathName(i))}
|
||||
</TableRow>);
|
||||
|
||||
return <Modal closeIcon
|
||||
onClose={onClose}
|
||||
open={open}
|
||||
>
|
||||
<ModalHeader>Move Files/Directories</ModalHeader>
|
||||
<ModalContent>
|
||||
<p>Search for a directory to move your files into:</p>
|
||||
<DirectorySearch onSelect={handleDirectory} disabled={pending}/>
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Source</TableHeaderCell>
|
||||
<TableHeaderCell>Destination</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{destinationRows}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ModalContent>
|
||||
<ModalActions>
|
||||
<Button
|
||||
color='violet'
|
||||
onClick={handleMove}
|
||||
disabled={pending}
|
||||
>Move</Button>
|
||||
<Button
|
||||
onClick={() => setDestination(null)}
|
||||
floated='left'
|
||||
disabled={pending}
|
||||
>Reset</Button>
|
||||
</ModalActions>
|
||||
</Modal>
|
||||
}
|
||||
|
||||
export function MakeDirectoryModal({open, onClose, parent, onSubmit}) {
|
||||
const [value, setValue] = useState('');
|
||||
const mediaDirectory = useMediaDirectory();
|
||||
|
||||
const handleInputChange = (e, props) => {
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
}
|
||||
setValue(props.value);
|
||||
}
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
}
|
||||
try {
|
||||
const path = parent ? `${parent}/${value}` : value;
|
||||
await makeDirectory(path);
|
||||
setValue('');
|
||||
if (onSubmit) {
|
||||
await onSubmit();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
return <Modal closeIcon open={open} onClose={onClose}>
|
||||
<ModalHeader>Make Directory</ModalHeader>
|
||||
<ModalContent>
|
||||
<pre>{mediaDirectory}/{parent}{value}</pre>
|
||||
<Input fluid
|
||||
label='Name'
|
||||
value={value}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</ModalContent>
|
||||
<ModalActions>
|
||||
<Button color='violet' onClick={handleSubmit}>Create</Button>
|
||||
</ModalActions>
|
||||
</Modal>
|
||||
}
|
||||
|
|
@ -1,12 +1,13 @@
|
|||
import React from "react";
|
||||
import _ from "lodash";
|
||||
import {Grid, Header as SHeader, Image,} from "semantic-ui-react";
|
||||
import {TagsProvider, TagsSelector} from "../Tags";
|
||||
import {TagsSelector} from "../Tags";
|
||||
import {Media} from "../contexts/contexts";
|
||||
import {encodeMediaPath} from "./Common";
|
||||
import {addTag, fetchFile, removeTag} from "../api";
|
||||
import {fetchFile, tagFileGroup, untagFileGroup} from "../api";
|
||||
import {StlViewer} from "react-stl-viewer";
|
||||
import {Button, Modal, ModalActions, ModalContent, ModalHeader} from "./Theme";
|
||||
import {toast} from "react-semantic-toasts-2";
|
||||
|
||||
function getMediaPathURL(previewFile) {
|
||||
if (previewFile['primary_path']) {
|
||||
|
|
@ -26,9 +27,9 @@ function getDownloadPathURL(previewFile) {
|
|||
|
||||
function getEpubViewerURL(previewFile) {
|
||||
if (previewFile['primary_path']) {
|
||||
return `/epub.html?url=download/${encodeMediaPath(previewFile['primary_path'])}`;
|
||||
return `/epub/epub.html?url=download/${encodeMediaPath(previewFile['primary_path'])}`;
|
||||
} else {
|
||||
return `/epub.html?url=download/${encodeMediaPath(previewFile['path'])}`;
|
||||
return `/epub/epub.html?url=download/${encodeMediaPath(previewFile['path'])}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -65,7 +66,7 @@ function getImageModal(previewFile) {
|
|||
|
||||
function getEpubModal(previewFile) {
|
||||
const downloadURL = getDownloadPathURL(previewFile);
|
||||
const viewerUrl = `/epub.html?url=${downloadURL}`;
|
||||
const viewerUrl = `/epub/epub.html?url=${downloadURL}`;
|
||||
return <ModalContent>
|
||||
<div className='full-height'>
|
||||
<iframe title='textModal' src={viewerUrl}
|
||||
|
|
@ -138,6 +139,15 @@ function getSTLModal(previewFile) {
|
|||
</React.Fragment>
|
||||
}
|
||||
|
||||
function getGenericModal(previewFile) {
|
||||
const path = previewFile.primary_path ?? previewFile.path;
|
||||
const name = path.replace(/^.*[\\\/]/, '');
|
||||
|
||||
return <React.Fragment>
|
||||
<ModalHeader>{name}</ModalHeader>
|
||||
</React.Fragment>
|
||||
}
|
||||
|
||||
export const FilePreviewContext = React.createContext({
|
||||
previewFile: null,
|
||||
setPreviewFile: null,
|
||||
|
|
@ -183,29 +193,28 @@ export function FilePreviewProvider({children}) {
|
|||
}
|
||||
|
||||
const localAddTag = async (name) => {
|
||||
await addTag(previewFile, name);
|
||||
await tagFileGroup(previewFile, name);
|
||||
await localFetchFile();
|
||||
}
|
||||
|
||||
const localRemoveTag = async (name) => {
|
||||
await removeTag(previewFile, name);
|
||||
await untagFileGroup(previewFile, name);
|
||||
await localFetchFile();
|
||||
}
|
||||
|
||||
function setModalContent(content, url, downloadURL) {
|
||||
let openButton;
|
||||
if (url) {
|
||||
openButton = <Button color='blue' as='a' href={url}>Open</Button>;
|
||||
}
|
||||
function setModalContent(content, url, downloadURL, path) {
|
||||
const openButton = url ? <Button color='blue' as='a' href={url}>Open</Button> : null;
|
||||
const closeButton = <Button onClick={handleClose}>Close</Button>;
|
||||
const tagsDisplay = <TagsSelector selectedTagNames={previewFile['tags']} onAdd={localAddTag}
|
||||
onRemove={localRemoveTag}/>;
|
||||
let downloadButton;
|
||||
if (downloadURL) {
|
||||
downloadButton = <Button color='yellow' as='a' href={downloadURL} floated='left'>
|
||||
Download
|
||||
</Button>;
|
||||
}
|
||||
const downloadButton = downloadURL ?
|
||||
<Button color='yellow' as='a' href={downloadURL} floated='left'>Download</Button>
|
||||
: null;
|
||||
const pathContent = path ? <ModalContent>
|
||||
<pre>{path}</pre>
|
||||
</ModalContent>
|
||||
: null;
|
||||
console.log('path', path);
|
||||
|
||||
setPreviewModal(<Modal closeIcon
|
||||
size='fullscreen'
|
||||
|
|
@ -213,6 +222,7 @@ export function FilePreviewProvider({children}) {
|
|||
onClose={e => handleClose(e)}
|
||||
>
|
||||
{content}
|
||||
{pathContent}
|
||||
<ModalActions>
|
||||
<Media at='mobile'>
|
||||
<Grid>
|
||||
|
|
@ -244,41 +254,47 @@ export function FilePreviewProvider({children}) {
|
|||
setPreviewModal(null);
|
||||
if (previewFile && !_.isObject(previewFile)) {
|
||||
console.error(`Unknown previewFile type: ${typeof previewFile}`);
|
||||
toast({
|
||||
type: 'error',
|
||||
title: 'Cannot preview file',
|
||||
description: 'Cannot preview file',
|
||||
time: 5000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (previewFile && !_.isEmpty(previewFile)) {
|
||||
const {mimetype, size} = previewFile;
|
||||
const path = previewFile['primary_path'] || previewFile['path'];
|
||||
console.debug('Previewing file', previewFile);
|
||||
const lowerPath = path.toLowerCase();
|
||||
console.debug(`useFilePreview path=${previewFile['path']} mimetype=${mimetype}`);
|
||||
const url = getMediaPathURL(previewFile);
|
||||
const downloadURL = getDownloadPathURL(previewFile);
|
||||
if (mimetype.startsWith('text/') && size > MAXIMUM_TEXT_SIZE) {
|
||||
// Large text files should be downloaded.
|
||||
window.open(downloadURL);
|
||||
} else if (mimetype.startsWith('text/') || mimetype.startsWith('application/json')) {
|
||||
setModalContent(getIframeModal(previewFile), url, downloadURL);
|
||||
setModalContent(getIframeModal(previewFile), url, downloadURL, path);
|
||||
} else if (mimetype.startsWith('video/')) {
|
||||
setModalContent(getVideoModal(previewFile), url, downloadURL);
|
||||
setModalContent(getVideoModal(previewFile), url, downloadURL, path);
|
||||
} else if (mimetype.startsWith('audio/')) {
|
||||
setModalContent(getAudioModal(previewFile), url, downloadURL);
|
||||
setModalContent(getAudioModal(previewFile), url, downloadURL, path);
|
||||
} else if (mimetype.startsWith('application/epub')) {
|
||||
const viewerURL = getEpubViewerURL(previewFile);
|
||||
setModalContent(getEpubModal(previewFile), viewerURL, downloadURL);
|
||||
setModalContent(getEpubModal(previewFile), viewerURL, downloadURL, path);
|
||||
} else if (mimetype.startsWith('application/pdf')) {
|
||||
setModalContent(getIframeModal(previewFile), url, downloadURL);
|
||||
setModalContent(getIframeModal(previewFile), url, downloadURL, path);
|
||||
} else if (mimetype.startsWith('image/')) {
|
||||
setModalContent(getImageModal(previewFile), url);
|
||||
setModalContent(getImageModal(previewFile), url, null, path);
|
||||
} else if (mimetype.startsWith('model/stl')) {
|
||||
setModalContent(getSTLModal(previewFile), null, downloadURL);
|
||||
setModalContent(getSTLModal(previewFile), null, downloadURL, path);
|
||||
} else if (mimetype.startsWith('application/octet-stream') && lowerPath.endsWith('.mp3')) {
|
||||
setModalContent(getAudioModal(previewFile), url, downloadURL);
|
||||
setModalContent(getAudioModal(previewFile), url, downloadURL, path);
|
||||
} else if (mimetype.startsWith('application/octet-stream') && lowerPath.endsWith('.stl')) {
|
||||
setModalContent(getSTLModal(previewFile), null, downloadURL);
|
||||
setModalContent(getSTLModal(previewFile), null, downloadURL, path);
|
||||
} else {
|
||||
// No special handler for this file type, just open it.
|
||||
window.open(downloadURL);
|
||||
setModalContent(getGenericModal(previewFile), url, downloadURL, path);
|
||||
}
|
||||
}
|
||||
}, [previewFile]);
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import {
|
|||
Dropdown,
|
||||
Form,
|
||||
Image,
|
||||
Label,
|
||||
PlaceholderLine,
|
||||
TableCell,
|
||||
TableHeaderCell,
|
||||
|
|
@ -28,6 +27,7 @@ import {
|
|||
PageContainer,
|
||||
Paginator,
|
||||
PreviewLink,
|
||||
TagIcon,
|
||||
textEllipsis,
|
||||
useTitle
|
||||
} from "./Common";
|
||||
|
|
@ -40,7 +40,7 @@ import {
|
|||
useSearchView
|
||||
} from "../hooks/customHooks";
|
||||
import {Route, Routes} from "react-router-dom";
|
||||
import {CardPlacholder} from "./Placeholder";
|
||||
import {CardPlaceholder} from "./Placeholder";
|
||||
import {ArchiveCard, ArchiveRowCells} from "./Archive";
|
||||
import Grid from "semantic-ui-react/dist/commonjs/collections/Grid";
|
||||
import {Media, StatusContext, ThemeContext} from "../contexts/contexts";
|
||||
|
|
@ -72,7 +72,7 @@ function EbookCard({file}) {
|
|||
|
||||
const downloadUrl = `/download/${encodeMediaPath(file.primary_path)}`;
|
||||
const isEpub = file['mimetype'].startsWith('application/epub');
|
||||
const viewerUrl = isEpub ? `/epub.html?url=${downloadUrl}` : null;
|
||||
const viewerUrl = isEpub ? `/epub/epub.html?url=${downloadUrl}` : null;
|
||||
|
||||
const color = mimetypeColor(file.mimetype);
|
||||
return <Card color={color}>
|
||||
|
|
@ -125,7 +125,7 @@ function FileCard({file}) {
|
|||
file.mimetype.startsWith('application/epub') || file.mimetype.startsWith('application/x-mobipocket-ebook')
|
||||
);
|
||||
|
||||
if (file.model === 'video') {
|
||||
if (file.model === 'video' && 'video' in file) {
|
||||
return <VideoCard key={file['primary_path']} file={file}/>;
|
||||
} else if (file.model === 'archive') {
|
||||
return <ArchiveCard key={file['primary_path']} file={file}/>;
|
||||
|
|
@ -167,7 +167,7 @@ export function FileCards({files}) {
|
|||
} else if (files && files.length === 0) {
|
||||
return <Segment>No results!</Segment>
|
||||
} else {
|
||||
return <CardGroupCentered><CardPlacholder/></CardGroupCentered>
|
||||
return <CardGroupCentered><CardPlaceholder/></CardGroupCentered>
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -269,9 +269,7 @@ export function FileTable({files, selectOn, onSelect, footer, selectedKeys}) {
|
|||
|
||||
export function FileRowTagIcon({file}) {
|
||||
if (file.tags && file.tags.length) {
|
||||
return <Label circular color='green' style={{padding: '0.5em', marginRight: '0.5em'}}>
|
||||
<Icon name='tag' style={{margin: 0}}/>
|
||||
</Label>;
|
||||
return <TagIcon/>;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -431,10 +429,10 @@ export function TagsQuerySelector({onChange}) {
|
|||
const {searchParams, updateQuery} = useQuery();
|
||||
const activeTags = searchParams.getAll('tag');
|
||||
|
||||
const localOnChange = (name) => {
|
||||
updateQuery({'tag': name});
|
||||
const localOnChange = (tagNames) => {
|
||||
updateQuery({'tag': tagNames});
|
||||
if (onChange) {
|
||||
onChange(name);
|
||||
onChange(tagNames);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -464,16 +462,16 @@ export function FilesSearchView({
|
|||
{key: 'model', text: '3D Model', value: 'model'},
|
||||
];
|
||||
|
||||
const {searchFiles, totalPages, activePage, setPage} = useSearchFiles(24, emptySearch, model);
|
||||
const {searchFiles, pages} = useSearchFiles(24, emptySearch, model);
|
||||
|
||||
const {body, paginator, selectButton, viewButton, limitDropdown, tagQuerySelector} = FilesView(
|
||||
searchFiles,
|
||||
activePage,
|
||||
totalPages,
|
||||
pages.activePage,
|
||||
pages.totalPages,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
setPage,
|
||||
pages.setPage,
|
||||
true,
|
||||
);
|
||||
|
||||
|
|
@ -597,7 +595,7 @@ export function FilesRefreshButton() {
|
|||
</Button>;
|
||||
}
|
||||
|
||||
export function DirectoryRefreshButton({directory}) {
|
||||
export function DirectoryRefreshButton({paths}) {
|
||||
const {status} = useContext(StatusContext);
|
||||
const refreshing = status && status['flags'] && status['flags'].indexOf('refreshing') >= 0;
|
||||
const refreshingDirectory = status && status['flags'] && status['flags'].indexOf('refreshing_directory') >= 0;
|
||||
|
|
@ -606,7 +604,7 @@ export function DirectoryRefreshButton({directory}) {
|
|||
|
||||
const handleClick = async () => {
|
||||
setLoading(true);
|
||||
await refreshDirectoryFiles(directory);
|
||||
await refreshDirectoryFiles(paths);
|
||||
}
|
||||
|
||||
// Clear loading when global refresh event completes.
|
||||
|
|
@ -620,6 +618,6 @@ export function DirectoryRefreshButton({directory}) {
|
|||
disabled={loading || refreshing || refreshingDirectory}
|
||||
>
|
||||
<Icon name='refresh'/>
|
||||
Refresh Directory
|
||||
Refresh
|
||||
</Button>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,14 +8,14 @@ import _ from "lodash";
|
|||
import Grid from "semantic-ui-react/dist/commonjs/collections/Grid";
|
||||
import {FileRowTagIcon} from "./Files";
|
||||
|
||||
const HeadlineParagraph = ({headline}) => {
|
||||
export const HeadlineText = ({headline, openTag = '<u>', closeTag = '</u>'}) => {
|
||||
const {s} = React.useContext(ThemeContext);
|
||||
if (headline) {
|
||||
headline = headline.replaceAll('<b>', '<b><u>');
|
||||
headline = headline.replaceAll('</b>', '</b></u>');
|
||||
headline = headline.replaceAll('<b>', openTag);
|
||||
headline = headline.replaceAll('</b>', closeTag);
|
||||
headline = headline.replaceAll('\n', '<br/>');
|
||||
// Postgres inserts <b>...</b> tags which we will use.
|
||||
return <p {...s} dangerouslySetInnerHTML={{__html: headline}}></p>
|
||||
return <span {...s} dangerouslySetInnerHTML={{__html: headline}}></span>
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -33,18 +33,18 @@ const FileHeadline = ({file, to}) => {
|
|||
|
||||
let header = title || name;
|
||||
if (title_headline) {
|
||||
header = <PreviewLink file={file}><HeadlineParagraph headline={title_headline}/></PreviewLink>;
|
||||
header = <PreviewLink file={file}><HeadlineText headline={title_headline}/></PreviewLink>;
|
||||
}
|
||||
if (to) {
|
||||
header = <Link to={to}><HeadlineParagraph headline={title_headline}/></Link>
|
||||
header = <Link to={to}><HeadlineText headline={title_headline}/></Link>
|
||||
}
|
||||
|
||||
let body = <p><small>No highlights available.</small></p>;
|
||||
if (b_headline || c_headline || d_headline) {
|
||||
body = <>
|
||||
<HeadlineParagraph headline={b_headline}/>
|
||||
<HeadlineParagraph headline={c_headline}/>
|
||||
<HeadlineParagraph headline={d_headline}/>
|
||||
<HeadlineText headline={b_headline}/>
|
||||
<HeadlineText headline={c_headline}/>
|
||||
<HeadlineText headline={d_headline}/>
|
||||
</>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -71,8 +71,8 @@ function DownloadMessage() {
|
|||
</p>
|
||||
|
||||
<p><b>Download only the areas you need</b>. Large regions like all of Asia, or the entire
|
||||
planet are most likely <b>too large</b> and won't render quickly. I recommend only
|
||||
importing files less than 1GB on a Raspberry Pi.</p>
|
||||
planet are most likely <b>too large</b> and won't render quickly. It is recommend to only
|
||||
import files less than 1GB on a Raspberry Pi.</p>
|
||||
|
||||
<p>Only <b>*.osm.pbf</b> files are supported!</p>
|
||||
|
||||
|
|
@ -235,7 +235,6 @@ class ManageMap extends React.Component {
|
|||
return <PageContainer>
|
||||
<WROLModeMessage content='Cannot modify Map'/>
|
||||
<DockerMapImportWarning/>
|
||||
<DownloadMessage/>
|
||||
<SlowImportMessage/>
|
||||
<Loader size='large' active={import_running} inline='centered'>
|
||||
{importingMessage}
|
||||
|
|
@ -263,6 +262,7 @@ class ManageMap extends React.Component {
|
|||
</TableRow>
|
||||
</TableFooter>
|
||||
</Table>
|
||||
<DownloadMessage/>
|
||||
</PageContainer>
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,11 +10,12 @@ const links = [
|
|||
{text: 'Videos', to: '/videos', key: 'videos'},
|
||||
{text: 'Archive', to: '/archive', key: 'archive'},
|
||||
{text: 'Map', to: '/map', key: 'map'},
|
||||
{text: 'Zim', to: '/zim', key: 'zim'},
|
||||
{text: 'Files', to: '/files', key: 'files'},
|
||||
{text: 'Inventory', to: '/inventory', key: 'inventory'},
|
||||
{
|
||||
text: 'More', key: 'more',
|
||||
links: [
|
||||
{text: 'Inventory', to: '/inventory', key: 'inventory'},
|
||||
{to: '/more/otp', text: 'One Time Pad', end: true},
|
||||
{to: '/more/statistics', text: 'Statistics', end: true},
|
||||
{to: '/more/vin', text: 'VIN Decoder', end: true},
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import React, {useContext} from "react";
|
|||
import {ThemeContext} from "../contexts/contexts";
|
||||
import {Card, Placeholder} from "./Theme";
|
||||
|
||||
export function CardPlacholder() {
|
||||
export function CardPlaceholder() {
|
||||
const {s} = useContext(ThemeContext);
|
||||
|
||||
return <Card>
|
||||
|
|
@ -22,7 +22,7 @@ export function CardPlacholder() {
|
|||
|
||||
export function VideoPlaceholder() {
|
||||
return <CardGroup doubling stackable>
|
||||
<CardPlacholder/>
|
||||
<CardPlaceholder/>
|
||||
</CardGroup>
|
||||
}
|
||||
|
||||
|
|
@ -39,3 +39,11 @@ export function ProgressPlaceholder() {
|
|||
<PlaceholderLine/>
|
||||
</Placeholder>
|
||||
}
|
||||
|
||||
export function TextPlaceholder() {
|
||||
return <Placeholder style={{marginBottom: '1em'}}>
|
||||
<PlaceholderLine/>
|
||||
<PlaceholderLine/>
|
||||
<PlaceholderLine/>
|
||||
</Placeholder>
|
||||
}
|
||||
|
|
|
|||
113
app/src/components/Search.js
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import React, {useEffect, useState} from "react";
|
||||
import {createSearchParams, Route, Routes, useHref, useNavigate} from "react-router-dom";
|
||||
import {FilesSearchView} from "./Files";
|
||||
import {usePages, useQuery} from "../hooks/customHooks";
|
||||
import {ZimSearchView} from "./Zim";
|
||||
import {searchEstimate} from "../api";
|
||||
import {normalizeEstimate, TabLinks} from "./Common";
|
||||
|
||||
export const useSearch = (defaultLimit = 48, totalPages = 0, emptySearch = false, model) => {
|
||||
const navigate = useNavigate();
|
||||
const href = useHref();
|
||||
|
||||
const {searchParams, updateQuery} = useQuery();
|
||||
const searchStr = searchParams.get('q');
|
||||
const activeTags = searchParams.getAll('tag');
|
||||
const pages = usePages(defaultLimit, totalPages);
|
||||
const filter = searchParams.get('filter');
|
||||
const model_ = searchParams.get('model') || model;
|
||||
|
||||
const setSearchStr = (value) => {
|
||||
navigate({
|
||||
pathname: '/search',
|
||||
// Start new search at offset 0.
|
||||
search: createSearchParams({q: value, o: 0}).toString(),
|
||||
});
|
||||
}
|
||||
|
||||
const setTags = (tags) => {
|
||||
updateQuery({tag: tags});
|
||||
}
|
||||
|
||||
const addTag = (name) => {
|
||||
const newTags = [...activeTags, name];
|
||||
setTags(newTags);
|
||||
}
|
||||
|
||||
const removeTag = (name) => {
|
||||
const newTags = activeTags.filter(i => i !== name);
|
||||
setTags(newTags);
|
||||
}
|
||||
|
||||
return {
|
||||
activeTags,
|
||||
addTag,
|
||||
filter,
|
||||
href,
|
||||
model: model_,
|
||||
pages,
|
||||
removeTag,
|
||||
searchParams,
|
||||
searchStr,
|
||||
setSearchStr,
|
||||
setTags,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export const useSearchEstimate = (search_str, activeTags) => {
|
||||
const [fileGroups, setFileGroups] = useState('?');
|
||||
const [zims, setZims] = useState(null);
|
||||
const [zimsSum, setZimsSum] = useState('?');
|
||||
|
||||
const localFetchSearchEstimate = async () => {
|
||||
if (!search_str && !activeTags) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const estimates = await searchEstimate(search_str, activeTags);
|
||||
console.debug('estimates', estimates);
|
||||
setFileGroups(estimates['file_groups']);
|
||||
setZimsSum(estimates['zimSum']);
|
||||
setZims(estimates['zims']);
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch estimates');
|
||||
console.error(e);
|
||||
setFileGroups(null);
|
||||
setZimsSum(null);
|
||||
setZims(null);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
localFetchSearchEstimate();
|
||||
}, [search_str, JSON.stringify(activeTags)]);
|
||||
|
||||
return {fileGroups, zimsSum, zims}
|
||||
}
|
||||
|
||||
export function SearchView() {
|
||||
const {searchStr, activeTags} = useSearch();
|
||||
const estimates = useSearchEstimate(searchStr, activeTags);
|
||||
|
||||
let filesTabName = 'Files';
|
||||
let zimsTabName = 'Zims';
|
||||
if (estimates) {
|
||||
const {fileGroups, zimsSum} = estimates;
|
||||
filesTabName = `Files (${normalizeEstimate(fileGroups)})`;
|
||||
zimsTabName = `Zims (${normalizeEstimate(zimsSum)})`;
|
||||
}
|
||||
|
||||
const links = [
|
||||
{text: filesTabName, to: '/search', key: 'filesSearch', end: true},
|
||||
{text: zimsTabName, to: '/search/zim', key: 'zimsSearch'},
|
||||
]
|
||||
|
||||
return <React.Fragment>
|
||||
<TabLinks links={links}/>
|
||||
<Routes>
|
||||
<Route path='/*' element={<FilesSearchView/>}/>
|
||||
<Route path='/zim' exact element={<ZimSearchView estimates={estimates}/>}/>
|
||||
</Routes>
|
||||
</React.Fragment>
|
||||
}
|
||||
|
|
@ -6,7 +6,10 @@ import {Table} from "./Theme";
|
|||
export class SortableTable extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {sortColumn: null, direction: 'ascending'};
|
||||
this.state = {
|
||||
sortColumn: props.defaultSortColumn || null,
|
||||
direction: props.defaultDirection || 'ascending',
|
||||
};
|
||||
}
|
||||
|
||||
changeSort = (key) => {
|
||||
|
|
|
|||
|
|
@ -186,8 +186,8 @@ export function Divider({...props}) {
|
|||
}
|
||||
|
||||
export function Header(props) {
|
||||
const {t, inverted} = useContext(ThemeContext);
|
||||
props = defaultGrey({...t, ...props}, inverted);
|
||||
const {t} = useContext(ThemeContext);
|
||||
props = {...t, ...props};
|
||||
return <SHeader {...props}/>
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React, {useContext, useState} from 'react';
|
||||
import {addTag, deleteVideos, removeTag} from "../api";
|
||||
import {tagFileGroup, deleteVideos, untagFileGroup} from "../api";
|
||||
import {Link, useNavigate, useParams} from "react-router-dom";
|
||||
import _ from "lodash";
|
||||
import {
|
||||
|
|
@ -159,12 +159,12 @@ function VideoPage({videoFile, prevFile, nextFile, fetchVideo, ...props}) {
|
|||
const tabMenu = theme === darkTheme ? {inverted: true, attached: true} : {attached: true};
|
||||
|
||||
const localAddTag = async (name) => {
|
||||
await addTag(videoFile, name);
|
||||
await tagFileGroup(videoFile, name);
|
||||
await fetchVideo();
|
||||
}
|
||||
|
||||
const localRemoveTag = async (name) => {
|
||||
await removeTag(videoFile, name);
|
||||
await untagFileGroup(videoFile, name);
|
||||
await fetchVideo();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -113,17 +113,15 @@ function VideosPage() {
|
|||
}
|
||||
|
||||
const onSelect = (path, checked) => {
|
||||
if (checked && path) {
|
||||
setSelectedVideos([...selectedVideos, path]);
|
||||
} else if (path) {
|
||||
setSelectedVideos(selectedVideos.filter(i => i !== path));
|
||||
}
|
||||
const newSelectedVideos = checked && path ? [...selectedVideos, path] : selectedVideos.filter(i => i !== path);
|
||||
console.debug(`Selected Videos: ${newSelectedVideos}`);
|
||||
setSelectedVideos(newSelectedVideos);
|
||||
}
|
||||
|
||||
const onDelete = async (e) => {
|
||||
e.preventDefault();
|
||||
setDeleteOpen(false);
|
||||
const videoIds = videos.filter(i => selectedVideos.indexOf(i['path']) >= 0).map(i => i['video']['id']);
|
||||
const videoIds = videos.filter(i => selectedVideos.indexOf(i['primary_path']) >= 0).map(i => i['video']['id']);
|
||||
await deleteVideos(videoIds);
|
||||
await fetchVideos();
|
||||
setSelectedVideos([]);
|
||||
|
|
|
|||
548
app/src/components/Zim.js
Normal file
|
|
@ -0,0 +1,548 @@
|
|||
import {useOutdatedZims, useSearchZim} from "../hooks/customHooks";
|
||||
import {
|
||||
Accordion,
|
||||
Button,
|
||||
Divider,
|
||||
Header,
|
||||
Icon,
|
||||
Modal,
|
||||
ModalActions,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
Placeholder,
|
||||
Segment
|
||||
} from "./Theme";
|
||||
import {HeadlineText} from "./Headline";
|
||||
import {TextPlaceholder} from "./Placeholder";
|
||||
import {
|
||||
AccordionContent,
|
||||
AccordionTitle,
|
||||
Button as SButton,
|
||||
Dropdown,
|
||||
Icon as SIcon,
|
||||
Label,
|
||||
Message,
|
||||
PlaceholderHeader,
|
||||
PlaceholderLine,
|
||||
TableCell,
|
||||
TableFooter,
|
||||
TableHeaderCell,
|
||||
TableRow
|
||||
} from "semantic-ui-react";
|
||||
import React, {useContext, useState} from "react";
|
||||
import {
|
||||
encodeMediaPath,
|
||||
humanFileSize,
|
||||
normalizeEstimate,
|
||||
PageContainer,
|
||||
Paginator,
|
||||
TabLinks,
|
||||
TagIcon,
|
||||
useTitle
|
||||
} from "./Common";
|
||||
import Grid from "semantic-ui-react/dist/commonjs/collections/Grid";
|
||||
import {TagsSelector} from "../Tags";
|
||||
import {
|
||||
deleteOutdatedZims,
|
||||
fetchZims,
|
||||
fetchZimSubscriptions,
|
||||
refreshFiles,
|
||||
saveSettings,
|
||||
tagZimEntry,
|
||||
untagZimEntry,
|
||||
zimSubscribe,
|
||||
zimUnsubscribe
|
||||
} from "../api";
|
||||
import {useSearch} from "./Search";
|
||||
import {TagsQuerySelector} from "./Files";
|
||||
import {ThemeContext} from "../contexts/contexts";
|
||||
import {Link, Route, Routes} from "react-router-dom";
|
||||
import {SortableTable} from "./SortableTable";
|
||||
import {toast} from "react-semantic-toasts-2";
|
||||
|
||||
const VIEWER_URL = `http://${window.location.hostname}:8085/`;
|
||||
|
||||
export const OutdatedZimsMessage = ({onClick}) => {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
const {outdated, current} = useOutdatedZims();
|
||||
|
||||
const onOpen = () => setOpen(true);
|
||||
const onClose = () => setOpen(false);
|
||||
|
||||
const handleDelete = async (e) => {
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
}
|
||||
try {
|
||||
const success = await deleteOutdatedZims();
|
||||
if (success) {
|
||||
toast({
|
||||
type: 'info',
|
||||
title: 'Zims deleted',
|
||||
description: 'Outdated Zims have been deleted.',
|
||||
time: 5000,
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
type: 'error',
|
||||
title: 'Failed to delete Zims',
|
||||
description: 'Outdated Zims have NOT been deleted. See server logs.',
|
||||
time: 5000,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
const handleIgnore = async (e) => {
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
}
|
||||
const config = {ignore_outdated_zims: true};
|
||||
await saveSettings(config);
|
||||
if (onClick) {
|
||||
await onClick();
|
||||
}
|
||||
}
|
||||
|
||||
let modalContent = <Placeholder>
|
||||
<PlaceholderLine/>
|
||||
</Placeholder>;
|
||||
if (outdated && outdated.length > 0) {
|
||||
modalContent = <>
|
||||
<Header as='h3'>To Delete</Header>
|
||||
{outdated.map(i => <pre key={i}>{i}</pre>)}
|
||||
|
||||
<Header as='h3'>To Keep</Header>
|
||||
{current.map(i => <pre key={i}>{i}</pre>)}
|
||||
</>
|
||||
}
|
||||
|
||||
return <Message icon info>
|
||||
<SIcon name='question'/>
|
||||
<Message.Content>
|
||||
<Message.Header>Outdated Zim Files</Message.Header>
|
||||
New Zim files have been downloaded. Outdated Zim files can be removed.
|
||||
<p></p>
|
||||
|
||||
<SButton primary onClick={() => setOpen(true)}>Delete</SButton>
|
||||
<Modal closeIcon
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
onOpen={onOpen}
|
||||
>
|
||||
<ModalHeader>Delete</ModalHeader>
|
||||
<ModalContent>
|
||||
{modalContent}
|
||||
</ModalContent>
|
||||
<ModalActions>
|
||||
<Button color='red' floated='left' onClick={handleDelete}>Delete</Button>
|
||||
<Button onClick={onClose}>Close</Button>
|
||||
</ModalActions>
|
||||
</Modal>
|
||||
|
||||
<Link to='/files?folders=zims'><SButton>Delete Manually</SButton></Link>
|
||||
<SButton secondary onClick={handleIgnore}>Ignore Forever</SButton>
|
||||
</Message.Content>
|
||||
</Message>
|
||||
}
|
||||
|
||||
export const KiwixRestartMessage = () => {
|
||||
return <Message icon warning>
|
||||
<SIcon name='exclamation'/>
|
||||
<Message.Content>
|
||||
<Message.Header>Kiwix must be restarted</Message.Header>
|
||||
<p>New Zim files have been downloaded; you must restart your containers.</p>
|
||||
|
||||
<p>Run the following to restart your containers:</p>
|
||||
<pre> docker-compose restart</pre>
|
||||
</Message.Content>
|
||||
</Message>
|
||||
}
|
||||
|
||||
const ZimSearchEntry = ({zimId, onTag, onUntag, entry}) => {
|
||||
const {path, title, headline, tag_names} = entry;
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const url = `/api/zim/${zimId}/entry/${encodeMediaPath(path)}`;
|
||||
|
||||
const handleClick = async (e) => {
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
}
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
const localAddTag = (name) => {
|
||||
onTag(zimId, path, name);
|
||||
}
|
||||
|
||||
const localUntag = (name) => {
|
||||
onUntag(zimId, path, name);
|
||||
}
|
||||
|
||||
const tagIcon = tag_names && tag_names.length > 0 ? <TagIcon/> : null;
|
||||
|
||||
return <div style={{marginLeft: '0.5em'}}>
|
||||
<Header
|
||||
as='h3'
|
||||
className='clickable'
|
||||
onClick={handleClick}
|
||||
>
|
||||
<u>
|
||||
<HeadlineText headline={title || path} openTag={'<i>'} closeTag={'</i>'}/>
|
||||
</u>
|
||||
{tagIcon}
|
||||
</Header>
|
||||
<HeadlineText headline={headline}/>
|
||||
<Modal closeIcon
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}>
|
||||
<ModalContent>
|
||||
<div className='full-height'>
|
||||
<iframe title='textModal' src={url}
|
||||
style={{
|
||||
height: '100%', width: '100%', border: 'none', position: 'absolute', top: 0,
|
||||
// Use white to avoid iframe displaying with dark-theme.
|
||||
backgroundColor: '#ffffff',
|
||||
}}/>
|
||||
</div>
|
||||
</ModalContent>
|
||||
<ModalActions>
|
||||
<Grid>
|
||||
<Grid.Column mobile={10} tablet={14}>
|
||||
<TagsSelector selectedTagNames={tag_names} onAdd={localAddTag} onRemove={localUntag}/>
|
||||
</Grid.Column>
|
||||
<Grid.Column width={2}>
|
||||
<Button color='blue' as='a' href={url} target='_blank'>Open</Button>
|
||||
</Grid.Column>
|
||||
</Grid>
|
||||
</ModalActions>
|
||||
</Modal>
|
||||
</div>
|
||||
}
|
||||
|
||||
const ZimAccordion = ({data, index, activeIndex, onClick, searchStr, activeTags}) => {
|
||||
const {id, estimate, metadata} = data;
|
||||
const {title, date} = metadata;
|
||||
const {zim, fetchSearch, pages, loading} = useSearchZim(searchStr, id, index === activeIndex, activeTags);
|
||||
const {t} = useContext(ThemeContext);
|
||||
|
||||
const localAddTag = async (zimId, path, name) => {
|
||||
await tagZimEntry(zimId, path, name);
|
||||
await fetchSearch();
|
||||
}
|
||||
|
||||
const localUntag = async (zimId, path, name) => {
|
||||
await untagZimEntry(zimId, path, name);
|
||||
await fetchSearch();
|
||||
}
|
||||
|
||||
let body = <TextPlaceholder/>;
|
||||
if (zim && !loading) {
|
||||
const {search} = zim;
|
||||
if (search && search.length > 0) {
|
||||
body = search.map(i => <Segment vertical key={i['path']}>
|
||||
<ZimSearchEntry zimId={id} onTag={localAddTag} onUntag={localUntag} entry={i}/>
|
||||
</Segment>);
|
||||
} else {
|
||||
body = <p {...t}>No results</p>;
|
||||
}
|
||||
}
|
||||
|
||||
const paginator = <center style={{marginTop: '2em'}}>
|
||||
<Paginator activePage={pages.activePage} totalPages={pages.totalPages} onPageChange={pages.setPage}/>
|
||||
</center>;
|
||||
|
||||
return <React.Fragment>
|
||||
<AccordionTitle
|
||||
index={index}
|
||||
active={index === activeIndex}
|
||||
onClick={() => onClick(index, activeIndex)}
|
||||
>
|
||||
<Header as='h3'>
|
||||
<Icon name='dropdown'/> {title} <Label>{normalizeEstimate(estimate)}</Label>
|
||||
</Header>
|
||||
</AccordionTitle>
|
||||
<AccordionContent active={index === activeIndex}>
|
||||
<Header as='h4'>{date}</Header>
|
||||
{body}
|
||||
{paginator}
|
||||
</AccordionContent>
|
||||
</React.Fragment>
|
||||
}
|
||||
|
||||
const ZimsRefreshWarning = () => {
|
||||
return <Message icon warning onClick={refreshFiles}>
|
||||
<SIcon name='hand point right'/>
|
||||
<Message.Content>
|
||||
<Message.Header>No Zims have been indexed.</Message.Header>
|
||||
<a href='#'>Click here</a> to refresh all your files.
|
||||
</Message.Content>
|
||||
</Message>;
|
||||
}
|
||||
|
||||
export const ZimSearchView = ({estimates}) => {
|
||||
const [activeIndex, setActiveIndex] = React.useState(null);
|
||||
const {searchStr, activeTags, setTags} = useSearch();
|
||||
const {zims} = estimates;
|
||||
|
||||
const handleClick = (index, activeIndex_) => {
|
||||
setActiveIndex(index === activeIndex_ ? -1 : index);
|
||||
}
|
||||
|
||||
let body = <TextPlaceholder/>;
|
||||
if (zims && zims.length === 0) {
|
||||
body = <ZimsRefreshWarning/>;
|
||||
} else if (zims) {
|
||||
body = zims.map((i, index) => <ZimAccordion
|
||||
key={i['path']}
|
||||
index={index}
|
||||
activeIndex={activeIndex}
|
||||
data={i}
|
||||
searchStr={searchStr}
|
||||
activeTags={activeTags}
|
||||
onClick={handleClick}
|
||||
/>);
|
||||
}
|
||||
|
||||
return <>
|
||||
<TagsQuerySelector onChange={setTags}/>
|
||||
<Accordion>
|
||||
{body}
|
||||
</Accordion>
|
||||
</>
|
||||
}
|
||||
|
||||
const ViewerMessage = () => {
|
||||
return <Message warning icon>
|
||||
<SIcon name='warning'/>
|
||||
<Message.Content>
|
||||
<p>You can view your Zim files using the Kiwix app, or at <a href={VIEWER_URL}>{VIEWER_URL}</a></p>
|
||||
</Message.Content>
|
||||
</Message>
|
||||
}
|
||||
|
||||
const ZimCatalogItemRow = ({item, subscriptions, iso_639_codes, fetchSubscriptions}) => {
|
||||
const {name, languages, size} = item;
|
||||
const subscription = name in subscriptions ? subscriptions[name] : null;
|
||||
const subscriptionLanguage = subscription ? subscription['language'] : 'en';
|
||||
|
||||
const [langauge, setLanguage] = useState(subscriptionLanguage);
|
||||
const [pending, setPending] = useState(false);
|
||||
const languageChange = subscription ? langauge !== subscription['language'] : false;
|
||||
|
||||
const handleButton = async (e) => {
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
}
|
||||
let success = false;
|
||||
try {
|
||||
setPending(true);
|
||||
if (subscription && !languageChange) {
|
||||
success = await zimUnsubscribe(subscription['id']);
|
||||
} else {
|
||||
success = await zimSubscribe(name, langauge);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
setPending(false);
|
||||
if (!success) {
|
||||
toast({
|
||||
type: 'error',
|
||||
title: 'Error!',
|
||||
description: subscription ? 'Failed to unsubscribe' : 'Failed to subscribe',
|
||||
})
|
||||
}
|
||||
if (fetchSubscriptions) {
|
||||
await fetchSubscriptions();
|
||||
}
|
||||
}
|
||||
|
||||
const handleLanguageChange = (e, {value}) => {
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
}
|
||||
setLanguage(value);
|
||||
}
|
||||
|
||||
const languageOptions = languages.map(i => {
|
||||
return {key: i, value: i, text: iso_639_codes[i]}
|
||||
});
|
||||
const languageDropdown = <Dropdown fluid search selection
|
||||
placeholder='Language'
|
||||
options={languageOptions}
|
||||
value={langauge}
|
||||
onChange={handleLanguageChange}
|
||||
/>;
|
||||
|
||||
const subscribeButton = <Button
|
||||
disabled={pending}
|
||||
onClick={handleButton}>
|
||||
{subscription && !languageChange ? 'Unsubscribe' : 'Subscribe'}
|
||||
</Button>
|
||||
|
||||
return <TableRow key={name}>
|
||||
<TableCell>{name}</TableCell>
|
||||
<TableCell>{languageDropdown}</TableCell>
|
||||
<TableCell>{subscribeButton}</TableCell>
|
||||
<TableCell>{humanFileSize(size)}</TableCell>
|
||||
</TableRow>
|
||||
}
|
||||
|
||||
class ManageZim extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
catalog: undefined,
|
||||
iso_639_codes: undefined,
|
||||
subscriptions: undefined,
|
||||
zims: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
async componentDidMount() {
|
||||
await this.fetchZims();
|
||||
await this.fetchSubscriptions();
|
||||
}
|
||||
|
||||
async fetchZims() {
|
||||
try {
|
||||
const {zims} = await fetchZims();
|
||||
this.setState({zims});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
async fetchSubscriptions() {
|
||||
try {
|
||||
const {subscriptions, catalog, iso_639_codes} = await fetchZimSubscriptions();
|
||||
this.setState({subscriptions, catalog, iso_639_codes});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
zimFileTableRow = (zim, sortData) => {
|
||||
const {path, size} = zim;
|
||||
|
||||
return <TableRow key={path}>
|
||||
<TableCell>{path}</TableCell>
|
||||
<TableCell>{humanFileSize(size)}</TableCell>
|
||||
</TableRow>
|
||||
}
|
||||
|
||||
render() {
|
||||
const {zims, catalog, iso_639_codes, subscriptions} = this.state;
|
||||
|
||||
const zimFilesHeaders = [
|
||||
{key: 'path', text: 'Path', sortBy: 'path', width: 14},
|
||||
{key: 'size', text: 'Size', sortBy: 'size', width: 2},
|
||||
];
|
||||
let zimFilesBody = <Placeholder>
|
||||
<PlaceholderHeader>
|
||||
<PlaceholderLine/>
|
||||
<PlaceholderLine/>
|
||||
</PlaceholderHeader>
|
||||
</Placeholder>;
|
||||
if (zims && zims.length > 0) {
|
||||
zimFilesBody = <SortableTable
|
||||
data={zims}
|
||||
rowFunc={this.zimFileTableRow}
|
||||
rowKey='path'
|
||||
tableHeaders={zimFilesHeaders}
|
||||
/>;
|
||||
} else if (zims === null || (zims && zims.length === 0)) {
|
||||
zimFilesBody = <Message icon warning>
|
||||
<Message.Content>
|
||||
You have not subscribed to any Kiwix projects, or your files have not been refreshed.
|
||||
</Message.Content>
|
||||
</Message>;
|
||||
}
|
||||
|
||||
const kiwixCatalogHeaders = [
|
||||
{
|
||||
key: 'name', text: 'Name', 'sortBy': [i => {
|
||||
return i['name'].toLowerCase()
|
||||
}], width: 8
|
||||
},
|
||||
{key: 'language', text: 'Language', 'sortBy': null, width: 4},
|
||||
{key: 'subscription', text: 'Subscription', 'sortBy': null, width: 2},
|
||||
{key: 'size', text: 'Maximum Size', sortBy: 'size', width: 2},
|
||||
];
|
||||
let kiwixCatalog = <Placeholder>
|
||||
<PlaceholderHeader>
|
||||
<PlaceholderLine/>
|
||||
<PlaceholderLine/>
|
||||
</PlaceholderHeader>
|
||||
</Placeholder>;
|
||||
if (subscriptions && Object.keys(subscriptions).length > 0) {
|
||||
kiwixCatalog = <SortableTable
|
||||
defaultSortColumn='name'
|
||||
data={catalog}
|
||||
rowFunc={i => <ZimCatalogItemRow
|
||||
iso_639_codes={iso_639_codes}
|
||||
subscriptions={subscriptions}
|
||||
item={i}
|
||||
fetchSubscriptions={async () => await this.fetchSubscriptions()}
|
||||
/>}
|
||||
rowKey='name'
|
||||
tableHeaders={kiwixCatalogHeaders}
|
||||
footer={<TableFooter>
|
||||
<TableRow>
|
||||
<TableHeaderCell colSpan={4}>
|
||||
<p>More Zim files are available from the full Kiwix library
|
||||
<a href='https://download.kiwix.org/'>https://download.kiwix.org/</a>
|
||||
</p>
|
||||
</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableFooter>}
|
||||
/>
|
||||
}
|
||||
|
||||
return <PageContainer>
|
||||
<Header as='h2'>Zim Files</Header>
|
||||
{zimFilesBody}
|
||||
|
||||
<Header as='h2'>Kiwix Catalog</Header>
|
||||
{kiwixCatalog}
|
||||
|
||||
<Divider/>
|
||||
|
||||
<ViewerMessage/>
|
||||
</PageContainer>
|
||||
}
|
||||
}
|
||||
|
||||
function ZimApp() {
|
||||
return <iframe
|
||||
title='zim'
|
||||
src={VIEWER_URL}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
border: 'none',
|
||||
backgroundColor: '#FFFFFF',
|
||||
}}/>
|
||||
}
|
||||
|
||||
export function ZimRoute() {
|
||||
useTitle('Zim');
|
||||
|
||||
const links = [
|
||||
{text: 'Viewer', to: '/zim', key: 'zim', end: true},
|
||||
{text: 'Manage', to: '/zim/manage', key: 'manage'},
|
||||
]
|
||||
|
||||
return <div style={{marginTop: '2em'}}>
|
||||
<TabLinks links={links}/>
|
||||
<Routes>
|
||||
<Route path='/' exact element={<ZimApp/>}/>
|
||||
<Route path='manage' exact element={<ManageZim/>}/>
|
||||
</Routes>
|
||||
</div>
|
||||
}
|
||||
|
|
@ -25,6 +25,7 @@ export class Settings extends React.Component {
|
|||
hotspot_password: null,
|
||||
hotspot_ssid: null,
|
||||
hotspot_status: null,
|
||||
ignore_outdated_zims: null,
|
||||
throttle_on_startup: null,
|
||||
throttle_status: null,
|
||||
}
|
||||
|
|
@ -46,6 +47,7 @@ export class Settings extends React.Component {
|
|||
hotspot_password: settings.hotspot_password,
|
||||
hotspot_ssid: settings.hotspot_ssid,
|
||||
hotspot_status: settings.hotspot_status,
|
||||
ignore_outdated_zims: settings.ignore_outdated_zims,
|
||||
throttle_on_startup: settings.throttle_on_startup,
|
||||
throttle_status: settings.throttle_status,
|
||||
}, this.handleHotspotChange);
|
||||
|
|
@ -64,6 +66,7 @@ export class Settings extends React.Component {
|
|||
hotspot_on_startup: this.state.hotspot_on_startup,
|
||||
hotspot_password: this.state.hotspot_password,
|
||||
hotspot_ssid: this.state.hotspot_ssid,
|
||||
ignore_outdated_zims: this.state.ignore_outdated_zims,
|
||||
throttle_on_startup: this.state.throttle_on_startup,
|
||||
}
|
||||
await saveSettings(settings);
|
||||
|
|
@ -111,6 +114,7 @@ export class Settings extends React.Component {
|
|||
hotspot_on_startup,
|
||||
hotspot_password,
|
||||
hotspot_ssid,
|
||||
ignore_outdated_zims,
|
||||
pending,
|
||||
qrCodeValue,
|
||||
throttle_on_startup,
|
||||
|
|
@ -163,6 +167,15 @@ export class Settings extends React.Component {
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div style={{margin: '0.5em'}}>
|
||||
<Toggle
|
||||
label='Ignore outdated Zims'
|
||||
disabled={disabled || ignore_outdated_zims === null}
|
||||
checked={ignore_outdated_zims === true}
|
||||
onChange={checked => this.handleInputChange(null, 'ignore_outdated_zims', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormGroup inline>
|
||||
<FormInput
|
||||
label={<>
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
getDownloads,
|
||||
getFiles,
|
||||
getInventory,
|
||||
getOutdatedZims,
|
||||
getSettings,
|
||||
getStatistics,
|
||||
getStatus,
|
||||
|
|
@ -20,6 +21,8 @@ import {
|
|||
searchArchives,
|
||||
searchDirectories,
|
||||
searchVideos,
|
||||
searchZim,
|
||||
searchZims,
|
||||
setHotspot,
|
||||
setThrottle,
|
||||
} from "../api";
|
||||
|
|
@ -27,13 +30,18 @@ import {createSearchParams, useSearchParams} from "react-router-dom";
|
|||
import {enumerate, filterToMimetypes, humanFileSize, secondsToFullDuration} from "../components/Common";
|
||||
import {StatusContext} from "../contexts/contexts";
|
||||
import {toast} from "react-semantic-toasts-2";
|
||||
import {useSearch} from "../components/Search";
|
||||
|
||||
const calculatePage = (offset, limit) => {
|
||||
return offset && limit ? Math.round((offset / limit) + 1) : 1;
|
||||
}
|
||||
|
||||
const calculateTotalPages = (total, limit) => {
|
||||
return total && limit ? Math.round(total / limit) : 1;
|
||||
// Return the total divided by the limit, but always at least 1.
|
||||
if (!total || !limit || total < limit) {
|
||||
return 1;
|
||||
}
|
||||
return Math.round(total / limit) + 1;
|
||||
}
|
||||
|
||||
export const useRecurringTimeout = (callback, delay) => {
|
||||
|
|
@ -147,11 +155,12 @@ export const useArchive = (archiveId) => {
|
|||
return {archiveFile: archiveFileGroup, history, fetchArchive};
|
||||
}
|
||||
|
||||
export const usePages = (defaultLimit) => {
|
||||
export const usePages = (defaultLimit = 24, totalPages = 0) => {
|
||||
const {searchParams, updateQuery} = useQuery();
|
||||
const offset = searchParams.get('o') || 0;
|
||||
const limit = parseInt(searchParams.get('l') || defaultLimit || 24);
|
||||
const [activePage, setActivePage] = useState(calculatePage(offset, limit));
|
||||
const [totalPages_, setTotalPages] = useState(totalPages);
|
||||
|
||||
const setLimit = (value) => {
|
||||
setPage(1);
|
||||
|
|
@ -159,12 +168,22 @@ export const usePages = (defaultLimit) => {
|
|||
}
|
||||
|
||||
const setPage = (value) => {
|
||||
console.debug(`setPage ${value}`);
|
||||
setActivePage(value);
|
||||
value = value - 1; // Page really starts as 0.
|
||||
updateQuery({o: value * limit});
|
||||
}
|
||||
|
||||
return {offset, limit, setLimit, activePage, setPage};
|
||||
const setTotal = (total) => {
|
||||
const newTotalPages = calculateTotalPages(total, limit);
|
||||
console.log('newTotalPages', newTotalPages);
|
||||
setTotalPages(newTotalPages);
|
||||
}
|
||||
|
||||
// Used for useEffect.
|
||||
const effect = JSON.stringify({offset, limit, activePage});
|
||||
|
||||
return {offset, limit, setLimit, activePage, setPage, totalPages: totalPages_, setTotal, effect};
|
||||
}
|
||||
|
||||
export const useSearchArchives = (defaultLimit) => {
|
||||
|
|
@ -403,18 +422,17 @@ export const useChannels = () => {
|
|||
}
|
||||
|
||||
export const useSearchFiles = (defaultLimit = 48, emptySearch = false, model) => {
|
||||
const {searchParams, updateQuery} = useQuery();
|
||||
const limit = searchParams.get('l') || defaultLimit;
|
||||
const offset = searchParams.get('o') || 0;
|
||||
const searchStr = searchParams.get('q');
|
||||
const filter = searchParams.get('filter');
|
||||
const model_ = searchParams.get('model');
|
||||
const activeTags = searchParams.getAll('tag');
|
||||
const {
|
||||
activeTags,
|
||||
pages,
|
||||
searchStr,
|
||||
filter,
|
||||
model: model_,
|
||||
setSearchStr
|
||||
} = useSearch(defaultLimit, emptySearch, model);
|
||||
const {view} = useSearchView();
|
||||
|
||||
const [searchFiles, setSearchFiles] = useState(null);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
const [activePage, setActivePage] = useState(calculatePage(offset, limit));
|
||||
const headline = view === 'headline';
|
||||
|
||||
const localSearchFiles = async () => {
|
||||
|
|
@ -423,13 +441,13 @@ export const useSearchFiles = (defaultLimit = 48, emptySearch = false, model) =>
|
|||
}
|
||||
const mimetypes = filterToMimetypes(filter);
|
||||
setSearchFiles(null);
|
||||
setTotalPages(0);
|
||||
try {
|
||||
let [file_groups, total] = await filesSearch(
|
||||
offset, limit, searchStr, mimetypes, model || model_, activeTags, headline);
|
||||
pages.offset, pages.limit, searchStr, mimetypes, model || model_, activeTags, headline);
|
||||
setSearchFiles(file_groups);
|
||||
setTotalPages(calculateTotalPages(total, limit));
|
||||
pages.setTotal(total);
|
||||
} catch (e) {
|
||||
pages.setTotal(0);
|
||||
console.error(e);
|
||||
toast({
|
||||
type: 'error',
|
||||
|
|
@ -442,25 +460,16 @@ export const useSearchFiles = (defaultLimit = 48, emptySearch = false, model) =>
|
|||
|
||||
useEffect(() => {
|
||||
localSearchFiles();
|
||||
}, [searchStr, limit, offset, activePage, filter, model, model_, JSON.stringify(activeTags), headline]);
|
||||
}, [searchStr, pages.effect, filter, model, model_, JSON.stringify(activeTags), headline]);
|
||||
|
||||
const setPage = (i) => {
|
||||
i = parseInt(i);
|
||||
let l = parseInt(limit);
|
||||
updateQuery({o: (l * i) - l})
|
||||
setActivePage(i);
|
||||
}
|
||||
|
||||
const setSearchStr = (value) => {
|
||||
updateQuery({q: value, o: null});
|
||||
}
|
||||
|
||||
const setLimit = (value) => {
|
||||
setActivePage(1);
|
||||
updateQuery({l: value, o: 0});
|
||||
}
|
||||
|
||||
return {searchFiles, totalPages, limit, searchStr, filter, setSearchStr, activePage, setPage, setLimit, activeTags};
|
||||
return {
|
||||
searchFiles,
|
||||
searchStr,
|
||||
filter,
|
||||
setSearchStr,
|
||||
pages,
|
||||
activeTags
|
||||
};
|
||||
}
|
||||
|
||||
export const useBrowseFiles = () => {
|
||||
|
|
@ -930,6 +939,106 @@ export const useUploadFile = () => {
|
|||
return {files, setFiles: handleFilesChange, progresses, destination, setDestination, doClear, doUpload}
|
||||
}
|
||||
|
||||
export const useSearchZims = (defaultLimit) => {
|
||||
const {offset, limit, setLimit, activePage, setPage} = usePages(defaultLimit);
|
||||
const {searchParams, updateQuery} = useQuery();
|
||||
const searchStr = searchParams.get('q') || '';
|
||||
|
||||
const [zims, setZims] = useState(null);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
|
||||
const localSearchZims = async () => {
|
||||
setZims(null);
|
||||
try {
|
||||
let [zims_,] = await searchZims(offset, limit, searchStr);
|
||||
setZims(zims_);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
toast({
|
||||
type: 'error',
|
||||
title: 'Unexpected server response',
|
||||
description: 'Could not get archives',
|
||||
time: 5000,
|
||||
});
|
||||
setZims([]);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
localSearchZims();
|
||||
}, [searchStr, limit, activePage, setZims]);
|
||||
|
||||
const setSearchStr = (value) => {
|
||||
updateQuery({q: value, o: 0, order: undefined});
|
||||
}
|
||||
|
||||
const setOrderBy = (value) => {
|
||||
setPage(1);
|
||||
updateQuery({order: value});
|
||||
}
|
||||
|
||||
return {
|
||||
zims,
|
||||
limit,
|
||||
setLimit,
|
||||
offset,
|
||||
setOrderBy,
|
||||
totalPages,
|
||||
activePage,
|
||||
setPage,
|
||||
searchStr,
|
||||
setSearchStr,
|
||||
fetchArchives: localSearchZims,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export const useSearchZim = (searchStr, zimId, active, activeTags, defaultLimit = 10) => {
|
||||
const [zim, setZim] = useState(null);
|
||||
const pages = usePages(defaultLimit);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const localFetchSearch = async () => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const zim = await searchZim(pages.offset, pages.limit, searchStr, zimId, activeTags);
|
||||
setZim(zim);
|
||||
pages.setTotal(zim.estimate);
|
||||
} catch (e) {
|
||||
pages.setTotal(0);
|
||||
console.error(`Failed to search Zim ${zimId}`);
|
||||
console.error(e);
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
localFetchSearch();
|
||||
}, [active, searchStr, JSON.stringify(activeTags), pages.effect]);
|
||||
|
||||
return {zim, fetchSearch: localFetchSearch, pages, loading}
|
||||
}
|
||||
|
||||
export const useOutdatedZims = () => {
|
||||
const [outdated, setOutdated] = useState(null);
|
||||
const [current, setCurrent] = useState(null);
|
||||
|
||||
const localFetchOutdatedZims = async () => {
|
||||
const zims = await getOutdatedZims();
|
||||
setOutdated(zims['outdated']);
|
||||
setCurrent(zims['current']);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
localFetchOutdatedZims();
|
||||
}, []);
|
||||
|
||||
return {outdated, current}
|
||||
}
|
||||
|
||||
export const useVINDecoder = (defaultVINNumber = '') => {
|
||||
const [value, setValue] = useState(defaultVINNumber);
|
||||
// The response vin from the API.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
# Import conftest for test fixtures
|
||||
from wrolpi.common import import_modules
|
||||
from modules.archive.conftest import * # noqa
|
||||
from modules.inventory.conftest import * # noqa
|
||||
from modules.videos.conftest import * # noqa
|
||||
from modules.zim.conftest import * # noqa
|
||||
from wrolpi.conftest import * # noqa
|
||||
|
||||
# Import modules and their conftest.py.
|
||||
import_modules()
|
||||
|
|
|
|||
|
|
@ -92,6 +92,15 @@ services:
|
|||
healthcheck:
|
||||
test: [ 'CMD-SHELL', 'curl http://127.0.0.1:80' ]
|
||||
|
||||
zim:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/zim/Dockerfile
|
||||
volumes:
|
||||
- './test:/media/wrolpi'
|
||||
ports:
|
||||
- '8085:8085'
|
||||
|
||||
volumes:
|
||||
openstreetmap-data:
|
||||
external: true
|
||||
|
|
|
|||
7
docker/zim/Dockerfile
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
FROM debian:12
|
||||
ENV DOCKER=true
|
||||
|
||||
RUN apt update && apt-get install -y kiwix kiwix-tools
|
||||
COPY docker/zim/* /
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
7
docker/zim/entrypoint.sh
Executable file
|
|
@ -0,0 +1,7 @@
|
|||
#! /usr/bin/env bash
|
||||
rm -f /library.xml
|
||||
|
||||
# Find all Zim files and import them.
|
||||
find /media/wrolpi -iname '*.zim' -exec /import_zim.sh {} \;
|
||||
|
||||
kiwix-serve --library /library.xml --address 0.0.0.0 --port 8085
|
||||
7
docker/zim/import_zim.sh
Executable file
|
|
@ -0,0 +1,7 @@
|
|||
#! /usr/bin/env bash
|
||||
# This should not be used outside the Zim docker container!!
|
||||
|
||||
# This file echos and imports Zim files.
|
||||
|
||||
echo Importing zim $*
|
||||
kiwix-manage /library.xml add $*
|
||||
BIN
icon.png
|
Before Width: | Height: | Size: 290 KiB After Width: | Height: | Size: 290 KiB |
10
main.py
|
|
@ -9,7 +9,7 @@ from sanic.signals import Event
|
|||
|
||||
from wrolpi import flags, BEFORE_STARTUP_FUNCTIONS
|
||||
from wrolpi import root_api, admin
|
||||
from wrolpi.common import logger, get_config, import_modules, check_media_directory, limit_concurrent, \
|
||||
from wrolpi.common import logger, get_config, check_media_directory, limit_concurrent, \
|
||||
wrol_mode_enabled, cancel_refresh_tasks, set_log_level
|
||||
from wrolpi.downloader import download_manager, import_downloads_config
|
||||
from wrolpi.root_api import api_app
|
||||
|
|
@ -66,7 +66,6 @@ def launch_interactive_shell():
|
|||
import code
|
||||
from wrolpi.db import get_db_session
|
||||
|
||||
modules = import_modules()
|
||||
with get_db_session() as session:
|
||||
code.interact(banner=INTERACTIVE_BANNER, local=locals())
|
||||
|
||||
|
|
@ -147,8 +146,8 @@ def main():
|
|||
|
||||
check_media_directory()
|
||||
|
||||
# Import the API in every module. Each API should attach itself to `root_api`.
|
||||
import_modules()
|
||||
# Import modules before calling BEFORE_STARTUP_FUNCTIONS.
|
||||
import modules # noqa
|
||||
|
||||
# Run the startup functions
|
||||
for func in BEFORE_STARTUP_FUNCTIONS:
|
||||
|
|
@ -172,6 +171,9 @@ async def startup(app: Sanic):
|
|||
flags.init_flags()
|
||||
await import_downloads_config()
|
||||
|
||||
from modules.zim.lib import flag_outdated_zim_files
|
||||
flag_outdated_zim_files()
|
||||
|
||||
|
||||
@api_app.after_server_start
|
||||
@limit_concurrent(1)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
from .archive import api
|
||||
from .inventory import api
|
||||
from .map import api
|
||||
from .otp import api
|
||||
from .videos import api
|
||||
from .zim import api, test
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import asyncio
|
||||
import json
|
||||
from abc import ABC
|
||||
from typing import Tuple, List
|
||||
from typing import List
|
||||
|
||||
from sqlalchemy import not_
|
||||
from sqlalchemy.orm import Session
|
||||
|
|
|
|||
|
|
@ -20,7 +20,8 @@ from wrolpi.common import get_media_directory, logger, extract_domain, chdir, es
|
|||
from wrolpi.dates import now, Seconds
|
||||
from wrolpi.db import get_db_session, get_db_curs, optional_session
|
||||
from wrolpi.errors import UnknownArchive, InvalidOrderBy
|
||||
from wrolpi.files.lib import handle_file_group_search_results, tag_names_to_sub_select
|
||||
from wrolpi.files.lib import handle_file_group_search_results
|
||||
from wrolpi.tags import tag_names_to_file_group_sub_select
|
||||
from wrolpi.vars import DOCKERIZED, PYTEST
|
||||
|
||||
logger = logger.getChild(__name__)
|
||||
|
|
@ -417,7 +418,7 @@ def search_archives(search_str: str, domain: str, limit: int, offset: int, order
|
|||
|
||||
if tag_names:
|
||||
# Filter all FileGroups by those that have been tagged with the provided tag names.
|
||||
tags_stmt, params_ = tag_names_to_sub_select(tag_names)
|
||||
tags_stmt, params_ = tag_names_to_file_group_sub_select(tag_names)
|
||||
params.update(params_)
|
||||
wheres.append(f'fg.id = ANY({tags_stmt})')
|
||||
|
||||
|
|
|
|||
|
|
@ -3,8 +3,6 @@ from datetime import datetime
|
|||
from decimal import Decimal
|
||||
from typing import Union, Optional
|
||||
|
||||
from wrolpi.dates import from_timestamp
|
||||
|
||||
|
||||
@dataclass
|
||||
class ItemPostRequest:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import json
|
||||
from http import HTTPStatus
|
||||
|
||||
import pytest
|
||||
|
||||
from modules.inventory import Item
|
||||
from modules.inventory.inventory import save_item
|
||||
|
||||
|
|
@ -73,33 +75,34 @@ def test_item_api(test_session, init_test_inventory, test_client):
|
|||
assert response.status_code == HTTPStatus.NO_CONTENT, response.json
|
||||
|
||||
|
||||
def test_inventory_api(test_session, test_client):
|
||||
@pytest.mark.asyncio
|
||||
async def test_inventory_api(test_session, test_async_client):
|
||||
"""An Inventory can be created."""
|
||||
inventory = dict(name='foo')
|
||||
request, response = test_client.post('/api/inventory', content=json.dumps(inventory))
|
||||
request, response = await test_async_client.post('/api/inventory', content=json.dumps(inventory))
|
||||
assert response.status_code == HTTPStatus.CREATED, response.status_code
|
||||
|
||||
request, response = test_client.get('/api/inventory')
|
||||
request, response = await test_async_client.get('/api/inventory')
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
inventory = response.json['inventories'][0]
|
||||
inventory_id = inventory['id']
|
||||
|
||||
request, response = test_client.get(f'/api/inventory/{inventory_id}')
|
||||
request, response = await test_async_client.get(f'/api/inventory/{inventory_id}')
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
|
||||
request, response = test_client.get('/api/inventory/categories')
|
||||
request, response = await test_async_client.get('/api/inventory/categories')
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
|
||||
request, response = test_client.get('/api/inventory/brands')
|
||||
request, response = await test_async_client.get('/api/inventory/brands')
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
|
||||
inventory = dict(name='new name')
|
||||
request, response = test_client.put(f'/api/inventory/{inventory_id}', content=json.dumps(inventory))
|
||||
request, response = await test_async_client.put(f'/api/inventory/{inventory_id}', content=json.dumps(inventory))
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, response.status_code
|
||||
|
||||
request, response = test_client.delete(f'/api/inventory/{inventory_id}')
|
||||
request, response = await test_async_client.delete(f'/api/inventory/{inventory_id}')
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, response.status_code
|
||||
|
||||
request, response = test_client.get('/api/inventory')
|
||||
request, response = await test_async_client.get('/api/inventory')
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert len(response.json['inventories']) == 0
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import pathlib
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Union
|
||||
|
||||
|
|
|
|||
|
|
@ -332,7 +332,8 @@ def test_channel_by_id(test_session, test_client, simple_channel, simple_video):
|
|||
assert response.status_code == HTTPStatus.OK
|
||||
|
||||
|
||||
def test_channel_crud(test_session, test_client, test_directory, test_download_manager):
|
||||
@pytest.mark.asyncio
|
||||
async def test_channel_crud(test_session, test_async_client, test_directory, test_download_manager):
|
||||
channel_directory = test_directory / 'channel directory'
|
||||
channel_directory.mkdir()
|
||||
|
||||
|
|
@ -344,15 +345,15 @@ def test_channel_crud(test_session, test_client, test_directory, test_download_m
|
|||
)
|
||||
|
||||
# Channel doesn't exist
|
||||
request, response = test_client.get('/api/videos/channels/examplechannel1')
|
||||
request, response = await test_async_client.get('/api/videos/channels/examplechannel1')
|
||||
assert response.status_code == HTTPStatus.NOT_FOUND, f'Channel exists: {response.json}'
|
||||
|
||||
# Create it
|
||||
request, response = test_client.post('/api/videos/channels', content=json.dumps(new_channel))
|
||||
request, response = await test_async_client.post('/api/videos/channels', content=json.dumps(new_channel))
|
||||
assert response.status_code == HTTPStatus.CREATED, response.json
|
||||
location = response.headers['Location']
|
||||
|
||||
request, response = test_client.get(location)
|
||||
request, response = await test_async_client.get(location)
|
||||
assert response.status_code == HTTPStatus.OK, response.json
|
||||
created = response.json['channel']
|
||||
assert created
|
||||
|
|
@ -369,13 +370,13 @@ def test_channel_crud(test_session, test_client, test_directory, test_download_m
|
|||
f'Channel directory is absolute: {created["directory"]}'
|
||||
|
||||
# Can't create it again
|
||||
request, response = test_client.post('/api/videos/channels', content=json.dumps(new_channel))
|
||||
request, response = await test_async_client.post('/api/videos/channels', content=json.dumps(new_channel))
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
|
||||
def put_and_fetch(new_channel_):
|
||||
request, response = test_client.put(location, content=json.dumps(new_channel))
|
||||
async def put_and_fetch(new_channel_):
|
||||
request, response = await test_async_client.put(location, content=json.dumps(new_channel))
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, response.status_code
|
||||
request, response = test_client.get(location)
|
||||
request, response = await test_async_client.get(location)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
channel = response.json['channel']
|
||||
return channel
|
||||
|
|
@ -384,7 +385,7 @@ def test_channel_crud(test_session, test_client, test_directory, test_download_m
|
|||
new_channel['name'] = 'Example Channel 2'
|
||||
new_channel['directory'] = str(new_channel['directory']) # noqa
|
||||
new_channel['download_frequency'] = 60
|
||||
channel = put_and_fetch(new_channel)
|
||||
channel = await put_and_fetch(new_channel)
|
||||
assert channel['id'] == 1
|
||||
assert channel['name'] == 'Example Channel 2'
|
||||
assert channel['directory'] == channel_directory.name
|
||||
|
|
@ -397,22 +398,22 @@ def test_channel_crud(test_session, test_client, test_directory, test_download_m
|
|||
|
||||
# Update with no download frequency.
|
||||
new_channel['download_frequency'] = None
|
||||
channel = put_and_fetch(new_channel)
|
||||
channel = await put_and_fetch(new_channel)
|
||||
assert channel['download_frequency'] is None
|
||||
# Download was deleted.
|
||||
assert len(test_download_manager.get_downloads(test_session)) == 0
|
||||
|
||||
# Can't update channel that doesn't exist
|
||||
request, response = test_client.put('/api/videos/channels/doesnt_exist',
|
||||
request, response = await test_async_client.put('/api/videos/channels/doesnt_exist',
|
||||
content=json.dumps(new_channel))
|
||||
assert response.status_code == HTTPStatus.NOT_FOUND
|
||||
|
||||
# Delete the new channel
|
||||
request, response = test_client.delete(location)
|
||||
request, response = await test_async_client.delete(location)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT
|
||||
|
||||
# Cant delete it again
|
||||
request, response = test_client.delete(location)
|
||||
request, response = await test_async_client.delete(location)
|
||||
assert response.status_code == HTTPStatus.NOT_FOUND
|
||||
|
||||
|
||||
|
|
@ -433,7 +434,8 @@ def test_channel_creation_with_download(test_client, test_session, test_director
|
|||
assert download.sub_downloader == 'video'
|
||||
|
||||
|
||||
def test_change_channel_url(test_client, test_session, test_download_manager, download_channel,
|
||||
@pytest.mark.asyncio
|
||||
async def test_change_channel_url(test_async_client, test_session, test_download_manager, download_channel,
|
||||
test_download_manager_config):
|
||||
# Change the Channel's URL.
|
||||
download_channel.update({'url': 'https://example.com/new-url'})
|
||||
|
|
|
|||
|
|
@ -1,14 +1,10 @@
|
|||
import asyncio
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from modules.videos import schema
|
||||
from modules.videos.channel import lib
|
||||
from modules.videos.lib import save_channels_config, import_channels_config
|
||||
from modules.videos.models import Channel, Video
|
||||
from modules.videos.models import Channel
|
||||
from wrolpi.errors import UnknownChannel
|
||||
from wrolpi.files.lib import refresh_files
|
||||
|
||||
|
||||
@pytest.mark.parametrize('params', [
|
||||
|
|
|
|||
|
|
@ -1,8 +1,3 @@
|
|||
import pytest
|
||||
|
||||
from modules.videos import Channel
|
||||
|
||||
|
||||
def test_delete_channel_no_url(test_session, test_client, channel_factory):
|
||||
"""
|
||||
A Channel can be deleted even if it has no URL.
|
||||
|
|
|
|||
|
|
@ -4,14 +4,13 @@ import pathlib
|
|||
import re
|
||||
import traceback
|
||||
from abc import ABC
|
||||
from typing import Tuple, List, Optional, Dict
|
||||
from typing import Tuple, List, Dict
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import yt_dlp.utils
|
||||
from sqlalchemy.orm import Session
|
||||
from yt_dlp import YoutubeDL
|
||||
from yt_dlp.extractor import YoutubeTabIE # noqa
|
||||
from yt_dlp.utils import UnsupportedError, DownloadError
|
||||
|
||||
from wrolpi.cmd import YT_DLP_BIN
|
||||
from wrolpi.common import logger, get_media_directory, escape_file_name, resolve_generators, background_task
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import contextlib
|
||||
import datetime
|
||||
import functools
|
||||
import html
|
||||
import pathlib
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import pathlib
|
|||
from pathlib import Path
|
||||
from typing import Optional, Dict, List, Union
|
||||
|
||||
from sqlalchemy import Column, Integer, String, Boolean, JSON, Date, ARRAY, ForeignKey, BigInteger, any_
|
||||
from sqlalchemy import Column, Integer, String, Boolean, JSON, Date, ARRAY, ForeignKey, BigInteger
|
||||
from sqlalchemy.orm import relationship, Session, deferred
|
||||
from sqlalchemy.orm.collections import InstrumentedList
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
from dataclasses import dataclass, field
|
||||
from datetime import date, datetime
|
||||
from datetime import date
|
||||
from typing import List, Optional
|
||||
|
||||
from modules.videos.video.lib import DEFAULT_VIDEO_ORDER, VIDEO_QUERY_LIMIT
|
||||
|
|
|
|||
|
|
@ -2,14 +2,13 @@ import json
|
|||
from http import HTTPStatus
|
||||
from pathlib import Path
|
||||
|
||||
import mock.mock
|
||||
import pytest
|
||||
|
||||
from modules.videos.models import Video, Channel
|
||||
from modules.videos.video import lib as video_lib
|
||||
from wrolpi.db import get_db_curs
|
||||
from wrolpi.downloader import DownloadFrequency, Download
|
||||
from wrolpi.files.lib import refresh_files, split_path_stem_and_suffix
|
||||
from wrolpi.files.lib import refresh_files
|
||||
from wrolpi.files.models import FileGroup
|
||||
|
||||
|
||||
|
|
@ -53,12 +52,12 @@ def test_refresh_videos(test_client, test_session, test_directory, simple_channe
|
|||
with_info_json=True, with_poster_ext='jpg')
|
||||
test_session.commit()
|
||||
video1.file_group.size = video1.file_group.data = None
|
||||
video1.file_group.files = list()
|
||||
video1.file_group.paths = list()
|
||||
# video2 is in the test directory.
|
||||
video2 = video_factory(channel_id=simple_channel.id, with_video_file=True, with_info_json=True,
|
||||
with_poster_ext='jpg')
|
||||
video2.file_group.data = None
|
||||
video2.file_group.files = []
|
||||
video2.file_group.paths = []
|
||||
test_session.commit()
|
||||
|
||||
assert not video1.file_group.size, 'video1 should not have size during creation'
|
||||
|
|
|
|||
|
|
@ -1,17 +1,13 @@
|
|||
import pathlib
|
||||
import subprocess
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from typing import List
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
import pytz
|
||||
from PIL import Image
|
||||
|
||||
from modules.videos.models import Channel, Video
|
||||
from wrolpi.common import get_absolute_media_path, sanitize_link
|
||||
from wrolpi.dates import now
|
||||
from wrolpi.common import get_absolute_media_path
|
||||
from wrolpi.downloader import Download, DownloadFrequency
|
||||
from wrolpi.vars import PROJECT_DIR
|
||||
from .. import common
|
||||
|
|
|
|||
|
|
@ -158,6 +158,7 @@ async def test_download_video_tags(test_session, video_download_manager, video_f
|
|||
|
||||
|
||||
@skip_circleci
|
||||
@pytest.mark.skip # TODO this test fails when running multiple tests.
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_channel(test_session, simple_channel, video_download_manager, video_file,
|
||||
mock_video_extract_info, mock_video_prepare_filename,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import pytest
|
||||
|
||||
from modules.videos.models import Video
|
||||
from wrolpi.dates import now
|
||||
from wrolpi.errors import FileGroupIsTagged
|
||||
from wrolpi.files import lib as files_lib
|
||||
from wrolpi.files.models import FileGroup
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ from modules.videos.models import Video
|
|||
from wrolpi.common import run_after, logger
|
||||
from wrolpi.db import get_db_session, optional_session
|
||||
from wrolpi.errors import UnknownVideo
|
||||
from wrolpi.files.lib import handle_file_group_search_results, tag_names_to_sub_select
|
||||
from wrolpi.files.lib import handle_file_group_search_results, tag_names_to_file_group_sub_select
|
||||
from ..lib import save_channels_config
|
||||
|
||||
logger.getChild(__name__)
|
||||
|
|
@ -103,7 +103,7 @@ def search_videos(
|
|||
|
||||
if tag_names:
|
||||
# Filter all FileGroups by those that have been tagged with the provided tag names.
|
||||
tags_stmt, params_ = tag_names_to_sub_select(tag_names)
|
||||
tags_stmt, params_ = tag_names_to_file_group_sub_select(tag_names)
|
||||
params.update(params_)
|
||||
wheres.append(f'fg.id = ANY({tags_stmt})')
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ from modules.videos.models import Video
|
|||
from modules.videos.video.lib import get_video_for_app
|
||||
from wrolpi.dates import now
|
||||
from wrolpi.errors import API_ERRORS, WROLModeEnabled
|
||||
from wrolpi.root_api import api_app
|
||||
|
||||
|
||||
def test_get_video_prev_next(test_session, channel_factory, video_factory):
|
||||
|
|
@ -121,7 +120,10 @@ def test_video_delete(test_client, test_session, test_directory, channel_factory
|
|||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
|
||||
|
||||
def test_wrol_mode(test_directory, simple_channel, simple_video, wrol_mode_fixture, test_download_manager, tag_factory):
|
||||
@pytest.mark.asyncio
|
||||
async def test_wrol_mode(test_async_client, test_directory, simple_channel, simple_video, wrol_mode_fixture,
|
||||
test_download_manager,
|
||||
tag_factory):
|
||||
"""Many methods are blocked when WROL Mode is enabled."""
|
||||
channel = dumps(dict(name=simple_channel.name, directory=str(simple_channel.directory)))
|
||||
tag = tag_factory()
|
||||
|
|
@ -129,32 +131,32 @@ def test_wrol_mode(test_directory, simple_channel, simple_video, wrol_mode_fixtu
|
|||
wrol_mode_fixture(True)
|
||||
|
||||
# Can't create, update, or delete a channel.
|
||||
_, resp = api_app.test_client.post('/api/videos/channels', content=channel)
|
||||
_, resp = await test_async_client.post('/api/videos/channels', content=channel)
|
||||
assert resp.status_code == HTTPStatus.FORBIDDEN
|
||||
assert resp.json['code'] == API_ERRORS[WROLModeEnabled]['code']
|
||||
_, resp = api_app.test_client.put(f'/api/videos/channels/{simple_channel.id}', content=channel)
|
||||
_, resp = await test_async_client.put(f'/api/videos/channels/{simple_channel.id}', content=channel)
|
||||
assert resp.status_code == HTTPStatus.FORBIDDEN
|
||||
assert resp.json['code'] == API_ERRORS[WROLModeEnabled]['code']
|
||||
_, resp = api_app.test_client.delete(f'/api/videos/channels/{simple_channel.id}')
|
||||
_, resp = await test_async_client.delete(f'/api/videos/channels/{simple_channel.id}')
|
||||
assert resp.status_code == HTTPStatus.FORBIDDEN
|
||||
assert resp.json['code'] == API_ERRORS[WROLModeEnabled]['code']
|
||||
|
||||
# Can't delete a video
|
||||
_, resp = api_app.test_client.delete(f'/api/videos/video/{simple_video.id}')
|
||||
_, resp = await test_async_client.delete(f'/api/videos/video/{simple_video.id}')
|
||||
assert resp.status_code == HTTPStatus.FORBIDDEN
|
||||
assert resp.json['code'] == API_ERRORS[WROLModeEnabled]['code']
|
||||
|
||||
# Can't refresh or download
|
||||
_, resp = api_app.test_client.post('/api/files/refresh')
|
||||
_, resp = await test_async_client.post('/api/files/refresh')
|
||||
assert resp.status_code == HTTPStatus.FORBIDDEN
|
||||
assert resp.json['code'] == API_ERRORS[WROLModeEnabled]['code']
|
||||
_, resp = api_app.test_client.post(f'/api/videos/channels/download/{simple_channel.id}')
|
||||
_, resp = await test_async_client.post(f'/api/videos/channels/download/{simple_channel.id}')
|
||||
assert resp.status_code == HTTPStatus.FORBIDDEN
|
||||
assert resp.json['code'] == API_ERRORS[WROLModeEnabled]['code']
|
||||
|
||||
# THE REST OF THESE METHODS ARE ALLOWED
|
||||
content = dict(video_id=simple_video.id, tag_name=tag.name)
|
||||
_, resp = api_app.test_client.post('/api/videos/tag', content=json.dumps(content))
|
||||
_, resp = await test_async_client.post('/api/videos/tag', content=json.dumps(content))
|
||||
assert resp.status_code == HTTPStatus.NO_CONTENT
|
||||
|
||||
assert test_download_manager.stopped.is_set()
|
||||
|
|
|
|||
3
modules/zim/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from .downloader import KiwixCatalogDownloader # noqa
|
||||
|
||||
__all__ = []
|
||||
156
modules/zim/api.py
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
import urllib.parse
|
||||
from http import HTTPStatus
|
||||
|
||||
from sanic import Request, response
|
||||
from sanic_ext import validate
|
||||
from sanic_ext.extensions.openapi import openapi
|
||||
|
||||
from wrolpi import lang
|
||||
from wrolpi.common import logger
|
||||
from wrolpi.db import get_db_session
|
||||
from wrolpi.downloader import download_manager
|
||||
from wrolpi.events import Events
|
||||
from wrolpi.root_api import get_blueprint, json_response
|
||||
from . import lib, schema
|
||||
|
||||
bp = get_blueprint('Zim', '/api/zim')
|
||||
|
||||
logger = logger.getChild(__name__)
|
||||
|
||||
|
||||
@bp.get('/')
|
||||
@openapi.definition(
|
||||
summary='List all known Zim files',
|
||||
response=schema.GetZimsResponse,
|
||||
)
|
||||
async def get_zims(_: Request):
|
||||
with get_db_session() as session:
|
||||
zims = lib.get_zims(session=session)
|
||||
resp = {
|
||||
'zims': [i.__json__() for i in zims],
|
||||
}
|
||||
return json_response(resp)
|
||||
|
||||
|
||||
@bp.delete('/<zim_ids:[0-9,]+>')
|
||||
@openapi.definition(
|
||||
summary='Delete all Zim files',
|
||||
)
|
||||
async def delete_zims(_: Request, zim_ids: str):
|
||||
zim_ids = [int(i) for i in zim_ids.split(',')]
|
||||
lib.delete_zims(zim_ids)
|
||||
return response.empty()
|
||||
|
||||
|
||||
@bp.post('/search/<zim_id:[0-9]+>')
|
||||
@openapi.definition(
|
||||
summary='Search all entries of a Zim',
|
||||
body=schema.ZimSearchRequest,
|
||||
response=schema.ZimSearchResponse,
|
||||
)
|
||||
@validate(schema.ZimSearchRequest)
|
||||
async def search_zim(_: Request, zim_id: int, body: schema.ZimSearchRequest):
|
||||
headlines = lib.headline_zim(body.search_str, zim_id, tag_names=body.tag_names, offset=body.offset,
|
||||
limit=body.limit)
|
||||
return json_response({'zim': headlines})
|
||||
|
||||
|
||||
@bp.get('/<zim_id:[0-9]+>/entry/<zim_path:[ -~/]*>')
|
||||
@openapi.definition(
|
||||
summary='Read the entry at `zim_path` from the Zim file',
|
||||
)
|
||||
async def get_zim_entry(_: Request, zim_id: int, zim_path: str):
|
||||
zim_path = urllib.parse.unquote(zim_path)
|
||||
entry = lib.get_entry(zim_path, zim_id=zim_id)
|
||||
content = bytes(entry.get_item().content)
|
||||
try:
|
||||
resp = response.html(content.decode('UTF-8'))
|
||||
logger.debug(f'Returning ZIM HTML response {repr(str(zim_path))}')
|
||||
except UnicodeDecodeError:
|
||||
resp = response.raw(content)
|
||||
logger.debug(f'Returning ZIM raw response {repr(str(zim_path))}')
|
||||
|
||||
return resp
|
||||
|
||||
|
||||
@bp.post('/tag')
|
||||
@openapi.definition(
|
||||
summary='Tag a Zim entry',
|
||||
body=schema.TagZimEntry,
|
||||
)
|
||||
@validate(schema.TagZimEntry)
|
||||
async def post_zim_tag(_: Request, body: schema.TagZimEntry):
|
||||
await lib.add_tag(body.tag_name, body.zim_id, body.zim_entry)
|
||||
return response.empty(HTTPStatus.CREATED)
|
||||
|
||||
|
||||
@bp.post('/untag')
|
||||
@openapi.definition(
|
||||
summary='Untag a Zim entry',
|
||||
body=schema.TagZimEntry,
|
||||
)
|
||||
@validate(schema.TagZimEntry)
|
||||
async def post_zim_untag(_: Request, body: schema.TagZimEntry):
|
||||
await lib.untag(body.tag_name, body.zim_id, body.zim_entry)
|
||||
return response.empty(HTTPStatus.NO_CONTENT)
|
||||
|
||||
|
||||
@bp.get('/subscribe')
|
||||
@openapi.definition(
|
||||
summary='Retrieve Zim subscriptions',
|
||||
response=schema.ZimSubscriptions,
|
||||
)
|
||||
async def get_zim_subscriptions(_: Request):
|
||||
catalog = lib.get_kiwix_catalog()
|
||||
with get_db_session() as session:
|
||||
subscriptions = {i: j.__json__() for i, j in lib.get_kiwix_subscriptions(session).items()}
|
||||
resp = dict(
|
||||
catalog=catalog,
|
||||
iso_639_codes=lang.ISO_639_CODES,
|
||||
subscriptions=subscriptions,
|
||||
)
|
||||
return json_response(resp)
|
||||
|
||||
|
||||
@bp.post('/subscribe')
|
||||
@openapi.definition(
|
||||
summary='Subscribe to a particular Kiwix Zim',
|
||||
body=schema.TagZimSubscribe,
|
||||
)
|
||||
@validate(schema.TagZimSubscribe)
|
||||
async def post_zim_subscribe(_: Request, body: schema.TagZimSubscribe):
|
||||
await lib.subscribe(body.name, body.language)
|
||||
if download_manager.disabled.is_set() or download_manager.stopped.is_set():
|
||||
# Downloads are disabled, warn the user.
|
||||
Events.send_downloads_disabled('Download created. But, downloads are disabled.')
|
||||
return response.empty(HTTPStatus.CREATED)
|
||||
|
||||
|
||||
@bp.delete('/subscribe/<subscription_id:[0-9]+>')
|
||||
@openapi.definition(
|
||||
summary='Unsubscribe to a particular Kiwix Zim',
|
||||
)
|
||||
async def delete_zim_subscription(_: Request, subscription_id: int):
|
||||
await lib.unsubscribe(subscription_id)
|
||||
return response.empty(HTTPStatus.NO_CONTENT)
|
||||
|
||||
|
||||
@bp.get('/outdated')
|
||||
@openapi.definition(
|
||||
summary='Returns the outdated and current Zim files',
|
||||
response=schema.OutdatedZims,
|
||||
)
|
||||
async def get_outdated_zims(_: Request):
|
||||
outdated, current = lib.find_outdated_zim_files()
|
||||
d = dict(outdated=outdated, current=current)
|
||||
return json_response(d)
|
||||
|
||||
|
||||
@bp.delete('/outdated')
|
||||
@openapi.definition(
|
||||
summary='Remove all outdated Zims, if any.'
|
||||
)
|
||||
async def delete_outdated_zims(_: Request):
|
||||
await lib.remove_outdated_zim_files()
|
||||
lib.flag_outdated_zim_files()
|
||||
return response.empty(HTTPStatus.NO_CONTENT)
|
||||
130
modules/zim/conftest.py
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import asyncio
|
||||
import json
|
||||
import pathlib
|
||||
from http import HTTPStatus
|
||||
from itertools import zip_longest
|
||||
from typing import List, Dict
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from mock import mock
|
||||
|
||||
from modules.zim.downloader import KiwixCatalogDownloader, KiwixZimDownloader
|
||||
from modules.zim.models import Zim
|
||||
from wrolpi.common import DownloadFileInfo
|
||||
from wrolpi.downloader import DownloadManager
|
||||
from wrolpi.vars import PROJECT_DIR
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def kiwix_download_manager(test_download_manager) -> DownloadManager:
|
||||
kiwix_downloader = KiwixCatalogDownloader()
|
||||
kiwix_zim_downloader = KiwixZimDownloader()
|
||||
|
||||
test_download_manager.register_downloader(kiwix_downloader)
|
||||
test_download_manager.register_downloader(kiwix_zim_downloader)
|
||||
|
||||
yield test_download_manager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def kiwix_download_zim(test_directory, kiwix_download_manager, test_zim_bytes):
|
||||
async def do_download(download_info: DownloadFileInfo = None, expected_url: str = None,
|
||||
download_file_side_effect=None, hrefs: List[str] = None):
|
||||
async def _download_file(url: str, output_path: pathlib.Path = None, info: DownloadFileInfo = None):
|
||||
if expected_url:
|
||||
assert url == expected_url
|
||||
if download_info:
|
||||
assert info == download_info
|
||||
output_path.write_bytes(test_zim_bytes)
|
||||
|
||||
with mock.patch('modules.zim.downloader.fetch_hrefs') as mock_fetch_hrefs, \
|
||||
mock.patch('modules.zim.downloader.get_download_info') as mock_get_download_info, \
|
||||
mock.patch('modules.zim.downloader.download_file') as mock_download_file:
|
||||
# Fake the download, but make sure it is called correctly. Also creates a Zim file at the output_path.
|
||||
mock_download_file.side_effect = download_file_side_effect or _download_file
|
||||
# These are the links in the parent directory of the url.
|
||||
mock_fetch_hrefs.return_value = hrefs or ['?C=N;O=D', '?C=M;O=A', '?C=S;O=A', '?C=D;O=A', '/zim/',
|
||||
'wikipedia_es_all_maxi_2023-05.zim',
|
||||
'wikipedia_es_all_maxi_2023-06.zim',
|
||||
'wikipedia_es_all_nopic_2023-05.zim',
|
||||
'wikipedia_es_all_nopic_2023-06.zim']
|
||||
# This is the DownloadInfo for the latest Zim file that was selected.
|
||||
mock_get_download_info.return_value = download_info or DownloadFileInfo(
|
||||
status=HTTPStatus.OK,
|
||||
name='wikipedia_es_all_maxi_2023-06.zim',
|
||||
size=len(test_zim_bytes),
|
||||
)
|
||||
|
||||
await kiwix_download_manager.wait_for_all_downloads()
|
||||
# Async download are hard to test, sleep to let tasks finish.
|
||||
await asyncio.sleep(0.1)
|
||||
await kiwix_download_manager.wait_for_all_downloads()
|
||||
|
||||
return do_download
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def test_zim_bytes():
|
||||
"""Read the byte contents of the test Zim (test/zim.zim)."""
|
||||
path = PROJECT_DIR / 'test/zim.zim'
|
||||
return path.read_bytes()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def zim_path_factory(test_zim_bytes, test_directory):
|
||||
"""Copy the test Zim into the test directory."""
|
||||
|
||||
def _(name: str = None) -> pathlib.Path:
|
||||
name = name if name else f'{uuid4()}.zim'
|
||||
path = test_directory / name
|
||||
path.write_bytes(test_zim_bytes)
|
||||
return path
|
||||
|
||||
return _
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_zim(test_session, zim_path_factory, test_directory) -> Zim:
|
||||
zim = Zim.from_paths(test_session, zim_path_factory())
|
||||
return zim
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def zim_factory(test_session, zim_path_factory, test_directory):
|
||||
def _(name: str) -> Zim:
|
||||
zim = Zim.from_paths(test_session, zim_path_factory(name))
|
||||
return zim
|
||||
|
||||
return _
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def assert_zim_search(test_client, test_directory):
|
||||
from http import HTTPStatus
|
||||
|
||||
def _(search_str: str, zim_id: int, expected: Dict, tag_names: List[str] = [],
|
||||
expected_status_code: int = HTTPStatus.OK):
|
||||
content = dict(search_str=search_str, tag_names=tag_names)
|
||||
assert isinstance(zim_id, int), 'This fixture only supports Zim ids of type integer'
|
||||
request, response = test_client.post(f'api/zim/search/{zim_id}', content=json.dumps(content))
|
||||
assert response.status_code == expected_status_code
|
||||
|
||||
result = response.json['zim']
|
||||
|
||||
if path := expected.get('path'):
|
||||
assert result['path'] == str(path.relative_to(test_directory))
|
||||
|
||||
if estimate := expected.get('estimate'):
|
||||
assert result['estimate'] == estimate
|
||||
|
||||
for response_entry, expected_entry in zip_longest(result['search'], expected['search']):
|
||||
if response_entry and not expected_entry:
|
||||
raise AssertionError(f'Response contained more entries than expected: {response_entry}')
|
||||
elif not response_entry and expected_entry:
|
||||
raise AssertionError(f'Response did not contain the expected entry: {expected_entry}')
|
||||
assert response_entry['path'] == expected_entry['path']
|
||||
assert response_entry['headline'] == expected_entry['headline']
|
||||
assert response_entry['rank'] == expected_entry['rank']
|
||||
|
||||
return _
|
||||
115
modules/zim/downloader.py
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
from http import HTTPStatus
|
||||
from typing import List
|
||||
|
||||
import bs4
|
||||
|
||||
from modules.zim import lib
|
||||
from wrolpi.common import logger, aiohttp_get, get_download_info, download_file, background_task
|
||||
from wrolpi.downloader import Downloader, Download, DownloadResult
|
||||
|
||||
__all__ = ['KiwixCatalogDownloader', 'KiwixZimDownloader', 'kiwix_zim_downloader', 'kiwix_catalog_downloader']
|
||||
|
||||
from wrolpi.files.lib import refresh_files
|
||||
from wrolpi.vars import PYTEST
|
||||
|
||||
logger = logger.getChild(__name__)
|
||||
|
||||
|
||||
async def fetch_hrefs(url: str) -> List[str]:
|
||||
content, status = await aiohttp_get(url, timeout=20)
|
||||
if status != HTTPStatus.OK:
|
||||
raise RuntimeError(f'Failed to fetch file catalog')
|
||||
|
||||
soup = bs4.BeautifulSoup(content, 'html.parser')
|
||||
downloads = list()
|
||||
for a_ in soup.find_all('a'):
|
||||
downloads.append(a_['href'])
|
||||
return downloads
|
||||
|
||||
|
||||
class KiwixCatalogDownloader(Downloader):
|
||||
"""
|
||||
Downloads the catalog of Zim files from Kiwix. Searches for the latest Zim that matches the provided URL.
|
||||
|
||||
If a newer file exists and has not been downloaded, schedule a Download.
|
||||
"""
|
||||
|
||||
name = 'kiwix_catalog'
|
||||
listable = False
|
||||
|
||||
async def do_download(self, download: Download) -> DownloadResult:
|
||||
url = download.url
|
||||
|
||||
# Get the parent directory of the file to be downloaded
|
||||
*parents, name = url.split('/')
|
||||
parent_url = '/'.join(parents)
|
||||
|
||||
# Find the latest Zim that has the correct name.
|
||||
downloadable_files = await fetch_hrefs(parent_url)
|
||||
matching_zims = sorted([i for i in downloadable_files if i.startswith(name) and i.endswith('.zim')])
|
||||
if not matching_zims:
|
||||
return DownloadResult(success=False, error=f'No Zim files match {str(repr(url))}')
|
||||
|
||||
logger.debug(f'Found matching Zims: {matching_zims}')
|
||||
latest_zim = f'{parent_url}/{matching_zims[-1]}'
|
||||
|
||||
return DownloadResult(
|
||||
success=True,
|
||||
downloads=[latest_zim, ],
|
||||
)
|
||||
|
||||
|
||||
kiwix_catalog_downloader = KiwixCatalogDownloader()
|
||||
|
||||
|
||||
class KiwixZimDownloader(Downloader):
|
||||
"""Downloads a Zim file to the zim directory."""
|
||||
name = 'kiwix_zim'
|
||||
listable = False
|
||||
|
||||
async def do_download(self, download: Download) -> DownloadResult:
|
||||
url = download.url
|
||||
|
||||
download_info = await get_download_info(url)
|
||||
|
||||
zim_directory = lib.get_zim_directory()
|
||||
zim_directory.mkdir(parents=True, exist_ok=True)
|
||||
output_path = zim_directory / f'{download_info.name}'
|
||||
|
||||
if output_path.is_file():
|
||||
output_size = output_path.stat().st_size
|
||||
if download_info.size and download_info.size == output_size:
|
||||
# File is already downloaded.
|
||||
return DownloadResult(success=True)
|
||||
|
||||
logger.info(f'Downloading Zim {url} to {output_path} of size {download_info.size}')
|
||||
await download_file(url, output_path=output_path, info=download_info)
|
||||
|
||||
# Notify the maintainer if outdated Zim files are lying around.
|
||||
lib.flag_outdated_zim_files()
|
||||
# Notify the maintainer to restart Kiwix container.
|
||||
lib.flag_kiwix_restart()
|
||||
|
||||
output_size = output_path.stat().st_size
|
||||
if download_info.size and output_size != download_info.size:
|
||||
return DownloadResult(
|
||||
success=False,
|
||||
error=f'Download size does not match: {output_size} != {download_info.size}')
|
||||
|
||||
return_code = await lib.check_zim(output_path)
|
||||
if return_code == 127:
|
||||
logger.warning(f'Not validating {output_path} because zimcheck is not installed')
|
||||
elif return_code > 0:
|
||||
return DownloadResult(success=False, error=f'Zim file is invalid')
|
||||
|
||||
# Add the new Zim to the FileGroups, model it.
|
||||
if PYTEST:
|
||||
await refresh_files([output_path, ])
|
||||
else:
|
||||
background_task(refresh_files([output_path, ]))
|
||||
|
||||
# Location is just the generic Kiwix viewer.
|
||||
return DownloadResult(success=True, location='/zim')
|
||||
|
||||
|
||||
kiwix_zim_downloader = KiwixZimDownloader()
|
||||
134
modules/zim/kiwix.py
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
WIKIPEDIA_LANGUAGES = ['ab', 'ace', 'ady', 'af', 'alt', 'am', 'ami', 'an', 'ang', 'anp', 'ar', 'arc', 'ary', 'arz',
|
||||
'as', 'ast', 'atj', 'av', 'avk', 'awa', 'ay', 'az', 'azb', 'ba', 'ban', 'bar', 'bcl', 'be',
|
||||
'be-tarask', 'bg', 'bho', 'bi', 'bjn', 'blk', 'bm', 'bn', 'bo', 'bpy', 'br', 'bs', 'bug', 'bxr',
|
||||
'ca', 'cbk', 'cdo', 'ce', 'ceb', 'ch', 'chr', 'chy', 'ckb', 'co', 'cr', 'crh', 'cs', 'csb', 'cu',
|
||||
'cv', 'cy', 'da', 'dag', 'de', 'din', 'diq', 'dsb', 'dty', 'dv', 'dz', 'ee', 'el', 'eml', 'en',
|
||||
'eo', 'es', 'et', 'eu', 'ext', 'fa', 'fat', 'ff', 'fi', 'fj', 'fo', 'fr', 'frp', 'frr', 'fur',
|
||||
'fy', 'ga', 'gag', 'gan', 'gcr', 'gd', 'gl', 'glk', 'gn', 'gom', 'gor', 'got', 'gsw', 'gu',
|
||||
'guc', 'gur', 'guw', 'gv', 'ha', 'hak', 'haw', 'he', 'hi', 'hif', 'hr', 'hsb', 'ht', 'hu', 'hy',
|
||||
'hyw', 'ia', 'id', 'ie', 'ig', 'ik', 'ilo', 'inh', 'io', 'is', 'it', 'iu', 'ja', 'jam', 'jbo',
|
||||
'jv', 'ka', 'kaa', 'kab', 'kbd', 'kbp', 'kcg', 'kg', 'ki', 'kk', 'kl', 'km', 'kn', 'ko', 'koi',
|
||||
'krc', 'ks', 'ksh', 'ku', 'kv', 'kw', 'ky', 'la', 'lad', 'lb', 'lbe', 'lez', 'lfn', 'lg', 'li',
|
||||
'lij', 'lld', 'lmo', 'ln', 'lo', 'lt', 'ltg', 'lv', 'lzh', 'mad', 'mai', 'map-bms', 'mdf', 'mg',
|
||||
'mhr', 'mi', 'min', 'mk', 'ml', 'mn', 'mni', 'mnw', 'mr', 'mrj', 'ms', 'mt', 'mwl', 'my', 'myv',
|
||||
'mzn', 'na', 'nah', 'nan', 'nap', 'nb', 'nds', 'nds-nl', 'ne', 'new', 'nia', 'nl', 'nn', 'nov',
|
||||
'nrm', 'nso', 'nv', 'ny', 'oc', 'olo', 'om', 'or', 'os', 'pa', 'pag', 'pam', 'pap', 'pcd', 'pdc',
|
||||
'pfl', 'pi', 'pih', 'pl', 'pms', 'pnb', 'pnt', 'ps', 'pt', 'pwn', 'qu', 'rm', 'rmy', 'rn', 'ro',
|
||||
'roa-tara', 'ru', 'rue', 'rup', 'rw', 'sa', 'sah', 'sat', 'sc', 'scn', 'sco', 'sd', 'se', 'sg',
|
||||
'sgs', 'sh', 'shi', 'shn', 'si', 'sk', 'skr', 'sl', 'sm', 'smn', 'sn', 'so', 'sq', 'sr', 'srn',
|
||||
'ss', 'st', 'stq', 'su', 'sv', 'sw', 'szl', 'szy', 'ta', 'tay', 'tcy', 'te', 'tet', 'tg', 'th',
|
||||
'ti', 'tk', 'tl', 'tn', 'to', 'tpi', 'tr', 'trv', 'ts', 'tt', 'tum', 'tw', 'ty', 'tyv', 'udm',
|
||||
'ug', 'uk', 'ur', 'uz', 've', 'vec', 'vep', 'vi', 'vls', 'vo', 'vro', 'wa', 'war', 'wo', 'wuu',
|
||||
'xal', 'xh', 'xmf', 'yi', 'yo', 'yue', 'za', 'zea', 'zh', 'zu']
|
||||
|
||||
WIKITIONARY_LANGUAGES = ['af', 'am', 'an', 'ang', 'ar', 'ast', 'ay', 'az', 'be', 'bg', 'bn', 'br', 'bs', 'ca', 'chr',
|
||||
'co', 'cs', 'csb', 'cy', 'da', 'de', 'dv', 'el', 'en', 'eo', 'es', 'et', 'eu', 'fa', 'fi',
|
||||
'fj', 'fo', 'fr', 'fy', 'ga', 'gd', 'gl', 'gn', 'gu', 'guw', 'gv', 'he', 'hi', 'hif', 'hr',
|
||||
'hsb', 'hu', 'hy', 'ia', 'id', 'ie', 'io', 'is', 'it', 'iu', 'ja', 'jbo', 'jv', 'ka', 'kk',
|
||||
'kl', 'km', 'kn', 'ko', 'ks', 'ku', 'kw', 'ky', 'la', 'lb', 'li', 'ln', 'lo', 'lt', 'lv', 'mg',
|
||||
'mi', 'mk', 'ml', 'mn', 'mr', 'ms', 'mt', 'my', 'na', 'nah', 'nan', 'nb', 'nds', 'ne', 'nl',
|
||||
'nn', 'oc', 'or', 'pa', 'pl', 'pnb', 'ps', 'pt', 'qu', 'ro', 'ru', 'rup', 'rw', 'sa', 'scn',
|
||||
'sd', 'sg', 'sh', 'si', 'sk', 'sl', 'sm', 'so', 'sq', 'sr', 'ss', 'st', 'su', 'sv', 'sw', 'ta',
|
||||
'te', 'tg', 'th', 'ti', 'tk', 'tl', 'tn', 'tpi', 'tr', 'ts', 'tt', 'ug', 'uk', 'ur', 'uz',
|
||||
'vec', 'vi', 'vo', 'wa', 'wo', 'yi', 'zh', 'zu']
|
||||
|
||||
WIKIBOOKS_LANGAUGES = ['af', 'ar', 'az', 'ba', 'be', 'bg', 'bn', 'bs', 'ca', 'cs', 'cv', 'cy', 'da', 'de', 'el', 'en',
|
||||
'eo', 'es', 'et', 'eu', 'fa', 'fi', 'fr', 'fy', 'gl', 'he', 'hi', 'hr', 'hu', 'hy', 'ia', 'id',
|
||||
'is', 'it', 'ja', 'ka', 'kk', 'km', 'ko', 'ku', 'ky', 'la', 'li', 'lt', 'mg', 'mk', 'ml', 'mr',
|
||||
'ms', 'nb', 'ne', 'nl', 'oc', 'pa', 'pl', 'pt', 'ro', 'ru', 'sa', 'si', 'sk', 'sl', 'sq', 'sr',
|
||||
'sv', 'ta', 'te', 'tg', 'th', 'tl', 'tr', 'tt', 'uk', 'ur', 'vi', 'zh']
|
||||
|
||||
KIWIX_CATALOG = [
|
||||
{
|
||||
'name': 'Wikibooks (with images)',
|
||||
'languages': WIKIBOOKS_LANGAUGES,
|
||||
'size': 4.3 * 1073741824,
|
||||
},
|
||||
{
|
||||
'name': 'Wikibooks (no images)',
|
||||
'languages': WIKIBOOKS_LANGAUGES,
|
||||
'size': 3.4 * 1073741824,
|
||||
},
|
||||
{
|
||||
'name': 'Wikipedia (with images)',
|
||||
'languages': WIKIPEDIA_LANGUAGES,
|
||||
'size': 94 * 1073741824,
|
||||
},
|
||||
{
|
||||
'name': 'Wikipedia (mini)',
|
||||
'languages': WIKIPEDIA_LANGUAGES,
|
||||
'size': 13 * 1073741824,
|
||||
},
|
||||
{
|
||||
'name': 'Wikipedia (no images)',
|
||||
'languages': WIKIPEDIA_LANGUAGES,
|
||||
'size': 50 * 1073741824,
|
||||
},
|
||||
{
|
||||
'name': 'Wikiversity (with images)',
|
||||
'languages': ['ar', 'cs', 'de', 'el', 'en', 'es', 'fi', 'fr', 'hi', 'it', 'ja', 'ko', 'pt', 'ru', 'sl', 'sv',
|
||||
'zh'],
|
||||
'size': 2.3 * 1073741824,
|
||||
},
|
||||
{
|
||||
'name': 'Wikiversity (no images)',
|
||||
'languages': ['ar', 'cs', 'de', 'el', 'en', 'es', 'fi', 'fr', 'hi', 'it', 'ja', 'ko', 'pt', 'ru', 'sl', 'sv',
|
||||
'zh'],
|
||||
'size': 1.8 * 1073741824,
|
||||
},
|
||||
{
|
||||
'name': 'Wikitionary (no images)',
|
||||
'languages': WIKITIONARY_LANGUAGES,
|
||||
'size': 7.3 * 1073741824,
|
||||
},
|
||||
{
|
||||
'name': 'Wikitionary (with images)',
|
||||
'languages': WIKITIONARY_LANGUAGES,
|
||||
'size': 8.0 * 1073741824,
|
||||
},
|
||||
{
|
||||
'name': 'Wikisource',
|
||||
'languages': ['ang', 'ar', 'as', 'az', 'be', 'bg', 'bn', 'br', 'bs', 'ca', 'cs', 'cy', 'da', 'de', 'el', 'en',
|
||||
'eo', 'es', 'et', 'eu', 'fa', 'fi', 'fo', 'fr', 'gl', 'gu', 'he', 'hr', 'ht', 'hu', 'hy', 'id',
|
||||
'is', 'it', 'ja', 'kn', 'ko', 'la', 'li', 'lt', 'mk', 'ml', 'mr', 'nan', 'nb', 'nl', 'or', 'pa',
|
||||
'pl', 'pms', 'pt', 'ro', 'ru', 'sa', 'sah', 'sk', 'sl', 'sr', 'sv', 'ta', 'te', 'th', 'tr', 'uk',
|
||||
'vec', 'vi', 'yi', 'zh', 'zh-min-nan'],
|
||||
'size': 16 * 1073741824,
|
||||
},
|
||||
{
|
||||
'name': 'Raspberry Pi (Stack Exchange)',
|
||||
'languages': ['en'],
|
||||
'size': 300 * 1048576,
|
||||
},
|
||||
{
|
||||
'name': 'Vikidia',
|
||||
'languages': ['ca', 'en', 'es', 'eu', 'fr', 'it', 'ru', 'scn'],
|
||||
'size': 711 * 1048576,
|
||||
},
|
||||
{
|
||||
'name': 'Stackoverflow (Stack Exchange)',
|
||||
'languages': ['en', 'es', 'ja', 'pt', 'ru'],
|
||||
'size': 76 * 1073741824,
|
||||
},
|
||||
{
|
||||
'name': 'Superuser (Stack Exchange)',
|
||||
'languages': ['en'],
|
||||
'size': 3 * 1073741824,
|
||||
},
|
||||
{
|
||||
'name': 'iFixit',
|
||||
'languages': ['de', 'en', 'es', 'fr', 'it', 'ja', 'ko', 'nl', 'pt', 'ru', 'tr', 'zh'],
|
||||
'size': 3 * 1073741824,
|
||||
},
|
||||
{
|
||||
'name': 'Gutenberg',
|
||||
'languages': ['af', 'ale', 'ang', 'ar', 'arp', 'bg', 'bgs', 'bo', 'br', 'brx', 'ca', 'ceb', 'cs', 'csb', 'cy',
|
||||
'da', 'de', 'el', 'en', 'enm', 'eo', 'es', 'et', 'fa', 'fi', 'fr', 'fur', 'fy', 'ga', 'gl', 'gla',
|
||||
'grc', 'he', 'hu', 'ia', 'ilo', 'is', 'it', 'iu', 'ja', 'kha', 'kld', 'ko', 'la', 'mi', 'mul',
|
||||
'myn', 'nah', 'nai', 'nap', 'nav', 'nl', 'no', 'oc', 'oji', 'pl', 'pt', 'rmr', 'ro', 'ru', 'sa',
|
||||
'sl', 'sr', 'sv', 'te', 'tl', 'zh'],
|
||||
'size': 70 * 1073741824,
|
||||
},
|
||||
]
|
||||
|
||||
KIWIX_CATALOG_BY_NAME = {i['name']: i for i in KIWIX_CATALOG}
|
||||
484
modules/zim/lib.py
Normal file
|
|
@ -0,0 +1,484 @@
|
|||
import asyncio
|
||||
import functools
|
||||
import pathlib
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import List, Tuple, Dict
|
||||
|
||||
from libzim import Entry
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.orm.exc import NoResultFound # noqa
|
||||
|
||||
from modules.zim import kiwix
|
||||
from modules.zim.kiwix import KIWIX_CATALOG
|
||||
from modules.zim.models import Zim, Zims, TagZimEntry, ZimSubscription
|
||||
from wrolpi import flags
|
||||
from wrolpi.common import register_modeler, logger, extract_html_text, extract_headlines, get_media_directory, walk, \
|
||||
register_refresh_cleanup, background_task
|
||||
from wrolpi.db import get_db_session, optional_session, get_db_curs
|
||||
from wrolpi.downloader import DownloadFrequency
|
||||
from wrolpi.errors import UnknownZim, UnknownZimSubscription
|
||||
from wrolpi.files.lib import refresh_files
|
||||
from wrolpi.files.models import FileGroup
|
||||
from wrolpi.vars import PYTEST, DOCKERIZED
|
||||
|
||||
logger = logger.getChild(__name__)
|
||||
|
||||
__all__ = [
|
||||
'flag_outdated_zim_files',
|
||||
'get_all_entries_tags',
|
||||
'get_entries_tags',
|
||||
'get_entry',
|
||||
'get_kiwix_catalog',
|
||||
'get_unique_paths',
|
||||
'get_zim',
|
||||
'get_zims',
|
||||
'zim_modeler',
|
||||
'zim_download_url_to_name',
|
||||
]
|
||||
|
||||
|
||||
@register_modeler
|
||||
async def zim_modeler():
|
||||
while True:
|
||||
with get_db_session(commit=True) as session:
|
||||
file_groups = session.query(FileGroup, Zim) \
|
||||
.filter(FileGroup.indexed != True, # noqa
|
||||
FileGroup.primary_path.ilike('%.zim'),
|
||||
) \
|
||||
.outerjoin(Zim, Zim.file_group_id == FileGroup.id) \
|
||||
.limit(10)
|
||||
file_groups: List[Tuple[FileGroup, Zim]] = list(file_groups)
|
||||
|
||||
processed = 0
|
||||
for file_group, zim in file_groups:
|
||||
processed += 1
|
||||
|
||||
zim_id = None
|
||||
try:
|
||||
if not zim:
|
||||
zim = Zim(file_group=file_group, path=file_group.primary_path)
|
||||
session.add(zim)
|
||||
session.flush([zim])
|
||||
zim_id = zim.id
|
||||
file_group.indexed = True
|
||||
except Exception as e:
|
||||
if PYTEST:
|
||||
raise
|
||||
logger.error(f'Unable to model Zim {zim_id=} {file_group.primary_path=}', exc_info=e)
|
||||
|
||||
logger.debug(f'Modeled {processed} zim files')
|
||||
|
||||
if processed < 10:
|
||||
# Did not reach limit, do not query again.
|
||||
break
|
||||
|
||||
# Sleep to catch cancel.
|
||||
await asyncio.sleep(0)
|
||||
|
||||
|
||||
def get_all_entries_tags():
|
||||
"""Returns a dict of dicts which contain the names of all Tags of each Entry's path.
|
||||
|
||||
Example:
|
||||
{
|
||||
1: {
|
||||
'path1': ['tag1', 'tag2'],
|
||||
},
|
||||
2: {
|
||||
'path2': ['tag1'],
|
||||
},
|
||||
"""
|
||||
stmt = '''
|
||||
SELECT tz.zim_id, tz.zim_entry, array_agg(t.name)::TEXT[]
|
||||
FROM
|
||||
tag t
|
||||
LEFT JOIN tag_zim tz on t.id = tz.tag_id
|
||||
GROUP BY 1, 2
|
||||
'''
|
||||
with get_db_curs() as curs:
|
||||
curs.execute(stmt)
|
||||
entries = dict()
|
||||
for zim_id, zim_entry, tag_names in curs.fetchall():
|
||||
if not zim_id:
|
||||
continue
|
||||
try:
|
||||
entries[zim_id][zim_entry] = tag_names
|
||||
except KeyError:
|
||||
entries[zim_id] = {zim_entry: tag_names}
|
||||
return entries
|
||||
|
||||
|
||||
def get_entries_tags(paths: List[str], zim_id: int):
|
||||
all_entries_tags = get_all_entries_tags()
|
||||
entries = dict()
|
||||
for path in paths:
|
||||
zim_tag_entries = all_entries_tags.get(zim_id, dict())
|
||||
entries[path] = zim_tag_entries.get(path, list())
|
||||
|
||||
return entries
|
||||
|
||||
|
||||
def get_unique_paths(*paths: str) -> List[str]:
|
||||
"""Return a new list which contains unique Zim paths."""
|
||||
new_paths = list()
|
||||
simple_paths = list()
|
||||
for path in paths:
|
||||
simple_path = path.replace('-', '').replace('_', '').replace('/', '').lower()
|
||||
if simple_path not in simple_paths:
|
||||
new_paths.append(path)
|
||||
simple_paths.append(simple_path)
|
||||
return new_paths
|
||||
|
||||
|
||||
@optional_session
|
||||
def headline_zim(search_str: str, zim_id: int, tag_names: List[str] = None, offset: int = 0, limit: int = 10,
|
||||
session: Session = None) -> Dict:
|
||||
zim_id = int(zim_id)
|
||||
zim = get_zim(zim_id, session=session)
|
||||
|
||||
if tag_names:
|
||||
# TODO what is the count goes over the limit?
|
||||
entries = zim.entries_with_tags(tag_names, offset=offset, limit=1_000, session=session)
|
||||
estimate = 0
|
||||
elif search_str:
|
||||
# Get suggested results (this searches titles).
|
||||
results = zim.suggest(search_str, offset, limit)
|
||||
search_results = zim.search(search_str, offset, limit)
|
||||
# Remove duplicate entries (disambiguation or redirect).
|
||||
results = get_unique_paths(*results, *search_results)
|
||||
results = results[:limit]
|
||||
entries = [zim.get_entry(i) for i in results]
|
||||
estimate = zim.estimate(search_str)
|
||||
else:
|
||||
raise RuntimeError('Must provide search_str or tag_names.')
|
||||
|
||||
articles = [bytes(i.get_item().content).decode('UTF-8') for i in entries]
|
||||
articles = [extract_html_text(i) for i in articles]
|
||||
|
||||
entries_tag_names = get_entries_tags([i.path for i in entries], zim_id)
|
||||
|
||||
# Always get headlines even if there is no search_str. This is because Postgres will return the start of each
|
||||
# article.
|
||||
headlines = extract_headlines(articles, search_str)
|
||||
|
||||
search = list()
|
||||
if search_str:
|
||||
# `search_str` allows us to headline the title.
|
||||
titles = [i.title for i in entries]
|
||||
headline_titles = extract_headlines(titles, search_str)
|
||||
|
||||
for entry in entries:
|
||||
title_headline, title_rank = headline_titles.pop(0)
|
||||
headline, rank = headlines.pop(0)
|
||||
if rank > 0 or title_rank > 0:
|
||||
# The result for each Entry.
|
||||
path = entry.path
|
||||
entry_tag_names = entries_tag_names[path] if path in entries_tag_names else list()
|
||||
search.append(dict(
|
||||
headline=headline,
|
||||
path=path,
|
||||
rank=max(rank, title_rank),
|
||||
tag_names=entry_tag_names,
|
||||
title=title_headline,
|
||||
))
|
||||
if tag_names:
|
||||
estimate += 1
|
||||
else:
|
||||
# No `search_str`, we return all entries.
|
||||
for entry in entries:
|
||||
headline, _ = headlines.pop(0)
|
||||
path = entry.path
|
||||
entry_tag_names = entries_tag_names[path] if path in entries_tag_names else list()
|
||||
search.append(dict(
|
||||
headline=headline,
|
||||
path=path,
|
||||
rank=0,
|
||||
tag_names=entry_tag_names,
|
||||
title=entry.title,
|
||||
))
|
||||
estimate = len(entries)
|
||||
|
||||
results = dict(
|
||||
path=zim.path,
|
||||
metadata=zim.zim_metadata,
|
||||
search=search,
|
||||
estimate=estimate,
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
@optional_session
|
||||
def get_zim(zim_id: int, session: Session = None) -> Zim:
|
||||
"""Return the Zim record of the provided path.
|
||||
|
||||
@raise UnknownZim: When no record exists with the id.
|
||||
@warning: Results are cached."""
|
||||
try:
|
||||
zim: Zim = session.query(Zim) \
|
||||
.join(FileGroup, FileGroup.id == Zim.file_group_id) \
|
||||
.filter(Zim.id == zim_id) \
|
||||
.one()
|
||||
return zim
|
||||
except Exception as e:
|
||||
raise UnknownZim() from e
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=1_000)
|
||||
def get_entry(path: str, zim_id: int) -> Entry:
|
||||
with get_db_session() as session:
|
||||
zim = get_zim(zim_id, session=session)
|
||||
entry = zim.get_entry(path, throw=True)
|
||||
return entry
|
||||
|
||||
|
||||
@optional_session
|
||||
def get_zims(ids: List[int] = None, session: Session = None) -> List[Zim]:
|
||||
if ids:
|
||||
zims = session.query(Zim).filter(Zim.id.in_(ids)).all()
|
||||
else:
|
||||
zims = Zims.get_all(session=session)
|
||||
|
||||
return zims
|
||||
|
||||
|
||||
@optional_session(commit=True)
|
||||
def delete_zims(ids: List[int], session: Session = None):
|
||||
zims = get_zims(ids, session=session)
|
||||
for zim in zims:
|
||||
zim.delete()
|
||||
|
||||
|
||||
@optional_session
|
||||
async def add_tag(tag_name: str, zim_id: int, zim_entry: str, session: Session = None) -> TagZimEntry:
|
||||
zim = session.query(Zim).filter_by(id=zim_id).one()
|
||||
tag_zim_entry = zim.tag_entry(tag_name, zim_entry)
|
||||
session.commit()
|
||||
return tag_zim_entry
|
||||
|
||||
|
||||
@optional_session
|
||||
async def untag(tag_name: str, zim_id: int, zim_entry: str, session: Session = None):
|
||||
zim: Zim = session.query(Zim).filter_by(id=zim_id).one()
|
||||
zim.untag_entry(tag_name, zim_entry)
|
||||
session.commit()
|
||||
|
||||
|
||||
def get_kiwix_catalog():
|
||||
return KIWIX_CATALOG.copy()
|
||||
|
||||
|
||||
@optional_session
|
||||
def get_kiwix_subscriptions(session: Session = None) -> Dict[str, ZimSubscription]:
|
||||
subscriptions = session.query(ZimSubscription)
|
||||
subscriptions_by_name = {i.name: i for i in subscriptions}
|
||||
return subscriptions_by_name
|
||||
|
||||
|
||||
@optional_session(commit=True)
|
||||
async def subscribe(name: str, language: str, session: Session = None,
|
||||
frequency: int = DownloadFrequency.days180) -> ZimSubscription:
|
||||
subscription = ZimSubscription.get_or_create(name, session=session)
|
||||
subscription.language = language
|
||||
|
||||
if name == 'Wikipedia (with images)':
|
||||
url = f'https://download.kiwix.org/zim/wikipedia/wikipedia_{language}_all_maxi_'
|
||||
elif name == 'Wikipedia (mini)':
|
||||
url = f'https://download.kiwix.org/zim/wikipedia/wikipedia_{language}_all_mini_'
|
||||
elif name == 'Wikipedia (no images)':
|
||||
url = f'https://download.kiwix.org/zim/wikipedia/wikipedia_{language}_all_nopic_'
|
||||
elif name == 'Wikiversity (with images)':
|
||||
url = f'https://download.kiwix.org/zim/wikiversity/wikiversity_{language}_all_maxi_'
|
||||
elif name == 'Wikiversity (no images)':
|
||||
url = f'https://download.kiwix.org/zim/wikiversity/wikiversity_{language}_all_nopic_'
|
||||
elif name == 'Wikitionary (no images)':
|
||||
url = f'https://download.kiwix.org/zim/wiktionary/wiktionary_{language}_all_nopic_'
|
||||
elif name == 'Wikitionary (with images)':
|
||||
url = f'https://download.kiwix.org/zim/wikiversity/wikiversity_{language}_all_maxi_'
|
||||
elif name == 'Wikibooks (no images)':
|
||||
url = f'https://download.kiwix.org/zim/wikibooks/wikibooks_{language}_all_nopic_'
|
||||
elif name == 'Wikibooks (with images)':
|
||||
url = f'https://download.kiwix.org/zim/wikibooks/wikibooks_{language}_all_maxi_'
|
||||
elif name == 'Wikisource':
|
||||
url = f'https://download.kiwix.org/zim/wikisource/wikisource_{language}_all_maxi_'
|
||||
elif name == 'Raspberry Pi (Stack Exchange)':
|
||||
url = f'https://download.kiwix.org/zim/stack_exchange/raspberrypi.stackexchange.com_{language}_all_'
|
||||
elif name == 'Vikidia':
|
||||
url = f'https://download.kiwix.org/zim/vikidia/vikidia_{language}_all_maxi_'
|
||||
elif name == 'Stackoverflow (Stack Exchange)':
|
||||
url = f'https://download.kiwix.org/zim/stack_exchange/{language}.stackoverflow.com_{language}_all_'
|
||||
elif name == 'Superuser (Stack Exchange)':
|
||||
url = f'https://download.kiwix.org/zim/stack_exchange/superuser.com_{language}_all_'
|
||||
elif name == 'iFixit':
|
||||
url = f'https://download.kiwix.org/zim/ifixit/ifixit_{language}_all_'
|
||||
elif name == 'Gutenberg':
|
||||
url = f'https://download.kiwix.org/zim/gutenberg/gutenberg_{language}_all_'
|
||||
else:
|
||||
raise ValueError(f'{name} not a valid Kiwix subscription name!')
|
||||
|
||||
if language not in kiwix.KIWIX_CATALOG_BY_NAME[name]['languages']:
|
||||
raise ValueError(f'Language {repr(str(language))} is invalid for {name}')
|
||||
|
||||
subscription.change_download(url, frequency, session=session)
|
||||
session.add(subscription)
|
||||
session.flush([subscription])
|
||||
session.commit()
|
||||
return subscription
|
||||
|
||||
|
||||
@optional_session
|
||||
async def unsubscribe(subscription_id: int, session: Session = None):
|
||||
subscription: ZimSubscription = session.query(ZimSubscription).filter_by(id=subscription_id).one_or_none()
|
||||
if subscription:
|
||||
session.delete(subscription)
|
||||
if subscription.download:
|
||||
session.delete(subscription.download)
|
||||
session.commit()
|
||||
else:
|
||||
raise UnknownZimSubscription(f'{subscription_id=}')
|
||||
|
||||
|
||||
KIWIX_URL_PARSER = re.compile(r'https:\/\/download\.kiwix\.org\/zim\/(.+?)\/(.+?)_(\w{2,3})(_.+)')
|
||||
|
||||
|
||||
def zim_download_url_to_name(url: str) -> Tuple[str, str]:
|
||||
_, project, language, flavor = KIWIX_URL_PARSER.match(url).groups()
|
||||
if project == 'wikipedia' and flavor == '_all_maxi_':
|
||||
name = 'Wikipedia (with images)'
|
||||
elif project == 'wikipedia' and flavor == '_all_nopic_':
|
||||
name = 'Wikipedia (no images)'
|
||||
elif project == 'wikipedia' and flavor == '_all_mini_':
|
||||
name = 'Wikipedia (mini)'
|
||||
elif project == 'wikiversity' and flavor == '_all_maxi_':
|
||||
name = 'Wikiversity (with images)'
|
||||
elif project == 'wikiversity' and flavor == '_all_nopic_':
|
||||
name = 'Wikiversity (no images)'
|
||||
elif project == 'wiktionary' and flavor == '_all_maxi_':
|
||||
name = 'Wikitionary (with images)'
|
||||
elif project == 'wiktionary' and flavor == '_all_nopic_':
|
||||
name = 'Wikitionary (no images)'
|
||||
elif project == 'wikibooks' and flavor == '_all_maxi_':
|
||||
name = 'Wikibooks (with images)'
|
||||
elif project == 'wikibooks' and flavor == '_all_nopic_':
|
||||
name = 'Wikibooks (no images)'
|
||||
elif project == 'wikisource' and flavor == '_all_maxi_':
|
||||
name = 'Wikisource'
|
||||
elif project == 'raspberrypi.stackexchange.com' and flavor == '_all_':
|
||||
name = 'Raspberry Pi (Stack Exchange)'
|
||||
elif project == 'vikidia' and flavor == '_all_maxi_':
|
||||
name = 'Vikidia'
|
||||
elif 'stackoverflow.com' in project and flavor == '_all_':
|
||||
name = 'Stackoverflow (Stack Exchange)'
|
||||
elif project == 'superuser.com' and flavor == '_all_':
|
||||
name = 'Superuser (Stack Exchange)'
|
||||
elif project == 'ifixit' and flavor == '_all_':
|
||||
name = 'iFixit'
|
||||
elif project == 'gutenberg' and flavor == '_all_':
|
||||
name = 'Gutenberg'
|
||||
else:
|
||||
raise RuntimeError(f'Could not find name for Zim URL: {url} {project=} {flavor=}')
|
||||
|
||||
if language not in kiwix.KIWIX_CATALOG_BY_NAME[name]['languages']:
|
||||
raise ValueError(f'Language {repr(str(language))} is invalid for {name}')
|
||||
|
||||
return name, language
|
||||
|
||||
|
||||
def get_zim_directory() -> pathlib.Path:
|
||||
media_directory = get_media_directory()
|
||||
zim_directory = media_directory / 'zims'
|
||||
return zim_directory
|
||||
|
||||
|
||||
async def check_zim(path: pathlib.Path) -> int:
|
||||
"""
|
||||
Runs `zimcheck` against the Zim file. Zim is valid only if this returns `0`.
|
||||
|
||||
@warning: Only performs the checksum check!
|
||||
"""
|
||||
cmd = f'zimcheck -C {path.absolute()}'
|
||||
proc = await asyncio.create_subprocess_shell(cmd)
|
||||
stdout, stderr = await proc.communicate()
|
||||
logger.debug(f'zimcheck returned {proc.returncode}')
|
||||
if proc.returncode != 0 and stderr:
|
||||
logger.debug(stderr)
|
||||
return proc.returncode
|
||||
|
||||
|
||||
ZIM_NAME_PARSER = re.compile(r'(.+?)_(\d{4}-\d{2}).zim')
|
||||
|
||||
|
||||
def parse_name(path: pathlib.Path) -> Tuple[str, datetime]:
|
||||
name, date = ZIM_NAME_PARSER.match(path.name).groups()
|
||||
date = datetime.strptime(date, '%Y-%m')
|
||||
return name, date
|
||||
|
||||
|
||||
def find_outdated_zim_files(path: pathlib.Path = None) -> Tuple[List[pathlib.Path], List[pathlib.Path]]:
|
||||
"""Search the Zim directory for outdated Zim files. Returns a list of outdated, and a list of current Zim files."""
|
||||
path = path or get_zim_directory()
|
||||
if not path.is_dir():
|
||||
logger.error(f'Cannot search for outdated Zim files because the directory does not exist: {path}')
|
||||
return list(), list()
|
||||
|
||||
# Find all non-empty Zim files.
|
||||
files = [i for i in walk(path) if i.is_file() and i.stat().st_size and i.suffix == '.zim']
|
||||
zims = list()
|
||||
for file in files:
|
||||
try:
|
||||
name, date = parse_name(file)
|
||||
except Exception as e:
|
||||
logger.warning(f'Found Zim {file} but its name does not match.', exc_info=e)
|
||||
continue
|
||||
|
||||
zims.append((name, date, file))
|
||||
|
||||
# Order by newest first.
|
||||
zims = sorted(zims, key=lambda i: i[1], reverse=True)
|
||||
outdated_files = list()
|
||||
current_files = list()
|
||||
newest_names = list()
|
||||
for name, date, file in zims:
|
||||
if name in newest_names:
|
||||
# Already saw this name, this date is older.
|
||||
outdated_files.append(file)
|
||||
else:
|
||||
newest_names.append(name)
|
||||
current_files.append(file)
|
||||
|
||||
return sorted(outdated_files), sorted(current_files)
|
||||
|
||||
|
||||
async def remove_outdated_zim_files(path: pathlib.Path = None):
|
||||
"""Deletes all old Zim files."""
|
||||
outdated, _ = find_outdated_zim_files(path)
|
||||
logger.info(f'Deleting {len(outdated)} outdated Zim files: {outdated}')
|
||||
for file in outdated:
|
||||
file.unlink()
|
||||
|
||||
if PYTEST:
|
||||
await refresh_files(outdated)
|
||||
else:
|
||||
background_task(refresh_files(outdated))
|
||||
|
||||
flag_kiwix_restart()
|
||||
|
||||
|
||||
@register_refresh_cleanup
|
||||
def flag_outdated_zim_files():
|
||||
"""Set the `outdated_zims` Flag if outdated Zim files can be found, otherwise clear it."""
|
||||
outdated, _ = find_outdated_zim_files()
|
||||
if outdated:
|
||||
logger.info('Outdated Zims were found')
|
||||
flags.outdated_zims.set()
|
||||
else:
|
||||
logger.info('Outdated Zims were not found')
|
||||
flags.outdated_zims.clear()
|
||||
|
||||
|
||||
def flag_kiwix_restart():
|
||||
"""Set `kiwix_restart` Flag only if running in Docker."""
|
||||
if DOCKERIZED:
|
||||
flags.kiwix_restart.set()
|
||||
else:
|
||||
logger.debug(f'Not setting kiwix_restart flag because this is not Dockerized')
|
||||
370
modules/zim/models.py
Normal file
|
|
@ -0,0 +1,370 @@
|
|||
import dataclasses
|
||||
import functools
|
||||
import pathlib
|
||||
from collections import OrderedDict
|
||||
from datetime import datetime
|
||||
from typing import List, Tuple, OrderedDict as OrderedDictType, Dict, Optional, Set
|
||||
|
||||
from libzim import Archive, Searcher, Query, Entry, SuggestionSearcher
|
||||
from sqlalchemy import Column, Integer, BigInteger, ForeignKey, Text, tuple_
|
||||
from sqlalchemy.orm import relationship, Session
|
||||
from sqlalchemy.orm.exc import NoResultFound # noqa
|
||||
|
||||
from wrolpi import dates, tags
|
||||
from wrolpi.common import Base, extract_html_text, logger, get_relative_to_media_directory
|
||||
from wrolpi.dates import TZDateTime
|
||||
from wrolpi.db import optional_session, get_db_curs
|
||||
from wrolpi.downloader import Download, download_manager
|
||||
from wrolpi.errors import UnknownZimEntry, UnknownZimTagEntry
|
||||
from wrolpi.files.models import FileGroup
|
||||
from wrolpi.media_path import MediaPathType
|
||||
from wrolpi.tags import Tag, tag_names_to_zim_sub_select
|
||||
|
||||
logger = logger.getChild(__name__)
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class ZimMetadata:
|
||||
date: str
|
||||
creator: str
|
||||
description: str
|
||||
name: str
|
||||
publisher: str
|
||||
tags: str
|
||||
title: str
|
||||
|
||||
def __json__(self) -> Dict:
|
||||
d = dict(
|
||||
date=self.date,
|
||||
creator=self.creator,
|
||||
description=self.description,
|
||||
name=self.name,
|
||||
publisher=self.publisher,
|
||||
tags=self.tags,
|
||||
title=self.title,
|
||||
)
|
||||
return d
|
||||
|
||||
|
||||
class EntrySummary:
|
||||
zim_id: int
|
||||
path: str
|
||||
title: str
|
||||
|
||||
def __json__(self) -> Dict:
|
||||
d = dict(
|
||||
zim_id=self.zim_id,
|
||||
path=self.path,
|
||||
title=self.title,
|
||||
)
|
||||
return d
|
||||
|
||||
|
||||
class Zim(Base):
|
||||
"""Records of Zim files.
|
||||
|
||||
Zim files stores wiki content (and much more) for offline usage. Zim files are compressed data that can be searched.
|
||||
"""
|
||||
|
||||
__tablename__ = 'zim'
|
||||
id = Column(Integer, primary_key=True)
|
||||
path: pathlib.Path = Column(MediaPathType, nullable=False)
|
||||
|
||||
file_group_id = Column(BigInteger, ForeignKey('file_group.id', ondelete='CASCADE'), unique=True, nullable=False)
|
||||
file_group: FileGroup = relationship('FileGroup')
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Zim id={self.id} file_group_id={self.file_group_id} path={self.path}>'
|
||||
|
||||
def __json__(self) -> dict:
|
||||
d = dict(
|
||||
id=self.id,
|
||||
path=self.path,
|
||||
file_group_id=self.file_group_id,
|
||||
metadata=self.zim_metadata,
|
||||
size=self.file_group.size,
|
||||
)
|
||||
return d
|
||||
|
||||
@staticmethod
|
||||
def from_paths(session: Session, *paths: pathlib.Path) -> 'Zim':
|
||||
file_group = FileGroup.from_paths(session, *paths)
|
||||
zim = Zim(path=file_group.primary_path, file_group=file_group)
|
||||
session.add(zim)
|
||||
session.flush([file_group, zim])
|
||||
return zim
|
||||
|
||||
def get_zim(self) -> Archive:
|
||||
if not self.path or not self.path.is_file():
|
||||
raise FileNotFoundError(f'{self} does not exist!')
|
||||
|
||||
if not self.path.suffix != 'zim':
|
||||
raise FileNotFoundError(f'{self} is not a valid zim file!')
|
||||
|
||||
zim = Archive(self.path)
|
||||
return zim
|
||||
|
||||
def delete(self):
|
||||
session: Session = Session.object_session(self)
|
||||
self.path.unlink(missing_ok=True)
|
||||
session.delete(self)
|
||||
|
||||
@functools.cached_property
|
||||
def zim_metadata(self) -> ZimMetadata:
|
||||
zim = self.get_zim()
|
||||
|
||||
def _get_metadata(path: str):
|
||||
try:
|
||||
return zim.get_metadata(path).decode()
|
||||
except RuntimeError:
|
||||
return None
|
||||
|
||||
metadata = ZimMetadata(
|
||||
date=_get_metadata('Date'),
|
||||
creator=_get_metadata('Creator'),
|
||||
description=_get_metadata('Description'),
|
||||
name=_get_metadata('Name'),
|
||||
publisher=_get_metadata('Publisher'),
|
||||
tags=_get_metadata('Tags'),
|
||||
title=_get_metadata('Title'),
|
||||
)
|
||||
return metadata
|
||||
|
||||
def get_searcher(self) -> Searcher:
|
||||
searcher = Searcher(self.get_zim())
|
||||
return searcher
|
||||
|
||||
def get_search(self, search_str: str):
|
||||
query = Query().set_query(search_str)
|
||||
searcher = self.get_searcher()
|
||||
search = searcher.search(query)
|
||||
return search
|
||||
|
||||
def get_suggestion_searcher(self) -> SuggestionSearcher:
|
||||
searcher = SuggestionSearcher(self.get_zim())
|
||||
return searcher
|
||||
|
||||
def estimate(self, search_str: str) -> int:
|
||||
"""Count the number of Entry(s) that contain the `search_str`."""
|
||||
search = self.get_search(search_str)
|
||||
count = search.getEstimatedMatches()
|
||||
return count
|
||||
|
||||
def search(self, search_str: str, offset: int = 0, limit: int = 10) -> List[str]:
|
||||
"""Return the paths of the Entry(s) that contain the `search_str`."""
|
||||
search = self.get_search(search_str)
|
||||
results = list(search.getResults(offset, limit))
|
||||
return results
|
||||
|
||||
def suggest(self, search_str: str, offset: int = 0, limit: int = 10) -> List[str]:
|
||||
"""Return the paths of the Entry(s) that contain the `search_str`. Also searches the titles."""
|
||||
searcher = self.get_suggestion_searcher()
|
||||
suggestions = searcher.suggest(search_str)
|
||||
results = list(suggestions.getResults(offset, limit))
|
||||
return results
|
||||
|
||||
def get_entry(self, path: str, throw: bool = True) -> Optional[Entry]:
|
||||
"""Search this Zim file for the provided Entry at the `path`."""
|
||||
zim = self.get_zim()
|
||||
try:
|
||||
entry = zim.get_entry_by_path(path)
|
||||
except KeyError:
|
||||
if throw:
|
||||
raise UnknownZimEntry(f'Cannot find entry at {path=}')
|
||||
else:
|
||||
return None
|
||||
return entry
|
||||
|
||||
def get_entry_html(self, path: str) -> Optional[str]:
|
||||
"""Search this Zim file for the provided Entry at `path`, read its contents and return the HTML."""
|
||||
content = bytes(self.get_entry(path).get_item().content).decode('UTF-8')
|
||||
return content
|
||||
|
||||
def get_entry_text(self, path: str) -> Optional[str]:
|
||||
"""Search this Zim file for the provided Entry at `path`, read its contents and return the text in the HTML."""
|
||||
html = self.get_entry_html(path)
|
||||
content = extract_html_text(html)
|
||||
return content
|
||||
|
||||
def tag_entry(self, tag_name: str, zim_entry: str) -> 'TagZimEntry':
|
||||
"""Create a TagZimEntry for this Zim at the provided entry (path) for the provided Tag."""
|
||||
session = Session.object_session(self)
|
||||
tag = Tag.find_by_name(tag_name, session=session)
|
||||
tag_zim_entry = TagZimEntry(tag=tag, zim=self, zim_entry=zim_entry)
|
||||
session.add(tag_zim_entry)
|
||||
tags.schedule_save()
|
||||
return tag_zim_entry
|
||||
|
||||
def untag_entry(self, tag_name: str, zim_entry: str):
|
||||
"""Removes any TagZimEntry of the Zim entry at the provided path for the provided Tag.
|
||||
|
||||
@raise UnknownZimTagEntry: No entry exists to remove."""
|
||||
session = Session.object_session(self)
|
||||
tag = Tag.find_by_name(tag_name, session=session)
|
||||
tag_zim_entry = session.query(TagZimEntry).filter_by(
|
||||
tag_id=tag.id,
|
||||
zim_id=self.id,
|
||||
zim_entry=zim_entry,
|
||||
).one_or_none()
|
||||
if tag_zim_entry:
|
||||
session.delete(tag_zim_entry)
|
||||
else:
|
||||
raise UnknownZimTagEntry(f'Could not find at {repr(str(zim_entry))}')
|
||||
tags.schedule_save()
|
||||
|
||||
@optional_session
|
||||
def entries_with_tags(self, tag_names: List[str], offset: int = 0, limit: int = 10, session: Session = None) \
|
||||
-> List[Entry]:
|
||||
"""Return Zim Entries tagged with the provided names."""
|
||||
tags_sub_select, params = tag_names_to_zim_sub_select(tag_names, zim_id=self.id)
|
||||
|
||||
with get_db_curs() as curs:
|
||||
curs.execute(tags_sub_select, params)
|
||||
zim_ids_entries: List[Tuple[int, int]] = list(curs.fetchall())
|
||||
|
||||
tag_zim_entries = session.query(TagZimEntry, Zim) \
|
||||
.join(Zim, Zim.id == TagZimEntry.zim_id) \
|
||||
.filter(tuple_(TagZimEntry.zim_id, TagZimEntry.zim_entry).in_(zim_ids_entries)) \
|
||||
.order_by(TagZimEntry.zim_id, TagZimEntry.zim_entry) \
|
||||
.offset(offset).limit(limit) \
|
||||
.distinct(TagZimEntry.zim_id, TagZimEntry.zim_entry)
|
||||
|
||||
entries = list()
|
||||
for tag_zim_entry, zim in tag_zim_entries:
|
||||
entry = zim.get_entry(tag_zim_entry.zim_entry)
|
||||
entries.append(entry)
|
||||
|
||||
return entries
|
||||
|
||||
@functools.cached_property
|
||||
def all_entries(self) -> Set[EntrySummary]:
|
||||
"""
|
||||
Iterates over all Entries in the Archive. Ignores non-article entries (javascript, images, etc).
|
||||
"""
|
||||
zim = self.get_zim()
|
||||
zim_id = self.id
|
||||
entry_count = zim.all_entry_count
|
||||
entries = set()
|
||||
for entry_id in range(entry_count):
|
||||
entry: Entry = zim._get_entry_by_id(entry_id)
|
||||
title = entry.title
|
||||
if title.startswith('m/') or title.startswith('-/') or title.startswith('I/'):
|
||||
continue
|
||||
|
||||
summary = EntrySummary()
|
||||
summary.zim_id = zim_id
|
||||
summary.path = entry.path
|
||||
summary.title = title
|
||||
entries.add(summary)
|
||||
return entries
|
||||
|
||||
def parse_name(self):
|
||||
from modules.zim import lib
|
||||
name, date = lib.parse_name(self.path)
|
||||
return name, date
|
||||
|
||||
|
||||
class Zims:
|
||||
"""Convenience class which searches all known Zim files."""
|
||||
|
||||
@staticmethod
|
||||
@optional_session
|
||||
def get_all(session: Session = None) -> List[Zim]:
|
||||
"""Returns all Zim records."""
|
||||
zims = list()
|
||||
for zim in session.query(Zim).order_by(Zim.path):
|
||||
try:
|
||||
# It may not be possible to read the Zim file (drive not mounted), report and ignore.
|
||||
zim.get_zim()
|
||||
zims.append(zim)
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to read Zim: {zim}', exc_info=e)
|
||||
return zims
|
||||
|
||||
@classmethod
|
||||
@optional_session
|
||||
def estimate(cls, search_str: str, session: Session = None) -> OrderedDictType[Zim, int]:
|
||||
zims = cls.get_all(session=session)
|
||||
results = OrderedDict()
|
||||
for zim in zims:
|
||||
results[zim] = zim.estimate(search_str)
|
||||
return results
|
||||
|
||||
@classmethod
|
||||
@optional_session
|
||||
def entries_with_tags(cls, tag_names: List[str], session: Session = None) -> OrderedDictType[Zim, int]:
|
||||
zims = cls.get_all(session=session)
|
||||
results = OrderedDict()
|
||||
for zim in zims:
|
||||
results[zim] = len(zim.entries_with_tags(tag_names, session=session))
|
||||
return results
|
||||
|
||||
|
||||
class TagZimEntry(Base):
|
||||
__tablename__ = 'tag_zim'
|
||||
|
||||
tag_id = Column(Integer, ForeignKey('tag.id', ondelete='CASCADE'), primary_key=True)
|
||||
tag: Tag = relationship('Tag')
|
||||
zim_id = Column(Integer, ForeignKey('zim.id', ondelete='CASCADE'), primary_key=True)
|
||||
zim: Zim = relationship('Zim')
|
||||
zim_entry: str = Column(Text, nullable=False, primary_key=True)
|
||||
created_at: datetime = Column(TZDateTime, default=dates.now)
|
||||
|
||||
def __repr__(self):
|
||||
tag = self.tag_id
|
||||
if self.tag:
|
||||
tag = self.tag.name
|
||||
zim = self.zim_id
|
||||
if self.zim:
|
||||
zim = get_relative_to_media_directory(self.zim.path)
|
||||
return f'<TagZimEntry {tag=} {zim=} zim_entry={self.zim_entry}>'
|
||||
|
||||
|
||||
class ZimSubscription(Base):
|
||||
__tablename__ = 'zim_subscription'
|
||||
|
||||
id: int = Column(Integer, primary_key=True)
|
||||
name: str = Column(Text, unique=True, nullable=False)
|
||||
language: str = Column(Text, nullable=False)
|
||||
download_id: int = Column(Integer, ForeignKey('download.id', ondelete='CASCADE'), nullable=False)
|
||||
download: Download = relationship('Download', primaryjoin='ZimSubscription.download_id==Download.id')
|
||||
|
||||
def __repr__(self):
|
||||
name = self.name
|
||||
language = self.language
|
||||
download_id = self.download_id
|
||||
return f'<ZimSubscription id={self.id} {name=} {language=} {download_id=}>'
|
||||
|
||||
def __json__(self):
|
||||
d = dict(
|
||||
id=self.id,
|
||||
name=self.name,
|
||||
language=self.language,
|
||||
download_id=self.download_id,
|
||||
download_url=self.download.url if self.download else None,
|
||||
)
|
||||
return d
|
||||
|
||||
def change_download(self, url: str, frequency: int, session: Session):
|
||||
# A Zim file is found using the KiwixCatalogDownloader, then handled by the KiwixZimDownloader.
|
||||
old_url = self.download.url if self.download else url
|
||||
download = download_manager.get_or_create_download(old_url, session=session, reset_attempts=True)
|
||||
download.url = url
|
||||
download.frequency = frequency
|
||||
download.downloader = 'kiwix_catalog'
|
||||
download.sub_downloader = 'kiwix_zim'
|
||||
download.attempts = 0
|
||||
download.info_json = {'language': self.language, 'name': self.name}
|
||||
session.flush([download, ])
|
||||
self.download_id = download.id
|
||||
return download
|
||||
|
||||
@staticmethod
|
||||
@optional_session
|
||||
def get_or_create(name: str, session: Session = None) -> 'ZimSubscription':
|
||||
subscription = session.query(ZimSubscription).filter_by(name=name).one_or_none()
|
||||
if subscription:
|
||||
return subscription
|
||||
|
||||
subscription = ZimSubscription(name=name)
|
||||
return subscription
|
||||
95
modules/zim/schema.py
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
from dataclasses import dataclass, field
|
||||
from typing import Optional, List, Dict
|
||||
|
||||
|
||||
@dataclass
|
||||
class ZimSearchRequest:
|
||||
search_str: Optional[str] = None
|
||||
tag_names: List[str] = field(default_factory=lambda: list())
|
||||
offset: Optional[int] = 0
|
||||
limit: Optional[int] = 10
|
||||
|
||||
|
||||
@dataclass
|
||||
class ZimMetadata:
|
||||
path: str
|
||||
creator: str
|
||||
description: str
|
||||
name: str
|
||||
publisher: str
|
||||
tags: str
|
||||
title: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class Zim:
|
||||
id: int
|
||||
path: str
|
||||
file_group_id: int
|
||||
metadata: ZimMetadata
|
||||
size: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class GetZimsResponse:
|
||||
zims: List[Zim]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ZimSearchHeadline:
|
||||
headline: str
|
||||
rank: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class ZimSearchResult:
|
||||
path: str
|
||||
metadata: ZimMetadata
|
||||
search: List[ZimSearchHeadline]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ZimSearchResponse:
|
||||
zim: ZimSearchResult
|
||||
|
||||
|
||||
@dataclass
|
||||
class TagZimEntry:
|
||||
tag_name: str
|
||||
zim_id: int
|
||||
zim_entry: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class ZimSubscription:
|
||||
id: int
|
||||
name: str
|
||||
language: str
|
||||
download_id: int
|
||||
download_url: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class ZimProject:
|
||||
name: str
|
||||
languages: List[str]
|
||||
size: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class ZimSubscriptions:
|
||||
subscriptions: Dict[str, ZimSubscription]
|
||||
catalog: List[ZimProject]
|
||||
iso_639_codes: Dict[str, str]
|
||||
|
||||
|
||||
@dataclass
|
||||
class TagZimSubscribe:
|
||||
name: str
|
||||
language: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class OutdatedZims:
|
||||
outdated: List[str]
|
||||
current: List[str]
|
||||
0
modules/zim/test/__init__.py
Normal file
245
modules/zim/test/test_api.py
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
import json
|
||||
from http import HTTPStatus
|
||||
|
||||
import pytest
|
||||
|
||||
from modules.zim import lib
|
||||
from modules.zim.models import Zim
|
||||
from wrolpi.downloader import Download
|
||||
|
||||
|
||||
def test_zim_search(test_client, test_session, test_zim, tag_factory, assert_zim_search):
|
||||
"""Entries can be tagged and retrieved using their containing Zim."""
|
||||
tag1, tag2 = tag_factory(), tag_factory()
|
||||
test_zim.tag_entry(tag1.name, 'one')
|
||||
test_zim.tag_entry(tag2.name, 'one')
|
||||
test_zim.tag_entry(tag1.name, 'two')
|
||||
test_session.commit()
|
||||
|
||||
# Test searching with only `search_str`.
|
||||
assert_zim_search('one', 1, {
|
||||
'path': test_zim.path,
|
||||
'search': [
|
||||
# Entry "One" contains a 'one' in the title, and the text.
|
||||
{'path': 'one', 'headline': 'This is the first item', 'rank': 0.06079271},
|
||||
# Home only contains one 'one'.
|
||||
{'path': 'home', 'rank': 0.06079271, 'headline': 'file for testing.\n<b>One</b>', },
|
||||
],
|
||||
'estimate': 2,
|
||||
})
|
||||
assert_zim_search('two', 1, {
|
||||
'path': test_zim.path,
|
||||
'search': [{'path': 'two', 'headline': 'This is the second item', 'rank': 0.06079271},
|
||||
{'path': 'home', 'headline': 'testing.\nOne\n<b>Two</b>', 'rank': 0.06079271}],
|
||||
'estimate': 2,
|
||||
})
|
||||
assert_zim_search('item', 1, {
|
||||
'path': test_zim.path,
|
||||
'search': [{'path': 'one', 'headline': 'first <b>item</b>', 'rank': 0.06079271},
|
||||
{'path': 'two', 'headline': 'second <b>item</b>', 'rank': 0.06079271}],
|
||||
'estimate': 2,
|
||||
})
|
||||
|
||||
# Test searching with `search_str` and `tag_names`.
|
||||
assert_zim_search('one', 1, tag_names=[tag2.name, ], expected={
|
||||
'search': [
|
||||
# Only entry "One" is tagged with Tag 2.
|
||||
{'path': 'one', 'headline': 'This is the first item', 'rank': 0.06079271},
|
||||
],
|
||||
'estimate': 1,
|
||||
})
|
||||
assert_zim_search('one', 1, tag_names=[tag1.name, tag2.name], expected={
|
||||
'search': [
|
||||
# Only "One" is tagged with Tag 1 and contains "one".
|
||||
{'path': 'one', 'headline': 'This is the first item', 'rank': 0.06079271},
|
||||
],
|
||||
'estimate': 1,
|
||||
})
|
||||
assert_zim_search('two', 1, tag_names=[tag1.name, ], expected={
|
||||
'search': [
|
||||
# Only "Two" was tagged with Tag 1 and contains "two".
|
||||
{'path': 'two', 'headline': 'This is the second item', 'rank': 0.06079271},
|
||||
],
|
||||
'estimate': 1,
|
||||
})
|
||||
assert_zim_search('two', 1, tag_names=[tag2.name, ], expected={
|
||||
# Two was not tagged with Tag 1.
|
||||
'search': [],
|
||||
'estimate': 0,
|
||||
})
|
||||
|
||||
# Test search with only `tag_names`.
|
||||
assert_zim_search('', 1, tag_names=[tag1.name, tag2.name], expected={
|
||||
'search': [
|
||||
# Return all entries that are tagged with the Tags.
|
||||
{'path': 'one', 'headline': 'This is the first item', 'rank': 0.0, 'tag_names': ['one', 'two']},
|
||||
],
|
||||
'estimate': 1,
|
||||
})
|
||||
|
||||
|
||||
def test_entries_by_tag(test_client, test_session, test_zim, tag_factory):
|
||||
"""Entries can be tagged and retrieved using their containing Zim."""
|
||||
tag1, tag2 = tag_factory(), tag_factory()
|
||||
test_zim.tag_entry(tag1.name, 'one')
|
||||
test_zim.tag_entry(tag2.name, 'one')
|
||||
test_zim.tag_entry(tag1.name, 'two')
|
||||
test_session.commit()
|
||||
|
||||
result = test_zim.entries_with_tags([tag1.name, tag2.name])
|
||||
assert [(i.path, i.title) for i in result] == [('one', 'One'), ]
|
||||
|
||||
result = test_zim.entries_with_tags([tag1.name, ])
|
||||
assert [(i.path, i.title) for i in result] == [('one', 'One'), ('two', 'Two')]
|
||||
|
||||
result = test_zim.entries_with_tags([tag2.name, ])
|
||||
assert [(i.path, i.title) for i in result] == [('one', 'One'), ]
|
||||
|
||||
|
||||
def test_zim_crud(test_client, test_session, test_directory, test_zim):
|
||||
request, response = test_client.get('/api/zim')
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert len(response.json['zims']) == 1
|
||||
assert response.json['zims'][0]['path'] == test_zim.path.name
|
||||
|
||||
request, response = test_client.delete('/api/zim/1')
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT
|
||||
assert not test_zim.path.exists()
|
||||
assert test_session.query(Zim).count() == 0
|
||||
|
||||
|
||||
def test_zim_tag_estimate(test_client, test_session, test_zim, tag_factory):
|
||||
"""Global search estimate returns Zim estimates."""
|
||||
tag1, tag2 = tag_factory(), tag_factory()
|
||||
test_session.commit()
|
||||
|
||||
content = dict(search_str=None, tag_names=[tag1.name, ])
|
||||
request, response = test_client.post('/api/search_estimate', content=json.dumps(content))
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json['zims'] and response.json['zims'][0]['estimate'] == 0
|
||||
|
||||
# Entries can be tagged in the API.
|
||||
content = dict(tag_name=tag1.name, zim_id=test_zim.id, zim_entry='home')
|
||||
request, response = test_client.post('/api/zim/tag', content=json.dumps(content))
|
||||
assert response.status_code == HTTPStatus.CREATED
|
||||
# Create other Tags directly.
|
||||
test_zim.tag_entry(tag2.name, 'one')
|
||||
test_zim.tag_entry(tag1.name, 'two')
|
||||
test_session.commit()
|
||||
|
||||
content = dict(search_str=None, tag_names=[tag1.name, ])
|
||||
request, response = test_client.post('/api/search_estimate', content=json.dumps(content))
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json['zims'] and response.json['zims'][0]['estimate'] == 2
|
||||
|
||||
|
||||
def test_zim_tag_and_untag(test_client, test_session, test_zim, tag_factory):
|
||||
"""Zim entries can be Tagged and Untagged."""
|
||||
tag1, tag2 = tag_factory(), tag_factory()
|
||||
test_session.commit()
|
||||
|
||||
# Entries can be tagged in the API.
|
||||
content = dict(tag_name=tag1.name, zim_id=test_zim.id, zim_entry='home')
|
||||
request, response = test_client.post('/api/zim/tag', content=json.dumps(content))
|
||||
assert response.status_code == HTTPStatus.CREATED
|
||||
|
||||
# Entries can be untagged in the API.
|
||||
content = dict(tag_name=tag1.name, zim_id=test_zim.id, zim_entry='home')
|
||||
request, response = test_client.post('/api/zim/untag', content=json.dumps(content))
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT
|
||||
|
||||
# Cannot untag twice.
|
||||
content = dict(tag_name=tag1.name, zim_id=test_zim.id, zim_entry='home')
|
||||
request, response = test_client.post('/api/zim/untag', content=json.dumps(content))
|
||||
assert response.status_code == HTTPStatus.NOT_FOUND
|
||||
|
||||
|
||||
def test_zim_subscribe(test_session, test_client):
|
||||
"""A Kiwix subscription can be scheduled in the API. The language can be changed."""
|
||||
# Subscribe to English.
|
||||
content = dict(name='Wikipedia (mini)', language='en')
|
||||
request, response = test_client.post('/api/zim/subscribe', content=json.dumps(content))
|
||||
assert response.status_code == HTTPStatus.CREATED
|
||||
|
||||
download: Download = test_session.query(Download).one()
|
||||
assert download.url == 'https://download.kiwix.org/zim/wikipedia/wikipedia_en_all_mini_'
|
||||
assert download.info_json == {'language': 'en', 'name': 'Wikipedia (mini)'}
|
||||
assert download.frequency
|
||||
request, response = test_client.get('/api/zim/subscribe')
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json['subscriptions'] == {
|
||||
'Wikipedia (mini)': {'download_id': 1,
|
||||
'download_url': 'https://download.kiwix.org/zim/wikipedia/wikipedia_en_all_mini_',
|
||||
'id': 1,
|
||||
'language': 'en',
|
||||
'name': 'Wikipedia (mini)'}}
|
||||
|
||||
# Change subscription to French.
|
||||
content = dict(name='Wikipedia (mini)', language='fr')
|
||||
request, response = test_client.post('/api/zim/subscribe', content=json.dumps(content))
|
||||
assert response.status_code == HTTPStatus.CREATED
|
||||
assert test_session.query(Download).count() == 1
|
||||
|
||||
download: Download = test_session.query(Download).one()
|
||||
assert download.url == 'https://download.kiwix.org/zim/wikipedia/wikipedia_fr_all_mini_'
|
||||
assert download.info_json == {'language': 'fr', 'name': 'Wikipedia (mini)'}
|
||||
assert download.frequency
|
||||
request, response = test_client.get('/api/zim/subscribe')
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json['subscriptions'] == {
|
||||
'Wikipedia (mini)': {'download_id': 1,
|
||||
'download_url': 'https://download.kiwix.org/zim/wikipedia/wikipedia_fr_all_mini_',
|
||||
'id': 1,
|
||||
'language': 'fr',
|
||||
'name': 'Wikipedia (mini)'}}
|
||||
|
||||
# Delete subscription.
|
||||
request, response = test_client.delete('/api/zim/subscribe/1')
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT
|
||||
request, response = test_client.get('/api/zim/subscribe')
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json['subscriptions'] == {}
|
||||
# No subscriptions or downloads.
|
||||
assert test_session.query(lib.ZimSubscription).count() == 0
|
||||
assert test_session.query(Download).count() == 0
|
||||
|
||||
|
||||
def test_get_zim_entry(test_session, test_client, test_zim):
|
||||
request, response = test_client.get('/api/zim/1/entry/home')
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
|
||||
request, response = test_client.get('/api/zim/1/entry/does not exist')
|
||||
assert response.status_code == HTTPStatus.NOT_FOUND
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_outdated_zim_files(test_session, test_directory, test_zim_bytes, test_async_client):
|
||||
"""Outdated Zim files are found and reported. They can automatically be deleted."""
|
||||
# Can search empty directory.
|
||||
outdated, current = lib.find_outdated_zim_files()
|
||||
assert not outdated and not current
|
||||
|
||||
(test_directory / 'zims/books').mkdir(parents=True)
|
||||
|
||||
wikibooks_one = test_directory / 'zims/books/wikibooks_en_nopic_maxi_2023-01.zim' # unique
|
||||
wikipedia_one = test_directory / 'zims/wikipedia_en_all_maxi_2023-01.zim' # outdated
|
||||
wikipedia_two = test_directory / 'zims/wikipedia_en_all_maxi_2023-02.zim' # keep this one
|
||||
asdf = test_directory / 'zims/asdf.zim' # Name cannot be parsed.
|
||||
wikibooks_one.write_bytes(test_zim_bytes)
|
||||
wikipedia_one.write_bytes(test_zim_bytes)
|
||||
wikipedia_two.write_bytes(test_zim_bytes)
|
||||
asdf.write_bytes(test_zim_bytes)
|
||||
empty = test_directory / 'zims/wikipedia_en_all_maxi_2023-03.zim' # empty files should be ignored.
|
||||
empty.touch()
|
||||
request, response = await test_async_client.get('/api/zim/outdated')
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json['outdated'] == [str(wikipedia_one.relative_to(test_directory)), ]
|
||||
|
||||
request, response = await test_async_client.delete('/api/zim/outdated')
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT
|
||||
# Only the outdated was deleted.
|
||||
assert wikibooks_one.is_file()
|
||||
assert not wikipedia_one.exists()
|
||||
assert wikipedia_two.is_file()
|
||||
assert asdf.is_file()
|
||||
assert empty.is_file()
|
||||
300
modules/zim/test/test_lib.py
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
import datetime
|
||||
from http import HTTPStatus
|
||||
|
||||
import pytest
|
||||
from libzim import Entry
|
||||
|
||||
from modules.zim import lib
|
||||
from modules.zim.models import TagZimEntry, ZimSubscription
|
||||
from wrolpi import tags, flags
|
||||
from wrolpi.common import DownloadFileInfo
|
||||
from wrolpi.downloader import Download, import_downloads_config, save_downloads_config
|
||||
from wrolpi.errors import UnknownZim
|
||||
from wrolpi.files import lib as files_lib
|
||||
from wrolpi.files.models import FileGroup
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_zim(test_session, zim_path_factory):
|
||||
zim_path_factory()
|
||||
await files_lib.refresh_files()
|
||||
|
||||
assert lib.get_zim(1)
|
||||
|
||||
with pytest.raises(UnknownZim):
|
||||
assert lib.get_zim(2)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_zim_get_entry(test_session, zim_path_factory):
|
||||
zim_path_factory()
|
||||
await files_lib.refresh_files()
|
||||
|
||||
entry: Entry = lib.get_entry('one', 1)
|
||||
assert entry.path == 'one'
|
||||
assert entry.title == 'One'
|
||||
assert bytes(entry.get_item().content).decode('UTF-8') == '''
|
||||
<html>
|
||||
<head>
|
||||
<title>One</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>This is the first item</h1>
|
||||
</body>
|
||||
</html>
|
||||
'''
|
||||
|
||||
entry: Entry = lib.get_entry('home', 1)
|
||||
assert entry.path == 'home'
|
||||
assert entry.title == 'Homepage'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_zim_get_entries_tags(test_session, test_zim, tag_factory):
|
||||
tag1, tag2 = tag_factory('tag1'), tag_factory('tag2')
|
||||
test_zim.tag_entry(tag1.name, 'one')
|
||||
test_zim.tag_entry(tag2.name, 'one')
|
||||
test_zim.tag_entry(tag1.name, 'two')
|
||||
test_session.commit()
|
||||
|
||||
assert lib.get_entries_tags(['one', 'two', 'home'], 1) \
|
||||
== dict(one=['tag1', 'tag2'], two=['tag1'], home=[])
|
||||
|
||||
|
||||
def test_get_unique_paths():
|
||||
paths = ['A/Prickly_Pear', 'A/Prickly_pear', 'A/Prickly-pear', 'A/Arborescent_Prickly_Pear',
|
||||
'A/Arborescent_prickly_pear', 'A/Beavertail_prickly_pear', 'A/Brittle_prickly-pear',
|
||||
'A/Chenille_prickly-pear', 'A/Coastal_Prickly_Pear', 'A/Coastal_prickly_pear',
|
||||
'A/Prickly_pears_in_South_Africa', 'A/Prickly_Pear_Cays', 'A/Prickly_pears_in_Australia', 'A/Opuntia',
|
||||
'A/Prickly_Pear_(British_Virgin_Islands)', 'A/Prickly_Pear_Island', 'A/Prickly_Pears_(film)',
|
||||
'A/Invasive_succulent_plants_in_South_Africa', 'A/Cactoblastis_Memorial_Hall', 'A/Thomas_Harvey_Johnston']
|
||||
assert lib.get_unique_paths(*paths) == ['A/Prickly_Pear',
|
||||
'A/Arborescent_Prickly_Pear',
|
||||
'A/Beavertail_prickly_pear',
|
||||
'A/Brittle_prickly-pear',
|
||||
'A/Chenille_prickly-pear',
|
||||
'A/Coastal_Prickly_Pear',
|
||||
'A/Prickly_pears_in_South_Africa',
|
||||
'A/Prickly_Pear_Cays',
|
||||
'A/Prickly_pears_in_Australia',
|
||||
'A/Opuntia',
|
||||
'A/Prickly_Pear_(British_Virgin_Islands)',
|
||||
'A/Prickly_Pear_Island',
|
||||
'A/Prickly_Pears_(film)',
|
||||
'A/Invasive_succulent_plants_in_South_Africa',
|
||||
'A/Cactoblastis_Memorial_Hall',
|
||||
'A/Thomas_Harvey_Johnston']
|
||||
|
||||
|
||||
def test_zim_tags_config(test_session, test_directory, test_zim, tag_factory, test_tags_config, fake_now):
|
||||
fake_now(datetime.datetime(2000, 1, 1, 0, 0, 0, 1))
|
||||
config = tags.get_tags_config()
|
||||
assert not config.tag_zims
|
||||
|
||||
# Tag three Zim entries.
|
||||
tag1, tag2 = tag_factory('Tag1'), tag_factory('Tag2')
|
||||
test_zim.tag_entry(tag1.name, 'one')
|
||||
test_zim.tag_entry(tag2.name, 'one')
|
||||
test_zim.tag_entry(tag1.name, 'two')
|
||||
test_session.commit()
|
||||
# Tags are created in empty database.
|
||||
assert {i[0] for i in test_session.query(tags.Tag.id)} == {1, 2}
|
||||
|
||||
config = tags.get_tags_config()
|
||||
assert config.tag_zims == [
|
||||
['Tag1', test_zim.path.name, 'one', '2000-01-01T00:00:00.000001+00:00'],
|
||||
['Tag2', test_zim.path.name, 'one', '2000-01-01T00:00:00.000001+00:00'],
|
||||
['Tag1', test_zim.path.name, 'two', '2000-01-01T00:00:00.000001+00:00'],
|
||||
]
|
||||
|
||||
# Remove a tag, the config should change.
|
||||
test_zim.untag_entry(tag1.name, 'one')
|
||||
test_session.commit()
|
||||
|
||||
config = tags.get_tags_config()
|
||||
assert config.tag_zims == [
|
||||
['Tag2', test_zim.path.name, 'one', '2000-01-01T00:00:00.000001+00:00'],
|
||||
['Tag1', test_zim.path.name, 'two', '2000-01-01T00:00:00.000001+00:00'],
|
||||
]
|
||||
|
||||
# Delete all Tags so they are recreated.
|
||||
test_session.query(tags.Tag).delete()
|
||||
# Delete all TagFileEntry(s), import them again.
|
||||
test_session.query(TagZimEntry).delete()
|
||||
test_session.commit()
|
||||
|
||||
tags.import_tags_config()
|
||||
|
||||
tag_zim_entries = test_session.query(TagZimEntry) \
|
||||
.order_by(TagZimEntry.zim_id, TagZimEntry.zim_entry)
|
||||
assert [(i.tag.name, i.zim.path, i.zim_entry) for i in tag_zim_entries] \
|
||||
== [('Tag2', test_zim.path, 'one'), ('Tag1', test_zim.path, 'two')]
|
||||
|
||||
# Tags were recreated.
|
||||
assert {i[0] for i in test_session.query(tags.Tag.id)} == {3, 4}
|
||||
|
||||
|
||||
def test_zim_all_entries(test_session, test_zim):
|
||||
entries = [i.__json__() for i in test_zim.all_entries]
|
||||
assert len(entries) == 12
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_zim_download(test_session, kiwix_download_zim, test_directory, test_zim_bytes, flags_lock):
|
||||
await lib.subscribe('Wikipedia (with images)', 'es')
|
||||
|
||||
# Downloading the catalog should lead to a new Zim file being downloaded.
|
||||
await kiwix_download_zim(expected_url='https://download.kiwix.org/zim/wikipedia/wikipedia_es_all_maxi_2023-06.zim')
|
||||
recurring_download, once_download = test_session.query(Download).order_by(Download.id).all()
|
||||
recurring_download: Download
|
||||
once_download: Download
|
||||
assert recurring_download.attempts == 1 and recurring_download.status == 'complete', \
|
||||
'Recurring download should have succeeded'
|
||||
assert not any([i[0] for i in test_session.query(Download.error)]), 'All downloads should have succeeded'
|
||||
assert (test_directory / 'zims/wikipedia_es_all_maxi_2023-06.zim').is_file()
|
||||
assert (test_directory / 'zims/wikipedia_es_all_maxi_2023-06.zim').stat().st_size
|
||||
assert once_download.attempts == 1
|
||||
|
||||
# Download catalog again, should not re-download the existing file.
|
||||
recurring_download.status = 'new'
|
||||
test_session.commit()
|
||||
await kiwix_download_zim(expected_url='https://download.kiwix.org/zim/wikipedia/wikipedia_es_all_maxi_2023-06.zim',
|
||||
download_file_side_effect=Exception('should not be called twice'))
|
||||
recurring_download, once_download = test_session.query(Download).order_by(Download.id).all()
|
||||
assert recurring_download.attempts == 2 and recurring_download.status == 'complete', \
|
||||
'Recurring download should have happened again'
|
||||
assert not any([i[0] for i in test_session.query(Download.error)]), 'All downloads should have succeeded'
|
||||
# New file was modeled into a FileGroup.
|
||||
assert test_session.query(FileGroup).count() == 1
|
||||
# No outdated files exist.
|
||||
assert not flags.outdated_zims.is_set()
|
||||
|
||||
# Download catalog again, should fetch the latest file.
|
||||
recurring_download.status = 'new'
|
||||
test_session.commit()
|
||||
await kiwix_download_zim(expected_url='https://download.kiwix.org/zim/wikipedia/wikipedia_es_all_maxi_2024-01.zim',
|
||||
download_info=DownloadFileInfo(
|
||||
status=HTTPStatus.OK,
|
||||
name='wikipedia_es_all_maxi_2024-01.zim',
|
||||
size=len(test_zim_bytes),
|
||||
),
|
||||
hrefs=['?C=N;O=D', '?C=M;O=A', '?C=S;O=A', '?C=D;O=A', '/zim/',
|
||||
'wikipedia_es_all_maxi_2023-05.zim',
|
||||
'wikipedia_es_all_maxi_2023-06.zim',
|
||||
'wikipedia_es_all_maxi_2024-01.zim', # The new Zim.
|
||||
'wikipedia_es_all_nopic_2023-05.zim',
|
||||
'wikipedia_es_all_nopic_2023-06.zim', ]
|
||||
)
|
||||
recurring_download, first_download, second_download = test_session.query(Download).order_by(Download.id).all()
|
||||
assert recurring_download.attempts == 3 and recurring_download.status == 'complete', \
|
||||
'Recurring download should have happened again'
|
||||
assert not any([i[0] for i in test_session.query(Download.error)]), 'All downloads should have succeeded'
|
||||
assert (test_directory / 'zims/wikipedia_es_all_maxi_2023-06.zim').is_file()
|
||||
assert (test_directory / 'zims/wikipedia_es_all_maxi_2023-06.zim').stat().st_size
|
||||
assert (test_directory / 'zims/wikipedia_es_all_maxi_2024-01.zim').is_file()
|
||||
assert (test_directory / 'zims/wikipedia_es_all_maxi_2024-01.zim').stat().st_size
|
||||
# New file was modeled.
|
||||
assert test_session.query(FileGroup).count() == 2
|
||||
# But old file is now outdated.
|
||||
assert flags.outdated_zims.is_set()
|
||||
|
||||
# Delete the outdated file.
|
||||
await lib.remove_outdated_zim_files()
|
||||
assert test_session.query(FileGroup).count() == 1
|
||||
# No longer outdated
|
||||
assert not flags.outdated_zims.is_set()
|
||||
|
||||
|
||||
def test_zim_tag_migration(test_session, test_directory, zim_factory, tag_factory, test_tags_config):
|
||||
"""When an outdated Zim file is deleted, the Tags should be moved to the latest Zim."""
|
||||
zim1 = zim_factory('wikipedia_en_all_maxi_2020-01.zim') # The outdated Zim.
|
||||
zim2 = zim_factory('wikipedia_en_all_maxi_2020-02.zim')
|
||||
zim3 = zim_factory('wikipedia_en_all_maxi_2020-03.zim') # The latest Zim.
|
||||
tag1, tag2 = tag_factory('tag1'), tag_factory('tag2')
|
||||
# Tag outdated Zim entries.
|
||||
zim1.tag_entry(tag1.name, 'home')
|
||||
zim1.tag_entry(tag2.name, 'one')
|
||||
# This should not be migrated.
|
||||
zim2.tag_entry(tag2.name, 'two')
|
||||
# Tag the latest Zim as well.
|
||||
zim3.tag_entry(tag2.name, 'one')
|
||||
test_session.commit()
|
||||
assert test_session.query(TagZimEntry).count() == 4
|
||||
|
||||
# All Zims have been tagged.
|
||||
tags_config = tags.get_tags_config()
|
||||
tags_config.save_tags(session=test_session)
|
||||
assert 'wikipedia_en_all_maxi_2020-01.zim' in (config_text := tags_config.get_file().read_text())
|
||||
assert 'wikipedia_en_all_maxi_2020-02.zim' in config_text
|
||||
assert 'wikipedia_en_all_maxi_2020-03.zim' in config_text
|
||||
|
||||
# Zim was deleted, as well as it's ZimTagEntry(s).
|
||||
zim1.delete()
|
||||
tags.import_tags_config(test_session)
|
||||
|
||||
tag_zim_entries = sorted([(i.tag.name, i.zim.path, i.zim_entry) for i in test_session.query(TagZimEntry)])
|
||||
assert tag_zim_entries == [
|
||||
('tag1', zim3.path, 'home'),
|
||||
('tag2', zim2.path, 'two'),
|
||||
('tag2', zim3.path, 'one'), # Both "one" tags were combined.
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'url,expected',
|
||||
[
|
||||
('https://download.kiwix.org/zim/wikipedia/wikipedia_arz_all_maxi_', ('Wikipedia (with images)', 'arz')),
|
||||
('https://download.kiwix.org/zim/wikipedia/wikipedia_arz_all_nopic_', ('Wikipedia (no images)', 'arz')),
|
||||
('https://download.kiwix.org/zim/wikipedia/wikipedia_ar_all_mini_', ('Wikipedia (mini)', 'ar')),
|
||||
('https://download.kiwix.org/zim/wikiversity/wikiversity_zh_all_maxi_', ('Wikiversity (with images)', 'zh')),
|
||||
('https://download.kiwix.org/zim/wikiversity/wikiversity_zh_all_nopic_', ('Wikiversity (no images)', 'zh')),
|
||||
('https://download.kiwix.org/zim/wiktionary/wiktionary_eo_all_maxi_', ('Wikitionary (with images)', 'eo')),
|
||||
('https://download.kiwix.org/zim/wiktionary/wiktionary_eo_all_nopic_', ('Wikitionary (no images)', 'eo')),
|
||||
('https://download.kiwix.org/zim/wikibooks/wikibooks_es_all_nopic_', ('Wikibooks (no images)', 'es')),
|
||||
('https://download.kiwix.org/zim/wikibooks/wikibooks_es_all_maxi_', ('Wikibooks (with images)', 'es')),
|
||||
('https://download.kiwix.org/zim/wikisource/wikisource_bn_all_maxi_', ('Wikisource', 'bn')),
|
||||
('https://download.kiwix.org/zim/vikidia/vikidia_ru_all_maxi_', ('Vikidia', 'ru')),
|
||||
('https://download.kiwix.org/zim/stack_exchange/raspberrypi.stackexchange.com_en_all_',
|
||||
('Raspberry Pi (Stack Exchange)', 'en')),
|
||||
('https://download.kiwix.org/zim/stack_exchange/stackoverflow.com_en_all_',
|
||||
('Stackoverflow (Stack Exchange)', 'en')),
|
||||
('https://download.kiwix.org/zim/stack_exchange/ja.stackoverflow.com_ja_all_',
|
||||
('Stackoverflow (Stack Exchange)', 'ja')),
|
||||
('https://download.kiwix.org/zim/stack_exchange/superuser.com_en_all_', ('Superuser (Stack Exchange)', 'en')),
|
||||
('https://download.kiwix.org/zim/ifixit/ifixit_nl_all_', ('iFixit', 'nl')),
|
||||
('https://download.kiwix.org/zim/gutenberg/gutenberg_ang_all_', ('Gutenberg', 'ang')),
|
||||
]
|
||||
)
|
||||
def test_zim_download_url_to_name(url, expected):
|
||||
assert lib.zim_download_url_to_name(url) == expected
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_zim_subscription_download_import(test_session, test_downloader_config):
|
||||
# Subscription creates a ZimSubscription and Download
|
||||
await lib.subscribe('Wikisource', 'en', session=test_session)
|
||||
# Add a once-download. This should not be associated with a ZimSubscription.
|
||||
test_session.add(
|
||||
Download(url='https://download.kiwix.org/zim/wikibooks/wikibooks_en_all_maxi_2021-03.zim', status='complete'))
|
||||
test_session.commit()
|
||||
download = test_session.query(Download).filter(Download.frequency.isnot(None)).one()
|
||||
assert download.url == 'https://download.kiwix.org/zim/wikisource/wikisource_en_all_maxi_'
|
||||
|
||||
# Save config file, delete all entries.
|
||||
await save_downloads_config()
|
||||
test_session.query(Download).delete()
|
||||
test_session.query(ZimSubscription).delete()
|
||||
test_session.commit()
|
||||
|
||||
for _ in range(1): # Import twice.
|
||||
# Importing the config restores the Download and ZimSubscription.
|
||||
await import_downloads_config(session=test_session)
|
||||
recurring, once = test_session.query(Download).order_by(Download.frequency)
|
||||
assert once.url == 'https://download.kiwix.org/zim/wikibooks/wikibooks_en_all_maxi_2021-03.zim'
|
||||
assert test_session.query(Download).count() == 2
|
||||
assert recurring.url == 'https://download.kiwix.org/zim/wikisource/wikisource_en_all_maxi_'
|
||||
assert recurring.frequency
|
||||
subscription: ZimSubscription = test_session.query(ZimSubscription).one()
|
||||
assert subscription.download_id == recurring.id
|
||||
assert subscription.name == 'Wikisource'
|
||||
assert subscription.language == 'en'
|
||||
12
nginx.conf
|
|
@ -40,8 +40,16 @@ http {
|
|||
alias /opt/wrolpi/icon.png;
|
||||
}
|
||||
|
||||
location /epub.html {
|
||||
alias /opt/wrolpi/app/public/epub.html;
|
||||
location /epub/epub.html {
|
||||
alias /opt/wrolpi/app/public/epub/epub.html;
|
||||
}
|
||||
|
||||
location /epub/epub.js {
|
||||
alias /opt/wrolpi/app/public/epub/epub.js;
|
||||
}
|
||||
|
||||
location /epub/jszip.min.js {
|
||||
alias /opt/wrolpi/app/public/jszip.min.js;
|
||||
}
|
||||
|
||||
location / {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ beautifulsoup4==4.12.2
|
|||
cachetools==5.3.0
|
||||
ebooklib==0.18
|
||||
feedparser==6.0.10
|
||||
libzim==3.1.0
|
||||
mock==5.0.2
|
||||
psutil==5.9.5
|
||||
psycopg2-binary==2.9.6
|
||||
|
|
|
|||
10
scripts/dev_build.sh
Executable file
|
|
@ -0,0 +1,10 @@
|
|||
#! /usr/bin/env bash
|
||||
# Pulls, builds, and initializes the development Docker containers.
|
||||
|
||||
docker volume create --name=openstreetmap-data
|
||||
docker volume create --name=openstreetmap-rendered-tiles
|
||||
docker-compose pull
|
||||
docker-compose build --parallel
|
||||
docker-compose up -d db
|
||||
docker-compose run --rm api db upgrade
|
||||
docker-compose stop
|
||||
|
|
@ -10,6 +10,7 @@ set -e
|
|||
apt install -y \
|
||||
apt-transport-https \
|
||||
ca-certificates \
|
||||
calibre \
|
||||
chromium \
|
||||
chromium-driver \
|
||||
cpufrequtils \
|
||||
|
|
@ -19,6 +20,8 @@ apt install -y \
|
|||
gnupg-agent \
|
||||
hostapd \
|
||||
htop \
|
||||
kiwix \
|
||||
kiwix-tools \
|
||||
libpq-dev \
|
||||
network-manager \
|
||||
nginx-doc \
|
||||
|
|
@ -30,7 +33,8 @@ apt install -y \
|
|||
python3-pip \
|
||||
python3-venv \
|
||||
software-properties-common \
|
||||
vim
|
||||
vim \
|
||||
zim-tools
|
||||
|
||||
# Install npm.
|
||||
npm --version || curl -fsSL https://deb.nodesource.com/setup_18.x | bash -
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ set -e
|
|||
apt install -y \
|
||||
apt-transport-https \
|
||||
ca-certificates \
|
||||
calibre \
|
||||
chromium \
|
||||
chromium-driver \
|
||||
cpufrequtils \
|
||||
|
|
@ -19,6 +20,8 @@ apt install -y \
|
|||
gnupg-agent \
|
||||
hostapd \
|
||||
htop \
|
||||
kiwix \
|
||||
kiwix-tools \
|
||||
libpq-dev \
|
||||
network-manager \
|
||||
nginx-doc \
|
||||
|
|
@ -30,7 +33,8 @@ apt install -y \
|
|||
python3-pip \
|
||||
python3-venv \
|
||||
software-properties-common \
|
||||
vim
|
||||
vim \
|
||||
zim-tools
|
||||
|
||||
# Install npm.
|
||||
npm --version || curl -fsSL https://deb.nodesource.com/setup_18.x | bash -
|
||||
|
|
|
|||
BIN
test/zim.zim
Normal file
408
wrolpi/common.py
|
|
@ -2,6 +2,7 @@ import asyncio
|
|||
import atexit
|
||||
import contextlib
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
import multiprocessing
|
||||
import os
|
||||
|
|
@ -12,9 +13,11 @@ import sys
|
|||
import tempfile
|
||||
from asyncio import Task
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, date
|
||||
from decimal import Decimal
|
||||
from functools import wraps
|
||||
from http import HTTPStatus
|
||||
from itertools import islice, filterfalse, tee
|
||||
from multiprocessing import Lock, Manager
|
||||
from pathlib import Path
|
||||
|
|
@ -24,15 +27,16 @@ from urllib.parse import urlparse, urlunsplit
|
|||
|
||||
import aiohttp
|
||||
import yaml
|
||||
from aiohttp import ClientResponse
|
||||
from bs4 import BeautifulSoup
|
||||
from sqlalchemy import types
|
||||
from sqlalchemy.ext.compiler import compiles
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from wrolpi.dates import now, from_timestamp
|
||||
from wrolpi.errors import WROLModeEnabled, NativeOnly
|
||||
from wrolpi.vars import PYTEST, MODULES_DIR, \
|
||||
DOCKERIZED, CONFIG_DIR, MEDIA_DIRECTORY
|
||||
from wrolpi.dates import now, from_timestamp, seconds_to_timestamp
|
||||
from wrolpi.errors import WROLModeEnabled, NativeOnly, UnrecoverableDownloadError
|
||||
from wrolpi.vars import PYTEST, DOCKERIZED, CONFIG_DIR, MEDIA_DIRECTORY
|
||||
|
||||
logger = logging.getLogger()
|
||||
ch = logging.StreamHandler()
|
||||
|
|
@ -68,62 +72,69 @@ def log_level_context(level):
|
|||
|
||||
|
||||
__all__ = [
|
||||
'logger',
|
||||
'set_log_level',
|
||||
'Base',
|
||||
'ModelHelper',
|
||||
'get_model_by_table_name',
|
||||
'tsvector',
|
||||
'compile_tsvector',
|
||||
'WROLPI_CONFIG',
|
||||
'sanitize_link',
|
||||
'ConfigFile',
|
||||
'get_config',
|
||||
'set_test_config',
|
||||
'wrol_mode_enabled',
|
||||
'wrol_mode_check',
|
||||
'enable_wrol_mode',
|
||||
'disable_wrol_mode',
|
||||
'insert_parameter',
|
||||
'iterify',
|
||||
'date_range',
|
||||
'remove_whitespace',
|
||||
'run_after',
|
||||
'set_test_media_directory',
|
||||
'get_media_directory',
|
||||
'check_media_directory',
|
||||
'get_absolute_media_path',
|
||||
'get_relative_to_media_directory',
|
||||
'get_files_and_directories',
|
||||
'minimize_dict',
|
||||
'make_media_directory',
|
||||
'extract_domain',
|
||||
'import_modules',
|
||||
'api_param_limiter',
|
||||
'partition',
|
||||
'chdir',
|
||||
'zig_zag',
|
||||
'walk',
|
||||
'escape_file_name',
|
||||
'native_only',
|
||||
'recursive_map',
|
||||
'DownloadFileInfo',
|
||||
'DownloadFileInfoLink',
|
||||
'ModelHelper',
|
||||
'WROLPI_CONFIG',
|
||||
'aiohttp_get',
|
||||
'aiohttp_head',
|
||||
'aiohttp_post',
|
||||
'register_modeler',
|
||||
'api_param_limiter',
|
||||
'apply_modelers',
|
||||
'register_refresh_cleanup',
|
||||
'apply_refresh_cleanup',
|
||||
'match_paths_to_suffixes',
|
||||
'chunks',
|
||||
'chunks_by_stem',
|
||||
'timer',
|
||||
'cum_timer',
|
||||
'limit_concurrent',
|
||||
'truncate_object_bytes',
|
||||
'background_task',
|
||||
'get_warn_once',
|
||||
'truncate_generator_bytes',
|
||||
'cancel_refresh_tasks',
|
||||
'cancelable_wrapper',
|
||||
'chdir',
|
||||
'check_media_directory',
|
||||
'chunks',
|
||||
'chunks_by_stem',
|
||||
'compile_tsvector',
|
||||
'cum_timer',
|
||||
'date_range',
|
||||
'disable_wrol_mode',
|
||||
'download_file',
|
||||
'enable_wrol_mode',
|
||||
'escape_file_name',
|
||||
'extract_domain',
|
||||
'extract_headlines',
|
||||
'extract_html_text',
|
||||
'get_absolute_media_path',
|
||||
'get_config',
|
||||
'get_download_info',
|
||||
'get_files_and_directories',
|
||||
'get_media_directory',
|
||||
'get_model_by_table_name',
|
||||
'get_relative_to_media_directory',
|
||||
'get_warn_once',
|
||||
'insert_parameter',
|
||||
'iterify',
|
||||
'limit_concurrent',
|
||||
'logger',
|
||||
'make_media_directory',
|
||||
'match_paths_to_suffixes',
|
||||
'minimize_dict',
|
||||
'native_only',
|
||||
'partition',
|
||||
'recursive_map',
|
||||
'register_modeler',
|
||||
'register_refresh_cleanup',
|
||||
'remove_whitespace',
|
||||
'run_after',
|
||||
'sanitize_link',
|
||||
'set_log_level',
|
||||
'set_test_config',
|
||||
'set_test_media_directory',
|
||||
'timer',
|
||||
'truncate_generator_bytes',
|
||||
'truncate_object_bytes',
|
||||
'tsvector',
|
||||
'walk',
|
||||
'wrol_mode_check',
|
||||
'wrol_mode_enabled',
|
||||
'zig_zag',
|
||||
]
|
||||
|
||||
# Base is used for all SQLAlchemy models.
|
||||
|
|
@ -278,6 +289,7 @@ class WROLPiConfig(ConfigFile):
|
|||
hotspot_on_startup=True,
|
||||
hotspot_password='wrolpi hotspot',
|
||||
hotspot_ssid='WROLPi',
|
||||
ignore_outdated_zims=False,
|
||||
throttle_on_startup=False,
|
||||
wrol_mode=False,
|
||||
)
|
||||
|
|
@ -330,6 +342,14 @@ class WROLPiConfig(ConfigFile):
|
|||
def hotspot_ssid(self, value: str):
|
||||
self.update({'hotspot_ssid': value})
|
||||
|
||||
@property
|
||||
def ignore_outdated_zims(self) -> bool:
|
||||
return self._config['ignore_outdated_zims']
|
||||
|
||||
@ignore_outdated_zims.setter
|
||||
def ignore_outdated_zims(self, value: bool):
|
||||
self.update({'ignore_outdated_zims': value})
|
||||
|
||||
@property
|
||||
def throttle_on_startup(self) -> bool:
|
||||
return self._config['throttle_on_startup']
|
||||
|
|
@ -626,22 +646,6 @@ def extract_domain(url):
|
|||
return domain
|
||||
|
||||
|
||||
def import_modules():
|
||||
"""Import all WROLPi Modules in the modules directory. Raise an ImportError if there are no modules."""
|
||||
modules = [i.name for i in MODULES_DIR.iterdir() if
|
||||
i.is_dir() and not (i.name.startswith('_') or i.name.startswith('.'))]
|
||||
imported = []
|
||||
for module_name in modules:
|
||||
module = f'modules.{module_name}.api'
|
||||
logger_.debug(f'Importing {module}')
|
||||
try:
|
||||
__import__(module, globals(), locals(), [], 0)
|
||||
imported.append(module_name)
|
||||
except ImportError as e:
|
||||
logger_.fatal(f'Unable to import {module}', exc_info=e)
|
||||
return imported
|
||||
|
||||
|
||||
def api_param_limiter(maximum: int, default: int = 20) -> callable:
|
||||
"""Create a function which restricts the maximum number that can be returned.
|
||||
Useful for restricting API limit params.
|
||||
|
|
@ -817,14 +821,233 @@ def recursive_map(obj: Any, func: callable):
|
|||
return func(obj)
|
||||
|
||||
|
||||
async def aiohttp_post(url: str, json_, timeout: int = None) -> Tuple[Dict, int]:
|
||||
"""Perform an async aiohttp post request. Return the json contents."""
|
||||
@contextlib.asynccontextmanager
|
||||
async def aiohttp_session(timeout: int = None):
|
||||
"""Convenience function because aiohttp timeout cannot be None."""
|
||||
if timeout:
|
||||
timeout = aiohttp.ClientTimeout(total=timeout) if timeout else None
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
yield session
|
||||
else:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
yield session
|
||||
|
||||
|
||||
async def aiohttp_post(url: str, json_, timeout: int = None) -> Tuple[Dict, int]:
|
||||
"""Perform an async aiohttp POST request. Return the json contents."""
|
||||
async with aiohttp_session(timeout) as session:
|
||||
async with session.post(url, json=json_) as response:
|
||||
return await response.json(), response.status
|
||||
|
||||
|
||||
async def aiohttp_get(url: str, timeout: int = None, headers: dict = None) -> Tuple[bytes, int]:
|
||||
"""Perform an async aiohttp GET request. Return the contents."""
|
||||
async with aiohttp_session(timeout) as session:
|
||||
async with session.get(url, headers=headers) as response:
|
||||
return await response.content.read(), response.status
|
||||
|
||||
|
||||
async def aiohttp_head(url: str, timeout: int = None) -> Tuple[ClientResponse, int]:
|
||||
"""Perform an async aiohttp HEAD request. Return the contents."""
|
||||
async with aiohttp_session(timeout) as session:
|
||||
async with session.head(url) as response:
|
||||
return response, response.status
|
||||
|
||||
|
||||
async def speed_test(url: str) -> int:
|
||||
"""Request the last megabyte of the provided url, return the content length divided by the time elapsed (speed)."""
|
||||
response, status = await aiohttp_head(url)
|
||||
content_length = response.headers['Content-Length']
|
||||
start_bytes = int(content_length) - 10485760
|
||||
range_ = f'bytes={start_bytes}-{content_length}'
|
||||
start_time = datetime.now()
|
||||
content, status = await aiohttp_get(url, headers={'Range': range_})
|
||||
elapsed = int((datetime.now() - start_time).total_seconds())
|
||||
return len(content) // elapsed
|
||||
|
||||
|
||||
async def get_fastest_mirror(urls: List[str]) -> str:
|
||||
"""Perform a speed test on each URL, return the fastest."""
|
||||
fastest_url = urls[0]
|
||||
fastest_speed = 0
|
||||
for url in urls:
|
||||
try:
|
||||
speed = await speed_test(url)
|
||||
except Exception as e:
|
||||
logger.error(f'Speedtest of {url} failed', exc_info=e)
|
||||
continue
|
||||
|
||||
if speed > fastest_speed:
|
||||
fastest_url = url
|
||||
|
||||
return fastest_url
|
||||
|
||||
|
||||
@dataclass
|
||||
class DownloadFileInfoLink:
|
||||
"""Represents an HTTP "Link" Header."""
|
||||
url: str
|
||||
rel: str
|
||||
type: str
|
||||
priority: int
|
||||
geo: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class DownloadFileInfo:
|
||||
"""Information about a file that can be downloaded."""
|
||||
name: str = None
|
||||
size: int = None
|
||||
type: str = None
|
||||
accept_ranges: str = None
|
||||
status: int = None
|
||||
location: str = None
|
||||
links: List[DownloadFileInfoLink] = None
|
||||
|
||||
|
||||
FILENAME_MATCHER = re.compile(r'.*filename="(.*)"')
|
||||
|
||||
|
||||
async def get_download_info(url: str, timeout: int = 60) -> DownloadFileInfo:
|
||||
"""Gets information (name, size, etc.) about a downloadable file at the provided URL."""
|
||||
response, status = await aiohttp_head(url, timeout)
|
||||
try:
|
||||
links = response.headers.getall('Link')
|
||||
except KeyError:
|
||||
links = None
|
||||
|
||||
new_links = list()
|
||||
if links:
|
||||
# Convert "Link" header strings to DownloadFileInfoLink.
|
||||
for idx, link in enumerate(links):
|
||||
url, *props = link.split(';')
|
||||
url = url[1:-1]
|
||||
properties = dict()
|
||||
for prop in props:
|
||||
name, value = prop.strip().split('=')
|
||||
properties[name] = value
|
||||
new_links.append(DownloadFileInfoLink(
|
||||
url,
|
||||
properties.get('rel').strip() if 'rel' in properties else None,
|
||||
properties.get('type').strip() if 'type' in properties else None,
|
||||
int(properties.get('pri').strip()) if 'pri' in properties else None,
|
||||
properties.get('geo').strip() if 'geo' in properties else None,
|
||||
))
|
||||
|
||||
info = DownloadFileInfo(
|
||||
type=response.headers.get('Content-Type'),
|
||||
size=int(response.headers['Content-Length']) if 'Content-Length' in response.headers else None,
|
||||
accept_ranges=response.headers.get('Accept-Ranges'),
|
||||
status=response.status,
|
||||
location=response.headers.get('Location'),
|
||||
links=new_links,
|
||||
)
|
||||
|
||||
disposition = response.headers.get('Content-Disposition')
|
||||
|
||||
if disposition and 'filename' in disposition:
|
||||
if (match := FILENAME_MATCHER.match(disposition)) and (groups := match.groups()):
|
||||
info.name = groups[0]
|
||||
else:
|
||||
# No Content-Disposition with filename, use the URL name.
|
||||
parsed = urlparse(url)
|
||||
info.name = parsed.path.split('/')[-1]
|
||||
|
||||
return info
|
||||
|
||||
|
||||
download_logger = logger_.getChild('download')
|
||||
|
||||
|
||||
async def download_file(url: str, output_path: pathlib.Path = None, info: DownloadFileInfo = None,
|
||||
timeout: int = 7 * 24 * 60 * 60):
|
||||
"""Uses aiohttp to download an HTTP file. Performs a speed test when mirrors are found, downloads from the fastest
|
||||
mirror.
|
||||
|
||||
Attempts to resume the file if `output_path` already exists.
|
||||
|
||||
@warning: Timeout default is a week because of large downloads.
|
||||
"""
|
||||
info = info or await get_download_info(url, timeout)
|
||||
|
||||
if output_path.is_file() and info.size == output_path.stat().st_size:
|
||||
download_logger.warning(f'Already downloaded {repr(str(url))} to {repr(str(output_path))}')
|
||||
return
|
||||
|
||||
if info.links and (mirror_urls := [i.url for i in info.links if i.rel == 'duplicate']):
|
||||
# Mirrors are available, find the fastest.
|
||||
download_logger.info(f'Performing download speed test on mirrors: {mirror_urls}')
|
||||
url = await get_fastest_mirror(mirror_urls)
|
||||
info = await get_download_info(url, timeout)
|
||||
|
||||
total_size = info.size
|
||||
|
||||
download_logger.info(f'Starting download of {url} with {total_size} total bytes')
|
||||
if info.accept_ranges == 'bytes' or not output_path.is_file():
|
||||
with output_path.open('ab') as fh:
|
||||
headers = dict()
|
||||
# Check the position of append, if it is 0 then we do not need to resume.
|
||||
position = fh.tell()
|
||||
if position:
|
||||
headers['Range'] = f'bytes={position}-'
|
||||
|
||||
async with aiohttp_session(timeout=timeout) as session:
|
||||
async with session.get(url, headers=headers) as response:
|
||||
response: ClientResponse
|
||||
if response.status == HTTPStatus.REQUESTED_RANGE_NOT_SATISFIABLE:
|
||||
download_logger.warning(f'Server responded with 416, file is probably already downloaded')
|
||||
return
|
||||
|
||||
if position and response.status != HTTPStatus.PARTIAL_CONTENT:
|
||||
raise UnrecoverableDownloadError(
|
||||
f'Tried to resume {repr(str(url))} but got status {response.status}')
|
||||
|
||||
# May or may not be using Range. Append each chunk to the output file.
|
||||
last_report = datetime.now()
|
||||
bytes_received = 0
|
||||
async for data in response.content.iter_any():
|
||||
fh.write(data)
|
||||
|
||||
bytes_received += len(data)
|
||||
if (elapsed := (datetime.now() - last_report).total_seconds()) > 10:
|
||||
# Report download speed every 10 seconds.
|
||||
bytes_per_second = int(bytes_received // elapsed)
|
||||
download_logger.debug(f'{bytes_received=} {elapsed=} {bytes_per_second=}')
|
||||
size = fh.tell()
|
||||
bytes_remaining = total_size - size
|
||||
seconds_remaining = bytes_remaining // bytes_per_second
|
||||
percent = int((size / total_size) * 100)
|
||||
download_logger.info(
|
||||
f'Downloading {url} at'
|
||||
f' rate={human_bandwidth(bytes_per_second)}'
|
||||
f' estimate={seconds_to_timestamp(seconds_remaining)}')
|
||||
download_logger.debug(f'Downloading {url} {total_size=} {size=} {percent=}')
|
||||
last_report = datetime.now()
|
||||
bytes_received = 0
|
||||
|
||||
# TODO this cannot be canceled.
|
||||
# Sleep to catch cancel.
|
||||
await asyncio.sleep(0)
|
||||
elif output_path.is_file():
|
||||
# TODO support downloading files that cannot be resumed.
|
||||
raise UnrecoverableDownloadError(f'Cannot resume download {url}')
|
||||
|
||||
|
||||
def human_bandwidth(bps: int) -> str:
|
||||
"""Convert bits per second to a more readable format.
|
||||
|
||||
>>> human_bandwidth(2000)
|
||||
# '2 Kbps'
|
||||
"""
|
||||
units = ['bps', 'Kbps', 'Mbps', 'Gbps', 'Tbps']
|
||||
unit = 0
|
||||
while bps > 1000 and unit <= len(units):
|
||||
bps /= 1000
|
||||
unit += 1
|
||||
|
||||
return f'{int(bps)} {units[unit]}'
|
||||
|
||||
|
||||
modelers = []
|
||||
|
||||
|
||||
|
|
@ -1189,3 +1412,48 @@ def url_strip_host(url: str) -> str:
|
|||
url = urlparse(url)
|
||||
url = urlunsplit(('', '', url.path, url.query, url.fragment))
|
||||
return url or '/'
|
||||
|
||||
|
||||
def extract_html_text(html: str) -> str:
|
||||
soup = BeautifulSoup(html, features='html.parser')
|
||||
|
||||
# kill all script and style elements
|
||||
for script in soup(["script", "style"]):
|
||||
script.extract() # rip it out
|
||||
|
||||
text = soup.body.get_text()
|
||||
|
||||
# break into lines and remove leading and trailing space on each
|
||||
lines = (line.strip() for line in text.splitlines())
|
||||
# break multi-headlines into a line each
|
||||
chunks_ = (phrase.strip() for line in lines for phrase in line.split(" "))
|
||||
# drop blank lines
|
||||
text = '\n'.join(chunk for chunk in chunks_ if chunk)
|
||||
|
||||
return text
|
||||
|
||||
|
||||
def extract_headlines(entries: List[str], search_str: str) -> List[Tuple[str, float]]:
|
||||
"""Use Postgres to extract Headlines and ranks of the `search_str` from the provided entries."""
|
||||
from wrolpi.db import get_db_curs
|
||||
|
||||
source = json.dumps([{'content': i} for i in entries])
|
||||
with get_db_curs() as curs:
|
||||
stmt = '''
|
||||
WITH vectored AS (
|
||||
-- Convert the "source" json to a recordset.
|
||||
with source as (select * from json_to_recordset(%s::json) AS (content TEXT))
|
||||
select
|
||||
to_tsvector('english'::regconfig, source.content) AS vector,
|
||||
source.content
|
||||
from source
|
||||
)
|
||||
SELECT
|
||||
ts_headline(vectored.content, websearch_to_tsquery(%s), 'MaxFragments=10, MaxWords=8, MinWords=7'),
|
||||
ts_rank(vectored.vector, websearch_to_tsquery(%s))
|
||||
FROM vectored
|
||||
'''
|
||||
curs.execute(stmt, [source, search_str, search_str])
|
||||
headlines = [tuple(i) for i in curs.fetchall()]
|
||||
|
||||
return headlines
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import shutil
|
|||
import tempfile
|
||||
import zipfile
|
||||
from abc import ABC
|
||||
from datetime import datetime
|
||||
from itertools import zip_longest
|
||||
from typing import List, Callable, Union, Dict, Sequence
|
||||
from typing import Tuple, Set
|
||||
|
|
@ -22,7 +23,8 @@ import pytest
|
|||
import sqlalchemy
|
||||
import yaml
|
||||
from PIL import Image
|
||||
from sanic_testing.testing import SanicTestClient
|
||||
from sanic_testing.reusable import ReusableClient
|
||||
from sanic_testing.testing import SanicASGITestClient
|
||||
from sqlalchemy.engine import Engine, create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
|
|
@ -120,10 +122,11 @@ def test_config(test_directory) -> pathlib.Path:
|
|||
ROUTES_ATTACHED = False
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def test_client() -> SanicTestClient:
|
||||
"""
|
||||
Get a Sanic Test Client with all default routes attached.
|
||||
@pytest.fixture()
|
||||
def test_client() -> ReusableClient:
|
||||
"""Get a Reusable Sanic Test Client with all default routes attached.
|
||||
|
||||
(A non-reusable client would turn on for each request)
|
||||
"""
|
||||
global ROUTES_ATTACHED
|
||||
if ROUTES_ATTACHED is False:
|
||||
|
|
@ -132,7 +135,22 @@ def test_client() -> SanicTestClient:
|
|||
api_app.blueprint(bp)
|
||||
ROUTES_ATTACHED = True
|
||||
|
||||
yield api_app.test_client
|
||||
client = ReusableClient(api_app)
|
||||
with client:
|
||||
yield client
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def test_async_client() -> SanicASGITestClient:
|
||||
"""Get an Async Sanic Test Client with all default routes attached."""
|
||||
global ROUTES_ATTACHED
|
||||
if ROUTES_ATTACHED is False:
|
||||
# Attach any blueprints for the test.
|
||||
for bp in BLUEPRINTS:
|
||||
api_app.blueprint(bp)
|
||||
ROUTES_ATTACHED = True
|
||||
|
||||
yield SanicASGITestClient(api_app)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
@ -163,6 +181,7 @@ async def test_download_manager(
|
|||
@pytest.fixture
|
||||
def fake_now():
|
||||
try:
|
||||
set_test_now(datetime(2000, 1, 1))
|
||||
yield set_test_now
|
||||
finally:
|
||||
# reset now() to its original functionality.
|
||||
|
|
@ -279,6 +298,11 @@ def vtt_file2(test_directory) -> pathlib.Path:
|
|||
yield destination
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def srt_text() -> str:
|
||||
return (PROJECT_DIR / 'test/example3.en.srt').read_text()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def srt_file3(test_directory) -> pathlib.Path:
|
||||
"""Return a copy of the example3 SRT file in the `test_directory`."""
|
||||
|
|
@ -407,10 +431,13 @@ def tag_factory(test_session):
|
|||
names = ['one', 'two', 'three', 'four', 'five', 'six']
|
||||
count = 1
|
||||
|
||||
def factory() -> Tag:
|
||||
tag = Tag(name=names.pop(0), color=f'#{str(count) * 6}')
|
||||
def factory(name: str = None) -> Tag:
|
||||
if not name:
|
||||
name = names.pop(0)
|
||||
tag = Tag(name=name, color=f'#{str(count) * 6}')
|
||||
test_session.add(tag)
|
||||
test_session.commit()
|
||||
test_session.flush([tag, ])
|
||||
return tag
|
||||
|
||||
return factory
|
||||
|
|
|
|||
31
wrolpi/db.py
|
|
@ -1,4 +1,4 @@
|
|||
import pathlib
|
||||
import inspect
|
||||
import types
|
||||
from contextlib import contextmanager
|
||||
from functools import wraps
|
||||
|
|
@ -6,9 +6,9 @@ from typing import ContextManager, Tuple, List, Union, Type, Generator
|
|||
|
||||
import psycopg2
|
||||
import psycopg2.extensions
|
||||
import sqlalchemy
|
||||
import sqlalchemy.exc
|
||||
from psycopg2._psycopg import cursor
|
||||
from sqlalchemy import create_engine, inspect
|
||||
from sqlalchemy.orm import sessionmaker, Session
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
|
|
@ -40,14 +40,15 @@ def get_db_args(dbname: str = None):
|
|||
# This engine is used to modify the databases.
|
||||
connect_args = dict(application_name='wrolpi_api_super')
|
||||
postgres_args = get_db_args('postgres')
|
||||
postgres_engine = create_engine('postgresql://{user}:{password}@{host}:{port}/{dbname}'.format(**postgres_args),
|
||||
postgres_engine = sqlalchemy.create_engine(
|
||||
'postgresql://{user}:{password}@{host}:{port}/{dbname}'.format(**postgres_args),
|
||||
execution_options={'isolation_level': 'AUTOCOMMIT'}, connect_args=connect_args)
|
||||
|
||||
# This engine is used for all normal tasks (except testing).
|
||||
db_args = get_db_args()
|
||||
connect_args = dict(application_name='wrolpi_api', connect_timeout=1)
|
||||
uri = 'postgresql://{user}:{password}@{host}:{port}/{dbname}'.format(**db_args)
|
||||
engine = create_engine(uri, poolclass=NullPool, connect_args=connect_args)
|
||||
engine = sqlalchemy.create_engine(uri, poolclass=NullPool, connect_args=connect_args)
|
||||
session_maker = sessionmaker(bind=engine)
|
||||
|
||||
LOGGED_ARGS = False
|
||||
|
|
@ -69,7 +70,7 @@ def _get_db_session():
|
|||
return engine, session
|
||||
|
||||
|
||||
def get_db_context() -> Tuple[create_engine, Session]:
|
||||
def get_db_context() -> Tuple[sqlalchemy.create_engine, Session]:
|
||||
"""
|
||||
Get a DB engine and session.
|
||||
"""
|
||||
|
|
@ -122,7 +123,7 @@ def get_db_curs(commit: bool = False):
|
|||
connection.rollback()
|
||||
|
||||
|
||||
def optional_session(commit: Union[callable, bool] = False):
|
||||
def optional_session(commit: Union[callable, bool] = False) -> callable:
|
||||
"""
|
||||
Wraps a function, if a Session is passed it will be used. Otherwise, a new session will be
|
||||
created and passed to the function.
|
||||
|
|
@ -140,15 +141,26 @@ def optional_session(commit: Union[callable, bool] = False):
|
|||
|
||||
def call_func(func, session, args, kwargs):
|
||||
if session:
|
||||
return func(*args, session=session, **kwargs)
|
||||
result = func(*args, session=session, **kwargs)
|
||||
if commit:
|
||||
session.commit()
|
||||
else:
|
||||
with get_db_session() as session:
|
||||
return func(*args, session=session, **kwargs)
|
||||
result = func(*args, session=session, **kwargs)
|
||||
if commit:
|
||||
session.commit()
|
||||
return result
|
||||
|
||||
def wrapper(*w_args, **w_kwargs):
|
||||
if len(w_args) == 1 and len(w_kwargs) == 0 and callable(w_args[0]):
|
||||
func = w_args[0]
|
||||
|
||||
if inspect.iscoroutinefunction(func):
|
||||
@wraps(func)
|
||||
async def wrapped(*args, session: Session = None, **kwargs):
|
||||
session, args, kwargs = find_session(*args, session=session, **kwargs)
|
||||
return await call_func(func, session, args, kwargs)
|
||||
else:
|
||||
@wraps(func)
|
||||
def wrapped(*args, session: Session = None, **kwargs):
|
||||
session, args, kwargs = find_session(*args, session=session, **kwargs)
|
||||
|
|
@ -156,6 +168,7 @@ def optional_session(commit: Union[callable, bool] = False):
|
|||
|
||||
return wrapped
|
||||
else:
|
||||
# No params were passed to this wrapper, `commit` is actually the function we are wrapping.
|
||||
session_, w_args, w_kwargs = find_session(*w_args, **w_kwargs)
|
||||
return call_func(commit, session_, w_args, w_kwargs)
|
||||
|
||||
|
|
@ -165,7 +178,7 @@ def optional_session(commit: Union[callable, bool] = False):
|
|||
@optional_session
|
||||
def get_ranked_models(ranked_primary_keys: List, model: Type[Base], session: Session = None) -> List[Base]:
|
||||
"""Get all objects whose primary keys are in the `ranked_primary_keys`, preserve their order."""
|
||||
pkey = inspect(model).primary_key[0]
|
||||
pkey = sqlalchemy.inspect(model).primary_key[0]
|
||||
pkey_name = pkey.name
|
||||
results = list(session.query(model).filter(pkey.in_(ranked_primary_keys)).all())
|
||||
results = sorted(results, key=lambda i: ranked_primary_keys.index(getattr(i, pkey_name)))
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
import multiprocessing
|
||||
import os
|
||||
|
|
@ -10,9 +9,7 @@ from abc import ABC
|
|||
from dataclasses import dataclass, field
|
||||
from datetime import timedelta, datetime
|
||||
from enum import Enum
|
||||
from functools import partial
|
||||
from itertools import filterfalse
|
||||
from operator import attrgetter
|
||||
from queue import Empty
|
||||
from typing import List, Dict, Generator, Iterable
|
||||
from typing import Tuple, Optional
|
||||
|
|
@ -45,6 +42,7 @@ class DownloadFrequency(int, Enum):
|
|||
biweekly = weekly * 2
|
||||
days30 = daily * 30
|
||||
days90 = daily * 90
|
||||
days180 = daily * 180
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -371,7 +369,6 @@ class DownloadManager:
|
|||
result = DownloadResult(success=False, error=str(traceback.format_exc()))
|
||||
try_again = False
|
||||
except Exception as e:
|
||||
|
||||
worker_logger.warning(f'Failed to download {url}. Will be tried again later.', exc_info=e)
|
||||
result = DownloadResult(success=False, error=str(traceback.format_exc()))
|
||||
|
||||
|
|
@ -503,7 +500,7 @@ class DownloadManager:
|
|||
return downloader
|
||||
raise InvalidDownload(f'Cannot find downloader with name {name}')
|
||||
|
||||
def get_or_create_download(self, url: str, session: Session) -> Download:
|
||||
def get_or_create_download(self, url: str, session: Session, reset_attempts: bool = False) -> Download:
|
||||
"""Get a Download by its URL, if it cannot be found create one."""
|
||||
if not url:
|
||||
raise ValueError('Download must have a URL')
|
||||
|
|
@ -511,6 +508,9 @@ class DownloadManager:
|
|||
download = self.get_download(session, url=url)
|
||||
if not download:
|
||||
if url in get_download_manager_config().skip_urls:
|
||||
if reset_attempts is True:
|
||||
self.remove_from_skip_list(url)
|
||||
else:
|
||||
raise InvalidDownload(
|
||||
f'Refusing to download {url} because it is in the download_manager.yaml skip list')
|
||||
download = Download(url=url, status='new')
|
||||
|
|
@ -1086,6 +1086,9 @@ async def import_downloads_config(session: Session):
|
|||
logger.warning(f'Refusing to import downloads config when DB is not up.')
|
||||
return
|
||||
|
||||
from modules.zim.lib import zim_download_url_to_name
|
||||
from modules.zim.models import ZimSubscription
|
||||
|
||||
try:
|
||||
logger.warning('Importing downloads in config')
|
||||
|
||||
|
|
@ -1124,6 +1127,29 @@ async def import_downloads_config(session: Session):
|
|||
logger.error('Failed to import downloads', exc_info=e)
|
||||
raise
|
||||
|
||||
try:
|
||||
# Claim any Kiwix subscriptions for ZimSubscription(s).
|
||||
downloads: List[Download] = session.query(Download)
|
||||
need_commit = False
|
||||
for download in downloads:
|
||||
if not download.url.startswith('https://download.kiwix.org/') or not download.frequency:
|
||||
# ZimSubscription requires download.kiwix.org AND a frequency.
|
||||
continue
|
||||
|
||||
name, language = zim_download_url_to_name(download.url)
|
||||
subscription = session.query(ZimSubscription).filter_by(name=name, language=language).one_or_none()
|
||||
if not subscription:
|
||||
subscription = ZimSubscription(name=name, language=language)
|
||||
subscription.change_download(download.url, download.frequency, session=session)
|
||||
session.add(subscription)
|
||||
need_commit = True
|
||||
|
||||
if need_commit:
|
||||
session.commit()
|
||||
except Exception as e:
|
||||
logger.error('Failed to restore ZimSubscriptions', exc_info=e)
|
||||
raise
|
||||
|
||||
|
||||
def parse_feed(url: str) -> FeedParserDict:
|
||||
"""Calls `feedparser.parse`, used for testing."""
|
||||
|
|
|
|||
|
|
@ -172,6 +172,30 @@ class FileUploadFailed(APIError):
|
|||
pass
|
||||
|
||||
|
||||
class UnknownZim(APIError):
|
||||
pass
|
||||
|
||||
|
||||
class UnknownZimEntry(APIError):
|
||||
pass
|
||||
|
||||
|
||||
class UnknownZimTagEntry(APIError):
|
||||
pass
|
||||
|
||||
|
||||
class UnknownZimSubscription(APIError):
|
||||
pass
|
||||
|
||||
|
||||
class FileConflict(APIError):
|
||||
pass
|
||||
|
||||
|
||||
class DirectoryConflict(APIError):
|
||||
pass
|
||||
|
||||
|
||||
error_codes = iter(range(1, 1000))
|
||||
|
||||
API_ERRORS = {
|
||||
|
|
@ -380,4 +404,29 @@ API_ERRORS = {
|
|||
'message': 'Failed to upload file',
|
||||
'status': HTTPStatus.BAD_REQUEST,
|
||||
},
|
||||
UnknownZim: {
|
||||
'code': next(error_codes),
|
||||
'message': 'Failed to find the Zim',
|
||||
'status': HTTPStatus.NOT_FOUND,
|
||||
},
|
||||
UnknownZimEntry: {
|
||||
'code': next(error_codes),
|
||||
'message': 'Failed to find the Zim entry at that path',
|
||||
'status': HTTPStatus.NOT_FOUND,
|
||||
},
|
||||
UnknownZimTagEntry: {
|
||||
'code': next(error_codes),
|
||||
'message': 'Failed to find the TagZimEntry',
|
||||
'status': HTTPStatus.NOT_FOUND,
|
||||
},
|
||||
UnknownZimSubscription: {
|
||||
'code': next(error_codes),
|
||||
'message': 'Failed to find the ZimSubscription',
|
||||
'status': HTTPStatus.NOT_FOUND,
|
||||
},
|
||||
FileConflict: {
|
||||
'code': next(error_codes),
|
||||
'message': 'File/directory already exists',
|
||||
'status': HTTPStatus.CONFLICT,
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
from . import downloader # noqa
|
||||
from . import ebooks # noqa
|
||||
from . import pdfs # noqa
|
||||
from .api import bp
|
||||
from . import downloader # noqa
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import pathlib
|
||||
import shutil
|
||||
from http import HTTPStatus
|
||||
from multiprocessing import Manager
|
||||
from typing import List
|
||||
|
|
@ -8,11 +9,13 @@ from sanic import response, Request
|
|||
from sanic_ext import validate
|
||||
from sanic_ext.extensions.openapi import openapi
|
||||
|
||||
from wrolpi.common import get_media_directory, wrol_mode_check, get_relative_to_media_directory, logger, background_task
|
||||
from wrolpi.errors import InvalidFile, UnknownDirectory, FileUploadFailed
|
||||
from wrolpi.common import get_media_directory, wrol_mode_check, get_relative_to_media_directory, logger, \
|
||||
background_task, walk
|
||||
from wrolpi.errors import InvalidFile, UnknownDirectory, FileUploadFailed, FileConflict
|
||||
from wrolpi.root_api import get_blueprint, json_response
|
||||
from . import lib, schema
|
||||
from ..schema import JSONErrorResponse
|
||||
from ..vars import PYTEST
|
||||
|
||||
bp = get_blueprint('Files', '/api/files')
|
||||
|
||||
|
|
@ -50,9 +53,10 @@ async def get_file(_: Request, body: schema.FileRequest):
|
|||
)
|
||||
@validate(schema.DeleteRequest)
|
||||
async def delete_file(_: Request, body: schema.DeleteRequest):
|
||||
if not body.file:
|
||||
raise InvalidFile('file cannot be empty')
|
||||
lib.delete_file(body.file)
|
||||
paths = [i for i in body.paths if i]
|
||||
if not paths:
|
||||
raise InvalidFile('paths cannot be empty')
|
||||
lib.delete(*paths)
|
||||
return response.empty()
|
||||
|
||||
|
||||
|
|
@ -149,6 +153,125 @@ async def post_search_directories(_, body: schema.DirectoriesSearchRequest):
|
|||
return json_response(body)
|
||||
|
||||
|
||||
@bp.post('/get_directory')
|
||||
@openapi.definition(
|
||||
summary='Get data about a directory',
|
||||
body=schema.Directory,
|
||||
)
|
||||
@validate(schema.Directory)
|
||||
async def post_get_directory(_: Request, body: schema.Directory):
|
||||
path = get_media_directory() / body.path
|
||||
size = 0
|
||||
file_count = 0
|
||||
directory_count = 0
|
||||
for file in walk(path):
|
||||
if file.is_file():
|
||||
size += file.stat().st_size
|
||||
file_count += 1
|
||||
elif file.is_dir():
|
||||
directory_count += 1
|
||||
body = {
|
||||
'path': path,
|
||||
'size': size,
|
||||
'file_count': file_count,
|
||||
'directory_count': directory_count,
|
||||
}
|
||||
return json_response(body)
|
||||
|
||||
|
||||
@bp.post('/directory')
|
||||
@openapi.definition(
|
||||
summary='Create a directory in the media directory.',
|
||||
body=schema.Directory,
|
||||
)
|
||||
@validate(schema.Directory)
|
||||
async def post_create_directory(_: Request, body: schema.Directory):
|
||||
path = get_media_directory() / body.path
|
||||
try:
|
||||
path.mkdir()
|
||||
except FileExistsError:
|
||||
raise FileConflict(f'{path} already exists')
|
||||
|
||||
if PYTEST:
|
||||
await lib.refresh_files([path, ])
|
||||
else:
|
||||
background_task(lib.refresh_files([path, ]))
|
||||
|
||||
return response.empty(HTTPStatus.CREATED)
|
||||
|
||||
|
||||
@bp.post('/move')
|
||||
@openapi.definition(
|
||||
summary='Move a file/directory into another directory in the media directory.',
|
||||
body=schema.Move,
|
||||
)
|
||||
@validate(schema.Move)
|
||||
async def post_move(_: Request, body: schema.Move):
|
||||
destination = get_media_directory() / body.destination
|
||||
if not destination.is_dir():
|
||||
raise UnknownDirectory(f'Cannot move files into {destination} because it does not exist')
|
||||
sources = [get_media_directory() / i for i in body.paths]
|
||||
for source in sources:
|
||||
if not source.exists():
|
||||
raise FileNotFoundError(f'Cannot find {source} to move')
|
||||
|
||||
try:
|
||||
await lib.move(destination, *sources)
|
||||
except Exception as e:
|
||||
raise FileConflict(f'Failed to move {sources} to {destination}') from e
|
||||
|
||||
return response.empty(HTTPStatus.NO_CONTENT)
|
||||
|
||||
|
||||
@bp.post('/rename')
|
||||
@openapi.definition(
|
||||
summary='Rename a file/directory in-place.',
|
||||
body=schema.Rename,
|
||||
)
|
||||
@validate(schema.Rename)
|
||||
async def post_rename(_: Request, body: schema.Rename):
|
||||
path = get_media_directory() / body.path
|
||||
new_path = path.with_name(body.new_name)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f'Cannot rename path {str(repr(path))} because it does not exist')
|
||||
elif new_path.exists():
|
||||
raise FileConflict(f'File already exists: {new_path}')
|
||||
|
||||
try:
|
||||
await lib.rename(path, body.new_name)
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to rename {path} to {new_path}', exc_info=e)
|
||||
raise FileConflict(f'Failed to rename {path} to {new_path}') from e
|
||||
|
||||
if PYTEST:
|
||||
await lib.refresh_files([path, new_path])
|
||||
else:
|
||||
background_task(lib.refresh_files([path, new_path]))
|
||||
|
||||
return response.empty(HTTPStatus.NO_CONTENT)
|
||||
|
||||
|
||||
@bp.post('/delete_directory')
|
||||
@openapi.definition(
|
||||
summary='Delete a directory in the media directory.',
|
||||
body=schema.Directory,
|
||||
)
|
||||
@validate(schema.Directory)
|
||||
async def delete_directory(_: Request, body: schema.Directory):
|
||||
path = get_media_directory() / body.path
|
||||
if path.is_dir():
|
||||
shutil.rmtree(path)
|
||||
else:
|
||||
raise FileNotFoundError(f'No directory found: {path}')
|
||||
|
||||
if PYTEST:
|
||||
await lib.refresh_files([path, ])
|
||||
else:
|
||||
background_task(lib.refresh_files([path, ]))
|
||||
|
||||
return response.empty()
|
||||
|
||||
|
||||
@bp.post('/tag')
|
||||
@validate(schema.TagFileGroupPost)
|
||||
async def post_tag_file_group(_, body: schema.TagFileGroupPost):
|
||||
|
|
|
|||
|
|
@ -1,105 +1,21 @@
|
|||
import asyncio
|
||||
import pathlib
|
||||
import re
|
||||
import traceback
|
||||
from abc import ABC
|
||||
from dataclasses import dataclass
|
||||
from http import HTTPStatus
|
||||
from typing import List
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import aiohttp
|
||||
|
||||
from wrolpi.common import get_media_directory, logger, background_task
|
||||
from wrolpi.common import get_media_directory, logger, background_task, get_download_info, \
|
||||
download_file
|
||||
from wrolpi.db import get_db_session
|
||||
from wrolpi.downloader import Downloader, Download, DownloadResult
|
||||
from wrolpi.errors import UnrecoverableDownloadError
|
||||
|
||||
__all__ = ['get_download_info', 'download_file', 'FileDownloader', 'file_downloader']
|
||||
|
||||
from wrolpi.files.models import FileGroup
|
||||
|
||||
from wrolpi.vars import PYTEST
|
||||
|
||||
__all__ = ['FileDownloader', 'file_downloader']
|
||||
|
||||
logger = logger.getChild(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DownloadFileInfo:
|
||||
name: str = None
|
||||
size: int = None
|
||||
type: str = None
|
||||
accept_ranges: str = None
|
||||
|
||||
|
||||
FILENAME_MATCHER = re.compile(r'.*filename="(.*)"')
|
||||
|
||||
|
||||
async def get_download_info(url: str, timeout: int = 60) -> DownloadFileInfo:
|
||||
"""Gets information (name, size, etc.) about a downloadable file at the provided URL."""
|
||||
timeout = aiohttp.ClientTimeout(total=timeout) if timeout is not None else None
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.head(url) as response:
|
||||
info = DownloadFileInfo(
|
||||
type=response.headers.get('Content-Type'),
|
||||
size=int(response.headers['Content-Length']) * 8 if 'Content-Length' in response.headers else None,
|
||||
accept_ranges=response.headers.get('Accept-Ranges'),
|
||||
)
|
||||
|
||||
disposition = response.headers.get('Content-Disposition')
|
||||
|
||||
if disposition and 'filename' in disposition:
|
||||
if (match := FILENAME_MATCHER.match(disposition)) and (groups := match.groups()):
|
||||
info.name = groups[0]
|
||||
else:
|
||||
# No Content-Disposition with filename, use the URL name.
|
||||
parsed = urlparse(url)
|
||||
info.name = parsed.path.split('/')[-1]
|
||||
|
||||
return info
|
||||
|
||||
|
||||
async def download_file(url: str, info: DownloadFileInfo, output_path: pathlib.Path):
|
||||
"""Uses aiohttp to download an HTTP file.
|
||||
|
||||
Attempts to resume the file if `output_path` already exists.
|
||||
"""
|
||||
logger.debug(f'Starting download of file {repr(str(url))}')
|
||||
|
||||
if output_path.is_file() and info.size == (output_path.stat().st_size * 8):
|
||||
logger.warning(f'Already downloaded {repr(str(url))} to {repr(str(output_path))}')
|
||||
return
|
||||
|
||||
if info.accept_ranges == 'bytes' or not output_path.is_file():
|
||||
with open(output_path, 'ab') as fh:
|
||||
headers = dict()
|
||||
# Check the position of append, if it is 0 then we do not need to resume.
|
||||
position = fh.tell()
|
||||
if position:
|
||||
headers['Range'] = f'bytes={position}-'
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(url, headers=headers) as response:
|
||||
if response.status == HTTPStatus.REQUESTED_RANGE_NOT_SATISFIABLE:
|
||||
logger.warning(f'Server responded with 416, file is probably already downloaded')
|
||||
return
|
||||
|
||||
if position and response.status != HTTPStatus.PARTIAL_CONTENT:
|
||||
raise UnrecoverableDownloadError(
|
||||
f'Tried to resume {repr(str(url))} but got status {response.status}')
|
||||
|
||||
# May or may not be using Range. Append each chunk to the output file.
|
||||
async for data in response.content.iter_any():
|
||||
fh.write(data)
|
||||
|
||||
# TODO this cannot be canceled.
|
||||
# Sleep to catch cancel.
|
||||
await asyncio.sleep(0)
|
||||
elif output_path.is_file():
|
||||
# TODO support downloading files that cannot be resumed.
|
||||
raise UnrecoverableDownloadError(f'Cannot resume download {url}')
|
||||
|
||||
|
||||
class FileDownloader(Downloader, ABC):
|
||||
"""A Downloader which can download an arbitrary HTTP file.
|
||||
|
||||
|
|
@ -135,7 +51,7 @@ class FileDownloader(Downloader, ABC):
|
|||
output_path = destination / info.name
|
||||
|
||||
try:
|
||||
await download_file(download.url, info, output_path)
|
||||
await download_file(download.url, output_path, info)
|
||||
background_task(save_and_tag(output_path, download.settings.get('tag_names')))
|
||||
|
||||
return DownloadResult(
|
||||
|
|
|
|||
|
|
@ -2,11 +2,13 @@ import asyncio
|
|||
import datetime
|
||||
import functools
|
||||
import glob
|
||||
import itertools
|
||||
import json
|
||||
import multiprocessing
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from itertools import zip_longest
|
||||
from pathlib import Path
|
||||
|
|
@ -21,14 +23,14 @@ from wrolpi import flags
|
|||
from wrolpi.cmd import which
|
||||
from wrolpi.common import get_media_directory, wrol_mode_check, logger, limit_concurrent, \
|
||||
partition, cancelable_wrapper, \
|
||||
get_files_and_directories, chunks_by_stem, apply_modelers, apply_refresh_cleanup, background_task
|
||||
get_files_and_directories, chunks_by_stem, apply_modelers, apply_refresh_cleanup, background_task, walk
|
||||
from wrolpi.dates import now, from_timestamp
|
||||
from wrolpi.db import get_db_session, get_db_curs, mogrify, optional_session
|
||||
from wrolpi.errors import InvalidFile, UnknownDirectory, UnknownFile, UnknownTag
|
||||
from wrolpi.errors import InvalidFile, UnknownDirectory, UnknownFile, UnknownTag, FileConflict, FileGroupIsTagged
|
||||
from wrolpi.events import Events
|
||||
from wrolpi.files.models import FileGroup, Directory
|
||||
from wrolpi.lang import ISO_639_CODES
|
||||
from wrolpi.tags import TagFile, Tag
|
||||
from wrolpi.tags import TagFile, Tag, tag_names_to_file_group_sub_select
|
||||
from wrolpi.vars import PYTEST
|
||||
|
||||
try:
|
||||
|
|
@ -42,8 +44,9 @@ except ImportError:
|
|||
|
||||
logger = logger.getChild(__name__)
|
||||
|
||||
__all__ = ['list_directories_contents', 'delete_file', 'split_path_stem_and_suffix', 'refresh_files', 'search_files',
|
||||
'get_mimetype', 'split_file_name_words', 'get_primary_file']
|
||||
__all__ = ['list_directories_contents', 'delete', 'split_path_stem_and_suffix', 'refresh_files', 'search_files',
|
||||
'get_mimetype', 'split_file_name_words', 'get_primary_file', 'get_file_statistics', 'estimate_search',
|
||||
'move', 'rename', 'remove_directory']
|
||||
|
||||
|
||||
@optional_session
|
||||
|
|
@ -131,12 +134,41 @@ def list_directories_contents(directories_: List[str]) -> Dict:
|
|||
|
||||
|
||||
@wrol_mode_check
|
||||
def delete_file(file: str):
|
||||
"""Delete a file in the media directory."""
|
||||
file = get_media_directory() / file
|
||||
if file.is_dir() or not file.is_file():
|
||||
raise InvalidFile(f'Invalid file {file}')
|
||||
file.unlink()
|
||||
def delete(*paths: Union[str, pathlib.Path]):
|
||||
"""
|
||||
Delete a file or directory in the media directory.
|
||||
|
||||
This will refuse to delete any files (or files in the directories) that have been tagged."""
|
||||
media_directory = get_media_directory()
|
||||
paths = [media_directory / i for i in paths]
|
||||
if any(i == media_directory for i in paths):
|
||||
raise InvalidFile(f'Cannot delete the media directory')
|
||||
for path in paths:
|
||||
if not path.is_dir() and not path.is_file():
|
||||
raise InvalidFile(f'Cannot delete {path} because it is not a file or a directory.')
|
||||
if len(paths) > 1:
|
||||
for p1, p2 in itertools.permutations(paths):
|
||||
if str(p2).startswith(str(p1)):
|
||||
raise InvalidFile(f'Cannot deleted nested paths')
|
||||
with get_db_session() as session:
|
||||
# Search for any files that have been tagged.
|
||||
for path in paths:
|
||||
query = session.query(FileGroup, TagFile) \
|
||||
.join(TagFile, TagFile.file_group_id == FileGroup.id)
|
||||
if path.is_file():
|
||||
# Could be deleting a file in a FileGroup that has been tagged.
|
||||
stem, _ = split_path_stem_and_suffix(path)
|
||||
query = query.filter(FileGroup.primary_path.like(f'{path.parent}/{stem}%'))
|
||||
else:
|
||||
# Search for any FileGroups that have been tagged under this directory.
|
||||
query = query.filter(FileGroup.primary_path.like(f'{path}/%'))
|
||||
for (file_group, tag_file) in query:
|
||||
raise FileGroupIsTagged(f"Cannot delete {file_group} because it is tagged")
|
||||
for path in paths:
|
||||
if path.is_dir():
|
||||
remove_directory(path)
|
||||
else:
|
||||
path.unlink()
|
||||
|
||||
|
||||
FILE_NAME_REGEX = re.compile(r'[_ .]')
|
||||
|
|
@ -194,7 +226,6 @@ def get_mimetype(path: Path) -> str:
|
|||
output = subprocess.check_output(cmd)
|
||||
output = output.decode()
|
||||
mimetype = output.split(' ')[-1].strip()
|
||||
return mimetype
|
||||
else:
|
||||
mimetype = mime.from_file(path)
|
||||
mimetype = _mimetype_suffix_map(path, mimetype)
|
||||
|
|
@ -432,6 +463,7 @@ async def refresh_discover_paths(paths: List[pathlib.Path], idempotency: datetim
|
|||
if deleted:
|
||||
# Delete any paths that do not exist.
|
||||
deleted_files = ' OR '.join([curs.mogrify('primary_path = %s', (str(i),)).decode() for i in deleted])
|
||||
|
||||
idempotency = curs.mogrify('%s', (idempotency,)).decode()
|
||||
wheres = f' ( (idempotency != {idempotency} OR idempotency is null) AND ({wheres}))' if wheres else ''
|
||||
stmt = f'''
|
||||
|
|
@ -651,7 +683,7 @@ def search_files(search_str: str, limit: int, offset: int, mimetypes: List[str]
|
|||
|
||||
if tags:
|
||||
# Filter all FileGroups by those that have been tagged with the provided tag names.
|
||||
tags_stmt, params_ = tag_names_to_sub_select(tags)
|
||||
tags_stmt, params_ = tag_names_to_file_group_sub_select(tags)
|
||||
params.update(params_)
|
||||
wheres.append(f'fg.id = ANY({tags_stmt})')
|
||||
|
||||
|
|
@ -683,24 +715,6 @@ def search_files(search_str: str, limit: int, offset: int, mimetypes: List[str]
|
|||
return results, total
|
||||
|
||||
|
||||
def tag_names_to_sub_select(names: List[str]) -> Tuple[str, dict]:
|
||||
"""Create the SQL necessary to filter FileGroup by the provided Tag names."""
|
||||
if not names:
|
||||
return '', dict()
|
||||
|
||||
stmt = '''
|
||||
SELECT
|
||||
tf.file_group_id
|
||||
FROM
|
||||
tag_file tf
|
||||
LEFT JOIN tag t on t.id = tf.tag_id
|
||||
GROUP BY file_group_id
|
||||
-- Match only FileGroups that have at least all the Tag names.
|
||||
HAVING array_agg(t.name)::TEXT[] @> %(tag_names)s::TEXT[]
|
||||
'''
|
||||
return stmt, dict(tag_names=names)
|
||||
|
||||
|
||||
def handle_file_group_search_results(statement: str, params: dict) -> Tuple[List[dict], int]:
|
||||
"""
|
||||
Execute the provided SQL statement and fetch the Files returned.
|
||||
|
|
@ -938,7 +952,8 @@ def get_refresh_progress():
|
|||
with get_db_curs() as curs:
|
||||
curs.execute('''
|
||||
SELECT
|
||||
COUNT(id) AS "total_file_groups",
|
||||
-- Sum all the files in each FileGroup.
|
||||
SUM(json_array_length(files)) AS "total_file_groups",
|
||||
COUNT(id) FILTER (WHERE indexed IS TRUE) AS "indexed",
|
||||
COUNT(id) FILTER (WHERE indexed IS FALSE) AS "unindexed",
|
||||
COUNT(id) FILTER (WHERE model IS NOT NULL) AS "modeled"
|
||||
|
|
@ -962,3 +977,196 @@ def get_refresh_progress():
|
|||
)
|
||||
|
||||
return status
|
||||
|
||||
|
||||
async def estimate_search(search_str: str, tag_names: List[str]):
|
||||
with get_db_curs() as curs:
|
||||
if search_str and tag_names:
|
||||
params = dict(search_str=search_str, tag_names=tag_names)
|
||||
stmt = '''
|
||||
SELECT COUNT(*) OVER() AS estimate
|
||||
FROM file_group
|
||||
LEFT JOIN tag_file tf on file_group.id = tf.file_group_id
|
||||
WHERE textsearch @@ websearch_to_tsquery(%(search_str)s)
|
||||
AND tf.tag_id IN (select id from tag where name = ANY(%(tag_names)s))
|
||||
'''
|
||||
elif tag_names:
|
||||
params = dict(tag_names=tag_names)
|
||||
stmt = '''
|
||||
SELECT COUNT(*) OVER() AS estimate
|
||||
FROM file_group
|
||||
LEFT JOIN tag_file tf on file_group.id = tf.file_group_id
|
||||
WHERE tf.tag_id IN (select id from tag where name = ANY(%(tag_names)s))'''
|
||||
else:
|
||||
params = dict(search_str=search_str)
|
||||
stmt = '''
|
||||
SELECT COUNT(*) OVER() AS estimate
|
||||
FROM file_group
|
||||
WHERE textsearch @@ websearch_to_tsquery(%(search_str)s)
|
||||
'''
|
||||
curs.execute(stmt, params)
|
||||
result = curs.fetchone()
|
||||
if result:
|
||||
return int(result['estimate'])
|
||||
return 0
|
||||
|
||||
|
||||
def _move(destination: pathlib.Path, *sources: pathlib.Path) -> List[Tuple[pathlib.Path, pathlib.Path]]:
|
||||
media_directory = get_media_directory()
|
||||
for source in sources:
|
||||
if not str(source).startswith(str(media_directory)):
|
||||
raise FileNotFoundError(f'{source} is not within the media directory')
|
||||
if not str(destination).startswith(str(media_directory)):
|
||||
raise FileNotFoundError(f'{destination} is not within the media directory')
|
||||
|
||||
destination_existed = destination.is_dir()
|
||||
|
||||
# [ (pathlib.Path('source'), pathlib.Path('destination')), ... ]
|
||||
plan = list()
|
||||
unique_paths = set()
|
||||
# Get all files that need to be moved. Check for destination conflicts.
|
||||
for source in sources:
|
||||
if source.is_dir():
|
||||
# Recursively move the directory's files.
|
||||
for path in walk(source):
|
||||
new_file = destination / path.relative_to(media_directory)
|
||||
if new_file.exists():
|
||||
raise FileConflict(f'Cannot move {path} to {new_file} because it already exists')
|
||||
if path not in unique_paths:
|
||||
plan.append((path, new_file))
|
||||
unique_paths.add(path)
|
||||
elif source.is_file():
|
||||
# Move all files associated with the source file.
|
||||
for file in glob_shared_stem(source):
|
||||
new_file = destination / file.name
|
||||
if new_file.exists():
|
||||
raise FileConflict(f'Cannot move {file} to {new_file} because it already exists')
|
||||
if file not in unique_paths:
|
||||
plan.append((file, new_file))
|
||||
unique_paths.add(file)
|
||||
|
||||
logger.info(f'Executing move plan with {len(plan)} steps.')
|
||||
|
||||
# Track the steps performed so they can be reversed.
|
||||
revert_plan = list()
|
||||
|
||||
def do_plan(plan_):
|
||||
# Move deepest files first.
|
||||
plan_ = sorted(plan_, key=lambda i: (len(i[0].parents), i[0].name), reverse=True)
|
||||
for old_file, new_file in plan_: # noqa
|
||||
new_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
logger.debug(f'Moving {old_file} to {new_file}')
|
||||
# if not new_file.exists():
|
||||
shutil.move(str(old_file), str(new_file))
|
||||
logger.debug(f'Moved {old_file} to {new_file}')
|
||||
revert_plan.append((new_file, old_file))
|
||||
# Move is complete.
|
||||
return plan_
|
||||
|
||||
try:
|
||||
plan = do_plan(plan)
|
||||
for source in sources:
|
||||
if source.is_dir():
|
||||
remove_directory(source)
|
||||
logger.info(f'Move execution completed')
|
||||
return plan
|
||||
except Exception as e:
|
||||
logger.error(f'Move failed', exc_info=e)
|
||||
# Move files back.
|
||||
do_plan(revert_plan)
|
||||
# Delete the directories that were created.
|
||||
directories = sorted(walk(destination), key=lambda i: len(i.parents), reverse=True)
|
||||
for path in directories:
|
||||
if path.is_dir():
|
||||
remove_directory(path)
|
||||
# Remove destination only if it did not exist at the start.
|
||||
if destination.is_dir() and destination_existed is False:
|
||||
remove_directory(destination)
|
||||
raise
|
||||
|
||||
|
||||
def remove_directory(directory: pathlib.Path):
|
||||
"""Remove a directory (only if it is empty) remove it's Directory record."""
|
||||
if directory.is_dir():
|
||||
directory.rmdir()
|
||||
with get_db_curs(commit=True) as curs:
|
||||
stmt = 'DELETE FROM directory WHERE path=%s'
|
||||
curs.execute(stmt, (str(directory),))
|
||||
|
||||
|
||||
async def move(destination: pathlib.Path, *sources: pathlib.Path) -> List[Tuple[pathlib.Path, pathlib.Path]]:
|
||||
"""Moves a file or directory (recursively), preserving applied Tags.
|
||||
|
||||
If the move fails at any point, the files will be returned to their previous locations.
|
||||
|
||||
Returns a list of tuples, the first Path in the tuple is the file's old location, the second Path is where the file
|
||||
was moved.
|
||||
"""
|
||||
# Move the files first, if this fails it will revert itself and raise an error.
|
||||
plan = _move(destination, *sources)
|
||||
|
||||
# Files were moved, migrate any Tags.
|
||||
plan_map = {old: new for old, new in plan}
|
||||
with get_db_session(commit=True) as session:
|
||||
old_file_paths = list(plan_map.keys())
|
||||
old_files: List[Tuple[FileGroup, TagFile]] = session.query(FileGroup, TagFile) \
|
||||
.filter(FileGroup.primary_path.in_(old_file_paths)) \
|
||||
.join(TagFile, TagFile.file_group_id == FileGroup.id).all()
|
||||
logger.info(f'Moving {len(old_files)} tagged files')
|
||||
for old_file, _ in old_files:
|
||||
try:
|
||||
new_file_path = plan_map[old_file.primary_path]
|
||||
logger.info(f'Moving tagged file {old_file.primary_path} to {new_file_path}')
|
||||
old_file.move(new_file_path, move_files=False)
|
||||
except KeyError as e:
|
||||
# Got a duplicate?
|
||||
logger.error('Error during tag migration', exc_info=e)
|
||||
session.commit()
|
||||
|
||||
if PYTEST:
|
||||
await refresh_files([destination, *sources])
|
||||
else:
|
||||
background_task(refresh_files([destination, *sources]))
|
||||
|
||||
return plan
|
||||
|
||||
|
||||
async def rename_file(path: pathlib.Path, new_name: str) -> pathlib.Path:
|
||||
"""Rename a file (and it's associated files). Preserve any tags."""
|
||||
new_path = path.with_name(new_name)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f'Cannot find {path} to rename')
|
||||
if new_path.exists():
|
||||
raise FileConflict(f'Cannot rename {path} because {new_path} already exists')
|
||||
|
||||
with get_db_session(commit=True) as session:
|
||||
fg = session.query(FileGroup).filter(FileGroup.primary_path == path).one_or_none()
|
||||
if not fg:
|
||||
# File wasn't yet in the DB.
|
||||
fg = FileGroup.from_paths(session, path)
|
||||
fg.move(new_path)
|
||||
|
||||
return new_path
|
||||
|
||||
|
||||
async def rename_directory(directory: pathlib.Path, new_name: str) -> pathlib.Path:
|
||||
"""Rename a directory. This is done by moving all files into the new directory, and removing the old directory."""
|
||||
new_directory = directory.with_name(new_name)
|
||||
if new_directory.exists():
|
||||
raise FileConflict(f'Cannot rename {directory} to {new_directory} because it already exists.')
|
||||
|
||||
# Move all paths into the new directory.
|
||||
paths = list(directory.iterdir())
|
||||
await move(new_directory, *paths)
|
||||
# Remove the old directory.
|
||||
remove_directory(directory)
|
||||
|
||||
return new_directory
|
||||
|
||||
|
||||
async def rename(path: pathlib.Path, new_name: str) -> pathlib.Path:
|
||||
"""Rename a directory or file. Preserve any tags."""
|
||||
if path.is_dir():
|
||||
return await rename_directory(path, new_name)
|
||||
|
||||
return await rename_file(path, new_name)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import copy
|
||||
import pathlib
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
from typing import List, Type, Optional
|
||||
|
||||
|
|
@ -289,6 +291,28 @@ class FileGroup(ModelHelper, Base):
|
|||
|
||||
self.files = collected_files
|
||||
|
||||
def move(self, new_primary_path: pathlib.Path, move_files: bool = True):
|
||||
"""Move all files in this group to a new location."""
|
||||
from wrolpi.files.lib import split_path_stem_and_suffix
|
||||
if move_files and new_primary_path.exists():
|
||||
raise FileExistsError(f'Cannot move {self} to {new_primary_path} because it already exists.')
|
||||
|
||||
new_name, _ = split_path_stem_and_suffix(new_primary_path, full=True)
|
||||
# Need a deepcopy because changes to self.files are ignored otherwise.
|
||||
new_files = copy.deepcopy(self.files)
|
||||
for idx, file in enumerate(new_files):
|
||||
_, suffix = split_path_stem_and_suffix(file['path'])
|
||||
new_path = pathlib.Path(f'{new_name}{suffix}')
|
||||
if move_files:
|
||||
shutil.move(file['path'], new_path)
|
||||
new_files[idx]['path'] = new_path
|
||||
self.files = new_files
|
||||
self.primary_path = new_primary_path
|
||||
# Need to re-index for self.data.
|
||||
self.indexed = False
|
||||
# Flush the changes to the FileGroup.
|
||||
Session.object_session(self).flush([self, ])
|
||||
|
||||
|
||||
class Directory(ModelHelper, Base):
|
||||
"""A representation of a file directory in the media directory."""
|
||||
|
|
|
|||