From 6f0fc6cb60f4139704c50e5381f7905618dc12e2 Mon Sep 17 00:00:00 2001 From: Michael Sitz Date: Thu, 20 Aug 2026 13:47:51 -0400 Subject: [PATCH] Add library browser feature --- .dockerignore | 38 + Dockerfile | 24 + LICENSE | 21 + README.md | 129 +++ docker-compose.yml | 22 + docker-compose.yml.orig | 22 + images/lesson-0-8-2025-08-04-04_58_17.png | Bin 0 -> 98229 bytes offlineu_core.py | 1038 +++++++++++++++++++++ requirements.txt | 7 + templates/course_dashboard.html | 632 +++++++++++++ templates/lesson_view.html | 365 ++++++++ templates/select_course.html | 153 +++ 12 files changed, 2451 insertions(+) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 docker-compose.yml create mode 100644 docker-compose.yml.orig create mode 100644 images/lesson-0-8-2025-08-04-04_58_17.png create mode 100644 offlineu_core.py create mode 100644 requirements.txt create mode 100644 templates/course_dashboard.html create mode 100644 templates/lesson_view.html create mode 100644 templates/select_course.html diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..14e031a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,38 @@ +# Version control +.git +.gitignore +.github + +# Ignore Docker-related files (not needed in the image) +Dockerfile +.dockerignore + +# Ignore cache and temporary files. Logs for future use. +**/__pycache__/ +*.pyc +*.pyo +*.pyd +*.log + +# Ignore virtual environments (if using venv) +**/python_env/ +**/.venv/ +**/venv/ +**/env/ +**/ENV/ + +# OfflineU specific +.offlineu_progress.json +data/ +courses/ + +# Auto-generated templates (created at runtime if missing) +# Leave commented if you modify templates +# templates/ + +# Harmless, but reduce image size. In the future, it may make more sense to put +# everything needed by the image in e.g., /app and modify the Dockerfile to copy from +# /app as opposed to blacklisting files in the repo one-by-one +LICENSE +README.md +images/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..cb70a92 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,24 @@ +# set base image (host OS) +FROM python:3.13.5-slim-bookworm + +# set the working directory in the container +WORKDIR /app + +# copy the dependencies file to the working directory +COPY requirements.txt . + +# install dependencies +RUN pip install --upgrade pip +RUN pip install -r requirements.txt + +# copy the content of the local src directory to the working directory +COPY . . + +EXPOSE 5000 + +# add healthcheck using Python standard library +HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:5000/health').read()" + +# command to run on container start +CMD [ "python", "/app/offlineu_core.py" ] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fd5ebe7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 WhiskeyCoder + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..f12543c --- /dev/null +++ b/README.md @@ -0,0 +1,129 @@ +# OfflineU: Self-Hosted Local Course Loader & Progress Tracker + +**OfflineU** is a sleek, self-hosted web app designed to load and view your offline video, audio, text, and quiz-based training courses. Whether it's Udemy downloads, "open sourced" training archives, or personal content, OfflineU turns your course folder into a fully navigable dashboard with automatic progress tracking. + +--- + +## ✨ Features + +* 📁 **Dynamic folder parsing**: Scans and maps your course structure into a browsable tree view. +* 🎥 **Video & Audio player**: Integrated media player with resume & completion tracking. +* 📄 **Text & HTML viewer**: Supports .txt, .md, .html, .pdf, and more. +* ✅ **Lesson progress tracking**: Auto-saves your time spent and marks lessons as completed. +* ♻️ **Continue where you left off**: Resume instantly from your last-accessed lesson. +* 💾 **Local-first & private**: 100% offline. No cloud, no tracking, no nonsense. +* 🧑‍💻 **Works with any course format**: No metadata required, just structured folders. +* 🧠 **Ideal for hoarders, students, or offline learning setups** + +--- + +## 🗈️ Screenshots + +> ![image](https://github.com/WhiskeyCoder/OfflineU/blob/main/images/lesson-0-8-2025-08-04-04_58_17.png) + +--- + +## 🛠️ Installation + +### 🔁 Quick Start (Local) + +1. Clone the repo: + + ```bash + git clone https://github.com/WhiskeyCoder/OfflineU.git + cd OfflineU + ``` + +2. Install Python dependencies: + + ```bash + pip install flask + ``` + +3. Run the app: + + ```bash + python offlineu_core.py --create-templates + ``` + +4. Open your browser: + + ``` + http://127.0.0.1:5000 + ``` + +--- + +## 📂 Folder Structure Example + +```bash +MyCourse/ +├── Section 1/ +│ ├── 01 - Intro.mp4 +│ ├── 02 - Setup Guide.pdf +│ └── 03 - Quiz.html +├── Section 2/ +│ ├── 04 - Advanced Tips.mp4 +│ └── resources/ +│ └── extras.md +└── .offlineu_progress.json ← created automatically +``` + +> 🌟 File types are detected automatically — videos, audio, quizzes, and docs. + +--- + +## 📁 Supported File Types + +| Type | Extensions | +| --------- | ----------------------------------------------------------- | +| Videos | `.mp4`, `.mkv`, `.webm`, `.mov`, `.avi`, etc. | +| Audio | `.mp3`, `.wav`, `.aac`, etc. | +| Docs | `.txt`, `.md`, `.html`, `.pdf`, `.docx` | +| Subtitles | `.srt`, `.vtt` | +| Quizzes | Detected if file name contains `quiz`, `exam`, `test`, etc. | + +--- + +## ⚙️ CLI Options + +| Option | Description | +| -------------------- | ------------------------------- | +| `--host` | Set host (default: `127.0.0.1`) | +| `--port` | Set port (default: `5000`) | +| `--debug` | Enable Flask debug mode | +| `--create-templates` | Generate default HTML templates | +| `` | Load course directly at startup | + +--- + +## 🧠 Roadmap + +* [x] Base function and testing +* [ ] Multi-user profile support +* [ ] Dark/light theme switcher +* [ ] Built-in quiz interactivity +* [ ] Import/export course metadata +* [ ] Mobile app wrapper +* [ ] Self hosted Docker Deployment + +--- + +## 💬 Community + +Join the development, suggest features, or ask questions via: + +* GitHub Issues: [https://github.com/WhiskeyCoder/OfflineU/issues](https://github.com/WhiskeyCoder/OfflineU/issues) + +--- + +## 🛡️ License + +MIT License — Use freely, modify locally, share widely. + +--- + +## ✨ Author + +Built with ❤️ by [@WhiskeyCoder](https://github.com/WhiskeyCoder) +Inspired by the dream of **learning freely, offline, and without limits.** diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..68fb329 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,22 @@ +version: '3.8' + +services: + offlineu: + build: . + container_name: offlineu-app + ports: + - "5000:5000" + environment: + - FLASK_ENV=production + volumes: + # Course library — grouped/browsable via the new Library view + - /volume1/files/training:/app/courses + # Progress data persistence + - ./data:/app/data + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:5000/"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s diff --git a/docker-compose.yml.orig b/docker-compose.yml.orig new file mode 100644 index 0000000..807488d --- /dev/null +++ b/docker-compose.yml.orig @@ -0,0 +1,22 @@ +version: '3.8' + +services: + offlineu: + image: ghcr.io/skippysteve/offlineu:main + container_name: offlineu-app + ports: + - "5000:5000" + environment: + - FLASK_ENV=production + volumes: + # Mount a local directory for course data persistence + - ./courses:/app/courses + # Mount a local directory for user data/progress + - ./data:/app/data + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:5000/"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s \ No newline at end of file diff --git a/images/lesson-0-8-2025-08-04-04_58_17.png b/images/lesson-0-8-2025-08-04-04_58_17.png new file mode 100644 index 0000000000000000000000000000000000000000..5370c9d3fb4e0f6ec06ca2253ac0bb0f5979c305 GIT binary patch literal 98229 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 z zIx?kEt5N*it#Ge_`)3Ja7aT;dcD4npLl{%1g)|x7;mQ-HJKCTp#@{*k`E9A^CdfPg z&IA(2Kg@%9%w2PN(=-H-puUMA3x^x*dHtPV26+|fFJ~t zvNk?4G6J%P!D2MTD$^>VIU`;})~}cK$Hix{3^;9RK(V&6s0n#B-6CpNOvXXa-yo4s zNWjqFqe@KMwlUFrL|-B>yzO1ZwQT(ub;prz3Rk%)c{B<#G;fq{Is6GqC}_J02Nu7A z5{A4U-2QC_iM6Q^6pm}l4e?$M#qM3~S=T;YD>3crdRduh>MSqPqa0;FlgIqnGS`9} zld?J@98^W_g`WkeU382AjX=!|Bm!}51yz_Qu%Qxn84tqW(_+$3yT5sJxn4bIUO{}f zhgJm@E!8vhN~t8>-?gR9G(7iGmn|GA`oo93t&j6`gz0^bRsr<{kt~Hgg+e;LL6tzV z4R#~W_wgC}3h zz6XuuB{2#fP^s<86)Uk9ZW(*zRJ|FGcRdw;A>S{~B3jP>)`fbv*b8M9xh~7y9SS4E z!!iIj*XnGvEng}u%*Ch(E~n`q384#DzP<*4ao53tu7#=%n@O8mCv~jFn6Ln?6>b}z zCVlniD?(30UZA~i*z3_xup$`DBmO5OoX~TSqT>h?7-ta@bme#4RwJKL!D-!O+0Z^1 z8A-{D*sE)A--FWJjpE|sT-L!z)6wf$dMPO`V_m1L+Fh0c^GIm|+}z8YpzR-J;qBIM zDp8J(Qry_i&!~nJsyqVFjMMu7XWy`Cg-d<{F+<^!TL)AIM_5u#oH#;Y6fnCGjgC%Y zB@jL(r_cZM z1#NpeX6|^Lvlcod-sKXyMVGvP$}5fFK?EY(1v!&mQqCvg9Hu{aEL7cQw!yS%WU3e6 zc1LMtoGJ+J6T8HPR+s!Nen#xEN44Ofv(|QMc51kFX{Hyk=M0>N)DyWD9`z1IkE9C)` zO7-$cJz{XDy3D5or918(K{=BIq}+;G zM!&rYl=s1MP1f|h9D%A;*AnQX;2d2Hz?I`(40e8%8-839=NEU~*jF{5RG3XFZ=x3u zeE6DR0s+!$=?JHfMijhiR#!MYT;)dI7~;gm%>aJs5%g7MuZ|kA zM-4PKaONqcLn+Lhv>qL}2O`wcHzP}HUyNGJPFT!VDSx)sSFFIeaPVhM-XE#PWfRb` z_k2}-e>2D`PUY35wAWFUWhHENWc%_05vL=0`17W?&dlr9fIq;$Rm*K)xVgFSpOYc! z*(#_}P!!l)W6fTyw_NP%!8)7EIM*`Q>(x|THI&Uno1mU|(Ke9{F+gN!uDtp#A`SxFxm6RCw%w+jD+R@fN>$i{SBa;Z5j z6cxp0maQ=$%qACQvl2d~QxTAw5Hih-}$55Z3p=;NUbB5wgWPo zLl$=zOYD`OxzDb(?ECYKH}7{IP3vA+Rr7z?R~{B5u2MD#d{}AR_boT&Jd&LC%|M$D~V~fHZE~)6D`fJ z4{x$wKBt*|@Ah|bmh)#$e|&J8^>V~0_eEWeulE(NU(oH;dTq-hTzx5gq>Iwi*DHDL z(ph~5aUrXe^-sO&XoyDyY*DYtrMa8=g6{hScNH$ZcIUz~&MKJK;gfTpE%;Mn!{@#} z6#V`+JZ$6sZ7y1eK(>}#;_C?KulH1#z20h1I44-BFuy%x7Ii;QHB-NJF5!{fb*GeR zURyp^Cuz-9_vBQf3YW{hFCv7lsSLc=%`aDWqQv82ryd%Dm?AW;R^0z;t;ot4$^7|| zEW4hTX4HKV_KRAYM_Pi(vD%5B@5^!t&$_ydif6pe`<7r4YnY3co}5eAek#bx)b{Cx zwc<6`SlyP5`yw|kl%HmMdxqungF9$9#jBT7*4P-UJ3hH&lC7gN^dy)iRX1NfSTOJF zdPC{DM?fCpo2k>Tyb&rwxUUn|+x4N%Sq`mQX7;rb$@i6S2%G4STco3q>h1hXPX$xouvO^!a5x1D zn1^3SL)6%Mgg-4k72#pTk)@;mK);EshncKp5l$Vv#-Le8{3N;5ySqB@VO$WHUM_sAMJH-x1EF+~Mu8j_a;nEP&>K^jw z>*C$&fQ~A-uleL+{`xn=@ueTWgOk*}RA@fC8~w^-hvw10aJc2CQuC^1s%X zFzK1sv(T8djLdX2R~9xClZDQt-{~dTOq;X}d-@Mq8T3LMOqLxcDg6 z0xSBT93^l7P8F}%W2_alT)Tfex!E{1sC+!p?xGE~%XjOPHR&}r=}9`9nTbVX1YlYt zuuy271o)~>g?)_gJWx{#{n;9m1x^}!fLts-d3~<+= z$u>QUZ*U$X3RNRgf;$!a67xg0kmLOWofbAqpq2GcHAj2&ei-;}G-D@=pKFbK*ioxe zO?ycIcLICy4IiLxc`4P>Yt}Q{O?DG>|Adb1@C7(=lA2F`yFEo2n4)}B5!k$5-q(9- zGpVUTvEF&|ly9oG&#Wa{dVGaEMNQ#wtLihMiv#7&GAUiM&G5XGRA_2-4*&pG$ii*K zRDVS6NF|_bFw^pPiE_H_FL*equC}3iEaW{Y>7KWjw!3pR{1>rieBniwK7aRsAQPQT zi<7J6A5CC7P0a{B^qA5WRE8T$X5rVbTXuX^d9pXO7dl~hG^+M!0v=85rT9At?BB*o^|&$NZ3$@B|ZK~OQBamsX2j?0~3CZ4pa zssy$N!w!E>>XbZIHN5Shn*zcL=-$mJCwH#RgsHq<)yYkud~eCtr_4rP9xB1leLx-j6C zt8@R85k&@Aar;iolxwhO#Zyv}x;H|HJYli>=` zLNc1Hmsff%|Fxkjfl+e6dlg7DpI=LIap^2GlugCEAk0|+Ni6iT zH$=lv2^XML7lc>8?3~ISRc+Z6@P4$Lf8zPb?}vBPWK5M@*ka312L+@-2qDbqQTVQh ztHXnL%w%OLDGD4yw@p8Fkm~h@q(f}dz&F>=C)d~gT8%G(IyBCoRk>3ucefG#_|25- zl9a6PWYz7K)|vTFlrw<7#8v6|wsA5p9E}Sf2X3A;2t63-S<4i*>jgxNy-~AXGOWuvL z(P}@1TD7j*%d7@OhRg?UGiXt}w!nom z)RbDV@rlzY1$`gZ53}*;#~0i#h@w=B{keTHDl#%voNJ$B(S`G=HIz#2c9AW#5EB(Z z>EmS4hB{5v2^V*{46M3F^sO`a?f}q+fgI~c_fWnmsSiZxilzq6!aeyS!3Jk34ipQ= z1e1+BEiZCzzF;pjlQQpey@<9DO1;V^i!&@TyARY4*TMCRxX=yn`JkmCeJcEEp@uPg z&{_X-&JSjAFQi;&YnL3LLHsno(CJTgwAEh>KF0DfD+7RgLD*R-!*$VY`3C0JSF z^uulQhp#d1)fWeqE|YW`+uxZl4P7MYmspBMEx6^&6;XWi&AXXwaHgev0W$90z7O-e zWqkqkw2B0?(v%eoS!kxvxWmmrQo53NWSx)N&L{PHEX8wqn1N}Rr}J{R4VvS|Zd-vR znTZQ$%g=h@DL(oI+ zDIIE(+m(I%Bioe`n&T+ga{>%raGU^4Kj`qlm%^O9 z5Ct#~ytn7gw_TalG`n&4>Fh@}Gv{1(SMg6*hs;|M2kj!=M-hB?*Cm+u*%u1Yo<@0~XBNQ+v6ZeZ-pCtQM<%7M8cy_qbE3wt>kPxw;nf+)N0gWBvUDQ>}1> zr-!GPa6)_2hp{CFs`TB|!Emqrl!~~Fo7~7ark-5;v{61J@%cd;UPm^_pA4!V9zW** zj*hc>cK86FR95kA;i^>l$F3GHk@}`pv9e+?!t`5Lm+Y*Nf;(TLqzx@hr#-(MSN}>pzoupb|L&9=Su3CAIuI+4e1*O#QS-7t&!ERj^lK)iZJzI20ks?;Rx8n4@;(bpTbA9B8JMR( zVP)}WrWZeUOMG$wN0{-$Rz4)w`|M+<+a=*tgcmY%b+t>aO%HaQT-#MDE{ z$;7x7so82M^hZ&9$@{sagy~e;Nj|9Yp@wY5JWCQh3<_7$?Sg{8+I`U_pQDX z43Nf2+>ymirrwFGgdrmqPDr`3fj?})Y?iv1W(GyzE{A(8zpZAW;+nO2$DNe!(Gq)y zghyLOMsWnfRQ+Uy{o1sbaFtw@94a*3pqXLxK-gsy%W7lbytKXIR)Ijcoa5S`Yp!ti zT{xGn#T6vD>x~{>$57PT2lNTt=>vb17n3D_Tx*zYQY?8s`*#;XccgGC;G6@YY0#nk ztX@@Y6zobWe@WwEqpxd~xb8Co$lMuB?MBUAFsQZ_|9;Q^#j|dvnJkJ_#sJk1d4-JW zsYgkpC25|nZ%mw*Uys{1cSrodFfE|&7Bp;kMXw`1?Clc9dl{Z9g&|Yqapb)edyU-I zG%F5rnNM+izu38}#Lqpw7(u3$x*~8bT-S5R{IzM4RI6$*R75ewDpqDPgc|m zflaBDwTf?h+!hd5@q-lYq6TIS|92$0+0)~9OQ&rohV|?l7dU=)iN~Wtn%@QQ2c)io z#Exkau1ezjnU?`sIy#RWw)?tUR%V;UeWAC$JeV?~{idQ(PM7JYF8bboyZdH%iQV4; zVX!&vHAnhes6=uI1yE*WD@t@c*|mJwT{Sqd6hJ0YBjRY6UKxe2FUXVXkjQp~Z>qcr z=URnhJ;HZeYPYRdfH znU-yxVN&SVeJ(u3%`Ab1k`RZRb_+Otxz~F`bsoHhx4Wr0>DPV1@s0NxK2vOuQO`QC zZ{*Byy|%>`vZ_vHU2QSQ%`bd=lI9iHL%B*@IOjA{@GYAh$?$idAMrj`AI@>v5&_-R z@+?2_-112s$_uw*(YD}#L;pq9MB+CLi&)u4i^YQ_%s|v1pu4rNq^xO)lmIJ*Igq^h zh{QpAK=b6mC>&fmTV~gMu22WGiB}o)JGEBV*5`FQ$vYJi-gCQ3Mht|W?sOA}^+2E1 zn&^Kd(##}`k`cr}pXeaNHpMAdxixu39NkRa*(DQ=NA>OK=afuOR#nA}bC#|(LNWk@ zqQyiTB`Q$`;@`U(`3rRF+>5Qb?U};s?Y}R9W&8~>T-;d9J4@3hj?Jqb!;9`)9yTEr z`ax==cc*kxe&1-B>5KzCF2&;9|pinAlKtC2VTi9P#B`YpHHEJcJ*t~@Rw<~vndVw z-Oi+nANo~Jd_cqmvzY8@itnM)uOj5l?QjcEfbb`M=@V5lBhW>Rin!|iC19;=(nTwO zpK^l+S!|GYnu&iIksb1T9k0==IoP=u`f#yK{x&@$*8VTl%rnvHqv~2{^{vVS(B+!Z^@{nh zH*EF3vuy$3JKF^oe^c9Pn~~d)SD#5yDVXTPtfurwKcT-ZYPv}070f}~9F{^a#-B*S ziQbewo3XO%`hdy)*Gy;+j;l@Q4#OHl!vgipTB}$?UAWQI z%D@OB*FXCm@=?OqV;p3K{L#8cdMH}DgbAN~`2NW(N#OKimD%H0bd$P@Gj|2l9;>Xl z{sL%VR&If+7M=BCqV!FxhVdvUR*By}hd~@D)i90kxHbQp#5d?74l_dY!Azq8IH$KV z>8B%GOp9|@j@{^KhD84*8`B+qm6gBU;GSZ)Ilj9p_A>=5j;}c8mR@hd&6iv(Ft_3Pu?E8#TY^9sz8An_>HVU`93&MQ~l@n zrFiOc$Y>wD2j5j%iCUsnugTjUM{Iiih>6n63;Lw**D&e*DK2o%ZYN8j5A^$esY8cZ zrjXN-Q+dsQ3e zo9mZ#rP$aQ(HhxhN6&;WF3kMzT+a(&U0tyo35@r%r~P2-0Uv)SDEu}Wg90Uwn@vF= z|HG}}QmeM-7H__;OK?lw2==i;Fn9+m$oCRSEoL)2l6J>4Zin9Kevsq!FyI`rI&12` zTK4;(e^}+`k|kZJmDVHZjy09%rt=Zz&oiZG#QVlHx&@s<1hc7$Uw3;Vu7_)6KUlkR z5#74(=O2FvrrJ3UHGr&KM-NP=(^bn1x!z8JZa|qw-n3@ zebyIQFVdlT$s)O+tXgy}`yY|y6ZaF`{!#uwEgmOF1mt$`0ZW#GJU}^i`;*&OpXHo7apLL8 zU(3VA3z5B@fbaZ$H;3v}V1ci5498dACQ*;Uq^iX~&h3XDuAV$`!v2!KzrVb^ylUXD zr|;zj`rgH;ggt<58baKC(BZa-k)U_@e1ci_13J)#YpfUBk|MwW+9m#Z&R2$=L}4F; z+93_8!6QYk6DK~zs0T+Dv~>br$GiO@3+y{_;zYmMU#KRy(f@&mtAw3LGVswZojCE1 zK!1j>@1|BJ9feA^zW{auPMmmNMc+Dnr4sfBl7U_w^cOJxKVeN=L^52ZUDBB6JaOV4 zgkG2a;A@rBCr-GF|G(0RtWqC^Qs)<jjv@ZbTU#sgbmAHui0IZ*K0$xFxX}$!K7M|~N9z_HGj{cu>aYlz zl@XHfVu>u;_TU9?XHSpBYGgyUPXK+Z8!P8uhBnS7yP9E7hGkf+^^ErQOmD@7hPYC{ zC7nF+oP+(ZUKU;i8+$s2w2k_?I*gz~ki>7r+*UBNUWc9%}`TzI{iQ$RZ0s>O$FGlI}zokF2V&ifEc?0{DpW3DbXH`r1X#+z90DkMvqL) zuN*xx#Ue`1x%nI?cJt2U{>XkoAEQtdC$boN%SS~<38 zc|~42O-+*egOa?dRx(%T4}kR&mtK@xiLaEbS_40p%tj1y(#PI8(B&Ufg}&=Ynmjx_ zvyZOd?QAlsMnHWSyR)6!@4Vr?JM;M9^+{D6G=l)&gOxVq%4gY#3;`^rgVN}Hv}ZZ*_ZFkR`fcKw`H zNxD(JWr#NdpRH2d=#TjL$K*5rwIOogL^LH4^L}Sq_1qR#cJq*O(6tw_*Nz3I8n5{* z&sMvcVB>i@Vv4=x<>Y208m`H7|7e*nnkWvk<57+a_O%-S5n;ZrYs3^QiElzDBqu*6 z;Y*ri{eHiZbQ|;@nZ=9V&rm=xYHOY3vzL<{=4D~p4Tz@0FY=Zj%CNrU_u-ufm5WddCy4jrRR;zu}H*EyD}1dyCy` z0ue!FL7h=U?=i~*r(un5rOLLU#O3eHKaU1;WV^V0`Uu;XF6@5s3H3N=(z}dN71bVI z7>5YBy+SD3nA(LvkEd=NYTm84wMHH~#kY@3ujbGo3Vl3JCwqFfda_R^|K^<$QBYV1 z!e`D!6^?r>c~~(r`po@4W0<)&a|^sH8kBcAi9YXS;+6V*^kLC7*}1-aHg+}#71r_D zcHwVx27ahff4u*&#C}adm6!LcjuaM7CRFXDfTrc(?02`>JKu9!BPJ5xS{Ytf8#w-g zdx;qkkI7S7Qw$)`_7>a?3c>vYv9yW#M$^jz`#jzF0x|M<)rZGBKg+RK+(t%N*Q~#( zcvk*?KOS>y*PC2gtmOMBiMVn=R6>n+us0mHg=2@$22pkE?-+4?cJNS7KY8M3!+)(( zftf-aGc^;*ZWnerFYGRDjLNbwa*3~VhCXusLE2=D+*%o`HP62~2y#UYxn@m%VQ*}% ztXkgu?Q9>kwLTrxV4L()Lyl{#%zEG8RZon1f)3E}7`B~g|n;I5m% zoE-11l+-+L?CtsCZ10lp;v%K$fTiN&w7t(uFN=yD46n@)>!-_p++r3lD(a4w4d}Ui z15teFC{tUB^*MMsG*H)I4%@TwtKw9BydI9Ps2HHVZ(s^FzoWpT9F*o(>q;8uFiuy? zvb9O8M1byYp461L6D^Q}HL)6383$k;0NO0ONEA=D# zOrn>d*BA`k>KgvFvw{k*E{a)~2Zi3fHPQc>h*{xCy>m&PS6#x{eZxe&(!Bb0^6l`5CGRAA&I6k(jK2c5e(#7hgv)|Pp7*arH6$J zxTVQkZ@mPR=j_-4ScksB;z;{iPS;kyXP0@^goCqzGAO86UPh zdrRO}W9>J?&=BNW$<~bL6<%Imhg7}O_@M3UGGMkFB+2@QBhP`RvsrHP7HkdN6SHO; z?QaHa{f0SV>B7y=_?oFY6N!bY)9l_-_!5)gcsB*J%k>a4MIv(5sIyeO&+dkbVd0aX zSy$((zi&?L6%9sK6#yrs=Wv*be3L9bmh{tMv&K?&VAMtw+&lbpe%_fAC;G1cOVWaB zs9S!Xu2^9*g5gDK?7)xe%Rk-AtZfxS4b9*QV$#yZ_!5Fd*TlxQL}Zhl^6wk0l`An7 zU6^GWt!tu)veUxn%rL>kwY54I<=~IFlCitCyS#^GTfo&W)dHEA@&mL? z7%}F7``tUg*)Pc?t?P-^TB6uLTP<Q>8CRSc(%w!!`Dx*yjeKmXuBS(ilKfiw z=BEnA${}E@rBJ|u7r2n|4H8fiT#+}payaL2S3+n8+gm(c8l$rVPFuT;jf9Gt95svw zs%pNs^~`?ie7Kf_18%XgEiAABM+dJwE1SFT>93~^4{6`{(xh6~>C)3TrC2>bk{UJ1 z{(E0{@i9?hwxB-4Z|~8gA&i02E}LuH*)}1Z(xOV<<)Fo<`bcuz`)|=jiIoA1Z#k*g z{KX`z^Gb>u-q%&sd?5mxLVif9%JY`sOG*pHx;ks^!K3u7FaC5n2(TLmRJzrOm#FWg z=$^sXC)(RsS($<@u^YSfhq@*cPvmpnS2UEB6;!V7>1MBC@2XgWKsFY(_LiZ3>yt^z zAgD^i)f3Mb>FUv+N=(wGgaR%%`@r6gtnhZNd*YZ9V<*uIs2%zgn%sW(w#Wl<5s|TO zq-`}~y3%A<3b-;6RMw?%TTxL&P~_pkBdpYPt{D*#68UE9%B2gJFK2#TQXXr-KKWvu z#i{<{WN3-E@g~z{5PiP&?!15e<&UjbpH*Gi&E<|h0#qW!CHJocoIiiz@iXV-3YgnU zlYiz(K6UjMA3uIvGuJ`?zVUsPhxI#PyrdfGU9WKXQgdjK2RDF-&i39rclI2^8FyQ| zgO@K~o(g;UGPoTgPPuU%k*HTM8npd+M7X*&2=g zQMfunGFbfRe2al0((~?`{BV`86~K2hv+6W3^dp~oQ&PHx+DUc$LEY^i({>BoDlOfv z@3!@>xlSvK>%Ka_FRx+ToT$3$SXf1{UE6QBOf4L=Q2idSW<-En<9cAVDI65 zB`PE}I!p2VlcmNpuc)aa=f6dn^xmx_cCZ{agte9ie38csYV<%yW;m;EP>rMR)PvrpIg{g@!dtmO{m zap==^=ntp=C0EfET6enk{ip8!|GTO^Pe(>taHH`(`^~?Ws(BS+Rn^)Bx&q9_PC({!W6m$QR8x^LA&_QuA>R7|t~l3Io)REXu}OT)!h0d$K4hxKezNYL-K zXQSZ-=%AQ4G#lv0XSHD^4NdCj(VMCv5qUeU6e^^fl+L<4<{zF&n$P=dNYFeOtO= zgjMU=5mqsN?(lA7cV~}l_LoO5y^@cNn%kmi`{MjJui(&`nRbPi*}fgy>FL-$%^!$h z7U2fRVDmgfIkhX&CUb*N1G%$yxQlz+@4hKLzBFfEY}vlJ`pqR+>Mg);;>q0R1`Vk8 z?6`w6K2F}rGtBf1K6!MqUBQEPW{!R!l5=*fV`F1erm3)(-tg`Ak2~pkMlxy{cW-`E zs;%M5+R5_FFv^tsypv~)b_xDgf79gjKYR6(VRc6*pmFer49#%;Nl>iH;GO9k0)4-X z|Dza2=aavP!kLD6o8T6hKj}P=o>|(yhRh47Q`N~_%J`4s5xQZ@Q0q^V6yWy%5#)bs z2_N1GNPGRKsSEn>{|NHGwFG8NaJzl=PZJvD!~cN%zqLeU|KB4;*aR(%>9TYU&i?4w zzPRue8m#_8yOHXNueqJ-5+x?=dS?N+&EAh;jbdNX5VWY z#Cf;J-kjU~MU!nROv>kWc=Yhy27T24G`+j`Y>x3V6Mdw5^U}$^EvF{!ePxa@CRSEW z!OCim8yJ{Ne~?kW>0SRHbBs(^6x5D6)!Xy^&AGE;Vv09UvbmN^+;t9nj{vAL%~NHyrmruIedFW$iu*79|oKX1=5 zUSyJ2n_gw@8SR+ep{l(}t*+nLB34>NuScXs|HXN#L=|%pwiNmY^xMtV6=$)0oN_;t z6Z0Rm|8Io)bEJ%!_W?HS;`3kp7bvVs64}Loy@aACdA~L>!$;_jq}pQZ*FjB&E9I4y ztXat_0G0oH1Lu3HRliZT4_iKMPv98)rEy4+;q?-m6bMSe}b)zNX|;sgeCh zMw$vh?*9=`i9UJ4tWW;7y|!bRzc!OX$;eRhRNR|%fh0ZcTf%*pjQ)dB{%x%NJ6pBH zJXZIdwYGmI&=-BL-`^vU<^wQSx z%hJx*pm5TF{y6BFAD#_#S~;^uMb64yY!xc3r2azk)J~fQW*M z!T>^0kkA=LL_kET0s>M4Lg)}eAepg%fPhk^1rY(MkrFAPC{3wBs)Qyb0fbNz2!tf$ zY({7P|6k|cv({Pb+`I10ViDHISN7iD-ur#t=Y8J2Bj0Xu*gt$5%fT+lx2wgn>*NWi zvqDaiLN4I9gg}2{sDL(h(z2}Hh`lX_6I$B;lhKE$jjpK=ROU81?$6Pa1xtdZjgyRR zLS}pC6FTxtaOqvw>Ytm~gg_ZChF`vzfU!uhHUU4uRF{C&vDQe` zr$c@_7IY>1(zi(&Yl(&5sU!!I8w#TuJZu4&w@&0nMm*^Yz4ugN&^*aE-_&!UOn2H^ zM%LLlf3)|@ZdVm|_nbS@iI%q`wC+A{Y}W^WN);8Kb23hO)Z4C)vd4a%9xE;er>Bsc zEF#Ozt=hqrrY>&IV|BW*OZo;pR0v+EX=V97L-9g~600oq7EZ>e(VC=yGEpM29VCR(lZ7zsddf%xxxsFa z)DhqO{>CzA82usU+LJPKSFYSg>=%f4AME3~q9Os(f@jW6D=SwoIN5|#*vl5#Tsbaa zE@+6cBS-GFYpI#n5R_#n!LIdHUQW3~jh!j;ULBpE-cz;NWqFjUagUbp*;(dgh6L$$YLLZ(rh86f zZf5ZNGG^-JgJOZmCHAe=3&iQzcV4h6=Ml1WA zP!;b-#`y4^pod29W3p~bSYvdtK&#QiP5e^Q{Fi(C&p_GL~Tv)Y*N^~9Se#9u^n?^QtySZWe_u-ioJbwN6?VnCujTKP{UTji%B0Pl4 ztXqP0@kWLCm%nu6h0m8chY1QnKlC&<-ai2EPCu$`UF#8%Rafp-mnCae*3&aZ={#TY z`txmV>w4GHto%9-FMKSO97b7QfV+MwcQ^pIA3d%em7+%Mp>#)FtRNQ-9kY63UxU+F z@%Ec`IAFL@JtpS)sVpZ<2{!4V9kq`STCtlxD@-b?_B$Z!8CaCng@sP^2zF<>yN*

OiN=HwUoUMmz4F)0FZANx+JzqY}3;$F`1{D-?cU8sgL*KrBy&_B2vg}>Y z6mOsFC@*{x6pXT0pQb7@+p4hCem}o`CRRcNovbj0SFhK>$?w%=!>;v$tzr6r0 zqG*KZ;u>37TT7wVZOS(~$JgT%lg4I1=m*vLhWW!0O5_q_8SNW9H03r{+!?U#rZaNt z8@2`ZEp@x8q*p(DoZD=Goi`Uo<|cb4?qD%%=N&Aza{y6e_9qrg#H$jwEN@%7iyinh zq2V29wGHtQ|x!A5nill~?9|m@jyJ-qXciC3v{BKb4faXh6v0g)ZCk2j;(^ zH3m-9kCBb$#hfbke6zmy_9B%$+0DAMU%$F|-{=mbEPi!yHIXnxAlaCs)O!d zIUha&X=;r{C|1n(D2&v^QUwjOo$(F5?&&C)Os#Z}Z!TLRaW`caOi zwma8k8K!)7aP7-g6Hc8D+N-_e=wx;A@|{3!_kS zl@!B2f$rU#pch8T@_{NsyzI1j4jfcc6iRjrQwr|cFr^EO^s4yQwZp(MzGecv~i%uFO zor`>&s-oaB`EqzWdohgpQ1-TxR>;R5v^eLq#W|b9i_^^}57c%4*+YHeH7@@-7QS`; zW<$n5#{P4sEsCah>f1YKh3@k-a9>#3PqhZ5Ly8yr87hZy!Vs4;zE-wqT;0N7E3bxC z{ij1!)h3$tFGF}r0`>5JF|G$&krLaw*r|-xO{(C<5pLzrL zzZ|a}?E?O_$J8nLpBXW%04gg&Eqlt7PIDQFx2^?4Q{_-0N#oFFsJmkS2 zmmchqO4O(E7Hb2BzG4S(2E9LUT=}WGb|LWm0f%wmsQe2tz})b}sy~qW%m0@+NxZxN zyZqtzeV_#Qzl$X90%sr>kSKrh2hRcQ{-3_2r@Tu+4W%{`Qx6m)%@#a>Q~|uq&Lh`4 zX`SQUgC&`vnU};-e4>4*mARPe^Pzj9E6SI>ykO$>!Lg1o7;NO(@bIA=(DHyeH37np zU?rrB7i7NDMt77n zc4jAlHs;{LgSLk1Y8N072ozd*N;~YP>#ki#b+PY{05%4zhg~l{o*mch6seXsEvM8m z`U&&9q1UR}03eIh0>1XgjJtHz*47?Be%u3Y9o+c~pqu!=HNt74RheX)f}~`gm@XEH zLdA+eV?bwkr~KFJ#5?VDJ^8=isI2ggCWiN~eqd>Ebw@n&(_dX-Fm8t@dbjJJmlHLK z>gbc(J@m`q#J}53g<|OxcSf3-?AM1@fKrEiq`Df8WK!BTXp8+5orWwfK3tHKF zzh}l?&GtmTVs5cmU{}fsmf}-Ts*0b#zj#}uUQxO-}NVbX8C4_TBTgQ#>1HNk{b`$ID}n^Rd6-h zZk+n06@_%JWH?_e(zxZ{FtN5IOi2mXClj_Z)@lkC-16Jn+N6e|`(r|nERpBGew`}d zXh9KtM)>xD7Vt46FU@D&0<(J|J~RJQRdrPrd=xd)(<<=8($dnsM8*}ll6%puO@;#@ zr@<9wIjd`H#sorj^~F%DsGZjSyIyig!Nj%r=M~P#2>}TUtIq2Q56`mEwv*3#ml>im zPajIU@n!3{8qDWdwvnKz4~pkCAi5J;lt8Y=ExvJn=EoFW3BAkwwko|h&2EX?{Q}u% z5cg1W_e`jDoDHpG?$v6tSF)L@a98s`h9dl zwB}M@Muv?`3F~#19Gv{JQd2!>vw0S#IaaUqF<^W=JCB&I;iXnEbnc(2o!7A6*YMdU#Rdgy zE_(RF01CDBN{{zhxVD|qH=i>mct*d`xJbzyEI+KCfLd@e&T)34X-!Bs-0XeB`r4(f zf-v4Mq6@h$a6B;TTJXB7wvJBp`qUM8wT{F#x@(e_~{=8ty8OsF1 z+bK{_u$^EzEEBd+IV&Vtvf-a`0ZyuT@yNYE8dqW$u-dV_pfz5j>Pw+b=Xs`Uz5UgL zLW)ARgappU}7P&{x^oBiGk<9Hn+>97JQVpg@-0$-Kr6#X1qW< z82)JQiK5Mo4d+Z$q4+^bL7P$GY03>S)K(IJwfNp3hxBlm@!JU&&Z6-_T7@qUpEZbr zm}BNk&!3WD6%z1>Ejc|7vo^hsJb?+6TPDhQ-H~ok^M=1XbJ|&lS?4FoQ{(lzhK}Jz z4yT>k*Bv6m9f?miK#L@8&M|pt9_Ddp6fM!OmzFTw^!d{JcDtb&Whclf%huMoi{`n< z)o5*!nWFW#Ei=##KK^U70Z@o+Wcty!E5#&;h?Z91%)38+vYQziXHUIju^<++*wke$ zXr&^z&iRkMy4&-54a?W<@Rn(r1JJ+OYMw?@GN3?5CP8?*4?3jtN@9-V{|o=)uXO& zP1E9P35fK;SJzx5+%n_E74zwm0mI}2?Qb6Ds30mc38(hyJ^fjdWmC1T6a49mcA^T@ zbKB9JMs&%mJ(73^jQn~N6Q6|}m1pzg-dt(uR~v3i)?PZ_cH0@>R`XVvKDH=YsnK@_o zAqaMQnv7YqT8D1b8)%lcx3zFwJ%`t|N8W}$y`APKcbPgfb#b~F0qv&!BWU@zsUbTI zhxx%>aIrdQIjzP>>0ICIQS2+ADh0I$D$T!Yt&f(HZecBEG>Syu;OXNkLyJGO78KQZ znb&5@>@&Qbz;Q;7!C93T2*r?Vh1{W<=zOqx#nrD=RL6!bXL(q-sWNG}HeV8sw;L2#;-vhkBQI`vGluz(23q%Ka)@Ds>Uy`;F!&CRKo z>0^Gdd^2o^<~8P)?esI|ww&izBZd7q(a~dHG-tg%nFHw+{fp`8@!>86!q4@Dysbbg z_97blN%!k9-$7YMKxmn&I8dg#0#)jtXy^{DD8J{-p*F`DQ*`R^iqF-i?TW9f%dcwH z74!~ctCZ0=D%O$f$z`h)NRv6f_Oxf@#@?IO#0rksFF zk!RH1)?j8{Vp3Zso8l1yDQjA49TN@y*?sIQdbew)CGp`Urng-)=g;dMekSaj1$IS! zgyA(v={_wV*yLnfLj$go;h}iX$cX#yMNd!9 zZy3910;#F5ucPmS*1HM9yqjhShs^=rn0mjc=Jxwj;Pbv-^jLIwc-;<$w`lL_sUrhqnClzge3`{qU^ zRlL4Hb#ZYKV8hzf$9{R*t27!A7mHnRdYqAwv8n&#uHSXPTUOXp7$flQTh3mN9=+s@ zlELAO(0WoWuO7&>A=-s8JrUtx}L*iGTEWBxr zInm+suTxx_y1TTZh#KpMy09nI^PQ#BB|}e3LBZbEc8QjimzPsNy8i3wfK_pTdBOTt zbzcMb=fO9uy5mrQHWpL)3#tMb`+vf5xmidn(v1aVJ`jvIpgCI`%Q)a4BAx>Xy`zo- zQQpqR#>V#c_WFoT7XOZb{S}70{r2tKDT033_egPp`t&XkM*fYCDfVQFi?n@{(-)|t zHu+sStPL^wyT8#lBzB_6x^$b9?(Pdol>nyO0M`Hnt-BKRoijBe0p66N!?)C2P;&~O&g?9CTh~kdU|IhI6|KsBM|Gq*$ zYmkxx#0$F&n>XTlzlGgjn;~;erBT2kei4zFn7BC}2ZrIFnGVv#Bl~r5AlANsNqV+t<3 zsEm`c5Z={u=w?FeKE*2ywJ`H*iKbM(9eZKiFC z4*wu=BVF0)<1gUkK^ym7zo`L|0a)Uv`;abD3YBqz_zN&2uig?6a@q{@EIseo8iYh5 z8;*{SjqS)1BF#Zw55L6qUovg>o)28Sy(6D6`*D~5@$U_NfjA>5NV5p9+18pJXz;_# zB0Lx87y0^<>c+WU=0{SmO90`-t-=8?(_tZdyu6m%;w81*3O_oP0ahb|SUBC|st9~$ zkTw(2vTN4~;P&mLY!tUCR6lw|&YXC@-dl7=&m;GYQj|uWbNzAj>aw_o?2J($*IGL9 z;4#IIBO}%!h;3cNVsn`z`&Z%i38vBk-rjRn(1gm$jh-y+oLmfITCXUy&ya^ww`X&$m`AIjYH4YZ518k4#@$Xuj@<3%;kuxwjzl8(J z=g;Jp!GpS3JTJJ@ocCWHlr;IV9AIbAY6riSV`j(n3|;`!*Ndj_S`sSl96%ChB7$c$ zo*6D5%3OS=qp|dJtXitVb{#)j5G*!0IU z6LKHtndXBXs(G~yuV*H@iSw)Gt(ca%kk^l*fUldkK?(_NW-Mq=v@xlRS3*Mv%5-u9 ztujUez@9A?wlQsQ?qqT%{P4-hzVBm8cl&dfx#O-rYhD zOI{!k#!0V&qpN9kAuY9EKt7t4D?A=1Dk{4D%r8kUWqEo!TUHUW{257a5=D$D-a{m| z8?tTV!93aX>HD;;1pM*M@~X19Wv&;uWuYPRM#2>d&IJakf|0ACg?1mP< zpvSs;+n39?G&q>ZIV*Q5gqJ$`2UrUEbbJMBEhLq%OSX%lg_-reG?30OFrr_t8Xq`q zls>!ApD`w|id6NDy zOFTaZ+AS8z=&veFbF0Wia1Eow^TKoIxno-66p=SyoA@ST_7Bt>`EmLCNwLAMn0z-? zKdJ}`7s8z`Ru&ylLQ+c1i5ltjOl}C`OPywKh!ASsI*-ggEP^G~;cQC1dZ<5;|!lNUl84-J5I+E$*HN+rp}+GG^VlzC!^ zs)D)m%x{H6&E9<=eE%}qn?hO^_f?t+sigaphz@-Yab6jnZtS9&q-8ag0;1=|5Y%#r zRU71`pSIBuCTS?>?aJWiuHm?kGg6Z75b{NA2N&eT;9Np_(DY92V#ewQA7hXkv2?HN zz6^4ArUyO5@$zDBoj{9__IC@TMf}PR(R%t6y5orzH?`z+@*cEWo)imgJEJ>S6 z7CtI`;?OV04ih=E&R>gfi2GN^M<;kol?BRP&FB76CP=tWTghv+Hqs!`=6_RhC%W z`b*DB%Ns*R2W~_)s6A^RPj+u2lbC4 zUX41MbQ|Gy>*CNCwEU=DC{`s!|1v*b=*9fqAtT2N7EYmvdE?SEiepmq9R~U28=2lfqc$9WtoJTq9CFWLdBh%E*+~+q$EYA z`wX~;k8lZN&O@|?y>bv0i-Eb7z{ZEfrQ;pHZntzk^b|AwkgOE6RP<{NYyG)Oo4K`& zbiOpLJg!5680mT3nQZH=s!mS3e*pDLAJh_8%EGQVS~GK6ep>C$C< ziC9%X*@mevF#>%s9P#z9BSK%Mzs~!eYH;8(1{M%!>>5%-wyrZ^ta0=F(MLin=Sk;? zl+0^DmbK~#L;+il$E}soj^O}U z%!#V!CjNQ#i=W32Jo{rQHCLoxI~(oIL#S3J5N+z|U*5xQMftoI1%Wwwk^LQ%xEI<>bbIw7^XS1{j!`2>Dj9fE+|hL z=`P@ME$T(%dZsyH6;o59%M|BpP|DRy#ib+}62}nK*V}ct9=| ziwtsAuVdKO2QH5%&!^-|N#$?YsNe@j3Yl&i6|oBh@AZy?xV40e^iczm*+OlhBB|oS zJDLU>XTN5!3ktM(V9YRxhRLlOSQBY;VK-q=r*%>P9N`V>#$3!)WJjSO)2`}Ps6T7b zEk1YCYpbY8+0AxzE1_2el}1|}#rKAem}j(C1v5S>*)8`xefPU$Xpo}_#nNT?lg+a% zo|2>_4_he90X_eAJ!)38^Ra@P$fObPg)T%n)M;XC z<{ouP#@Ufik&@ic_N3zL%qBw9Z(Y0_&`Y_GOGcZ2iSw3`$)Cdf$d78k5NIxAjR}x) zV;L^WypPXc=d2!OrkJn=zidAC`asGv6+4M^u=MnkptjC6w{Z6x6ihO@dXw)~1S!7d z6>AoaAkUfU-R^_Haz2jBJg-@}x0SJ+Thx*8u$k**7vSPK7H3pQ(?1scP@>kiR#UQ+ zhX1`8t0vMLS%FNlK~;9dnd*7Be;p-QcMr-MakerHh6HU1h|TMRL`P$^iki*^g;kiJ z*O`9Bn33mIb&Dg0;XG*mM0V6?Cve^CP=r@PvJ%;HyOhRAg&As{6*xv%ap+|Z+*07M z**~T-SsLoelx$eb2d$WuH5!9)GyhJb5Kbol+>_^%IaZa0RatTii_EBGh;40%J?s8( z2SUs_CB z=O>Lsb<}ej@VY#TZI!q@^KBLRPm~9Q#DTLI%TV|R6}5cAEx6y|%FCJJ1%g*7!RZzW zHz8HKInZE2OuRl}y**eurKKwodu>y7S>FWPrJvLZ7Jqyj$rFueMC5VNfmR#h0lV!& z72pP*&14){w;L@Mub?o!Fui(~Kej)?LXolGaC3qv5@}qxts-8!QPNu5E25!9zQd(V z6drWqY*VKo1r7VHZoSdmAY@4PoroC}N*X-)*pNAqcvb{=M8;gGWoQgruYz3J9uaU} z@72lgm3<^aG~*4y%{Vm;VWaxO7W1oFoXRW$Y5su-{?YW3=y6d|>Ovm$@aRD5BRyu$ z2QA(}A2avNo3;X_PN6Mby>s$*iQIyO!koPm*gVF{8iVHT=XW5J>uEob(JHbk!^z;l zmT(0IcdC2fx`FEhbn6Epuv)xijy&u3??l?>eNwsg{m^81;TCP_Js-nkgz^4(bnx(_ z(XR&K;x=Yl{nvRaEU~i6iPnC;n1}Iw{#@Zq=OoUo-GrxggXn*;%9Ak4f$6Q>vM<~Y*4d5`qK#I z?yYeBtx03N6Z|=>>zU)6%GAIy!_Mt#I}eQe)jshR#d{l1hoib}*$ysm8VG3;>RZ{b zik{5O%)Fw5B+6=KmhPf@+I9=p72hs)t6(Y=o1_AK%}vmO?U-)PZ#L9}kl)YpYQJCgli z483Vzuj?wRIY5p-`7ah9PPUKB6TnKup~c?cG?ofJ+PoD}}`=>RoH( zY=ldW8x>KEhHU$D?KEq7`EV^wFs2Y@UJwVTcPjHCpp}%_X0Pp>@K!_e;2l5FV~9lk zfwi~+L#+bJfYQ`$2{TbDV=DQ?Sok<7%C1r zwD7jAoCUql;)uL&(k||`^`#1+gC_Af6DnBA!2)Wh1>D~}Mwqe2usgC6(80S-e9s|* zC&Ny_4n^RKy4B()K0CXSID_}5@ptB}nbX8qW83HZ*Fgw5ZtjSWg?yh-#+Gk0Sx!m(99X;|Z5%usWEAXr zN7wn2WY{4`a_-z4A7c#J5>gMoxaDdT@F_oT*s=j(DN|nd_Gw2mw9bBJC#T@=w6-is zVmamVf`bAZnpzCeX*F=@9quD_OeSw<(vQ)xfk{TaE?ooX@4nk*1Tst@kX4N>F)8p3 zR-gdPYUQI_fL7^$ee1-AIZ#|2Q*_JhZ7>*u6WQGJ;2yw=ifRN`Y=#9Luf8)51U5i|5fh{h{c#uR`vM|>a<%eJ$+><{N&%2y#!-Xg^rSYYP!=OYHJ~6oJbWWI!>U+R zEwIQq-$Um$AgzA*X^*m+8VF14PR`G(z)6Z=sri(}wwR)S-t&5!LWRPg92|o>GQ{{$ z0uqZrK0vW+xl7kx5x9p(^Xy2>I`|hz>>2oXPOl1Zdc$B*l&;L1y0uxZ3cEHBePf>g zCzmbxH_{QuCPQ#(4M&2_FQ_emSC|A44dM{sLDdiVRPv8KZv_mk^)19{Aznt%nsP6M9mvmKY6r?rNp=X`9=>{K6W>;B^>Ab#0p zlyOH6vp!(aveC5C2$qalNNm_96B|xU0eY`r%h0wb0e1Yi9bJ>^Iayhg@T6}ia)McIYj2|9xhY4`sp-;cOrdp#xf@9L+Q?vz_k M=f;(i%Xjbp2N2xE4FCWD literal 0 HcmV?d00001 diff --git a/offlineu_core.py b/offlineu_core.py new file mode 100644 index 0000000..75a95c1 --- /dev/null +++ b/offlineu_core.py @@ -0,0 +1,1038 @@ +#!/usr/bin/env python3 +""" +OfflineU - Self-hosted Course Viewer & Tracker +Enhanced version with dynamic subdirectory navigation +""" + +import os +import json +import mimetypes +import re +import sys +import argparse +from pathlib import Path +from datetime import datetime +from dataclasses import dataclass, asdict +from typing import List, Dict, Optional, Any, Tuple +from flask import Flask, render_template, request, jsonify, send_file, redirect, url_for + +app = Flask(__name__) +app.config['SECRET_KEY'] = 'your-secret-key-change-in-production' + +# Supported file types +VIDEO_EXTENSIONS = {'.mp4', '.mkv', '.avi', '.mov', '.webm', '.m4v', '.flv', '.wmv'} +AUDIO_EXTENSIONS = {'.mp3', '.wav', '.m4a', '.aac', '.ogg', '.flac'} +SUBTITLE_EXTENSIONS = {'.srt', '.vtt', '.ass', '.sub', '.sbv'} +TEXT_EXTENSIONS = {'.txt', '.md', '.html', '.htm', '.pdf', '.docx', '.doc', '.rtf'} +QUIZ_INDICATORS = {'quiz', 'exam', 'test', 'assessment', 'exercise', 'assignment', 'homework'} + +# Base directory the "Library" browser scans for courses, so users don't have +# to type a full filesystem path. Matches the ./courses volume mount in +# docker-compose.yml by default; override with --library-path or the +# COURSES_LIBRARY_PATH env var. +LIBRARY_PATH = os.environ.get('COURSES_LIBRARY_PATH', '/app/courses') + + +@dataclass +class Lesson: + title: str + path: str + lesson_type: str # 'video', 'audio', 'text', 'quiz', 'mixed' + video_file: Optional[str] = None + audio_file: Optional[str] = None + subtitle_file: Optional[str] = None + text_files: List[str] = None + completed: bool = False + last_accessed: Optional[str] = None + progress_seconds: int = 0 + order: int = 0 + + def __post_init__(self): + if self.text_files is None: + self.text_files = [] + + +@dataclass +class DirectoryNode: + """Represents a directory in the course structure""" + name: str + path: str + type: str # 'directory' or 'lesson' + children: Dict[str, 'DirectoryNode'] = None + lessons: List[Lesson] = None + completed: bool = False + last_accessed: Optional[str] = None + order: int = 0 + has_content: bool = False # Whether this directory contains actual lesson content + + def __post_init__(self): + if self.children is None: + self.children = {} + if self.lessons is None: + self.lessons = [] + + +@dataclass +class Course: + name: str + path: str + root_node: DirectoryNode + progress_file: str + last_accessed_path: Optional[str] = None + completion_percentage: float = 0.0 + + def __post_init__(self): + if self.root_node is None: + self.root_node = DirectoryNode("", "", "directory") + + +class DynamicCourseParser: + """Enhanced parser that builds a proper directory tree structure""" + + @staticmethod + def scan_directory(course_path: str) -> Course: + """Scan directory and build dynamic tree structure""" + course_path = Path(course_path) + if not course_path.exists() or not course_path.is_dir(): + raise ValueError(f"Invalid course path: {course_path}") + + course_name = course_path.name + print(f"Scanning course: {course_name}") + + # Build the directory tree + root_node = DynamicCourseParser._build_directory_tree(course_path, course_path) + + # Calculate completion statistics + stats = DynamicCourseParser._calculate_completion_stats(root_node) + + progress_file = str(course_path / ".offlineu_progress.json") + + return Course( + name=course_name, + path=str(course_path), + root_node=root_node, + progress_file=progress_file + ) + + @staticmethod + def _build_directory_tree(course_path: Path, current_path: Path, depth: int = 0) -> DirectoryNode: + """Recursively build directory tree structure""" + if depth > 10: # Prevent infinite recursion + return DirectoryNode(current_path.name, str(current_path), "directory") + + node_name = current_path.name if current_path != course_path else "Course Root" + node = DirectoryNode( + name=node_name, + path=str(current_path), + type="directory", + order=depth + ) + + try: + # Get all items in current directory + items = sorted(current_path.iterdir(), key=lambda x: (x.is_file(), x.name.lower())) + + for item in items: + if item.name.startswith('.'): + continue + + if item.is_dir(): + # Recursively process subdirectory + child_node = DynamicCourseParser._build_directory_tree(course_path, item, depth + 1) + if child_node.has_content or child_node.children: + node.children[child_node.name] = child_node + node.has_content = True + + elif item.is_file(): + # Process file as potential lesson content + lesson = DynamicCourseParser._create_lesson_from_file(item, course_path) + if lesson: + # Add lesson directly to this node's lessons list + node.lessons.append(lesson) + node.has_content = True + + except (PermissionError, OSError) as e: + print(f"Error accessing {current_path}: {e}") + + return node + + @staticmethod + def _create_lesson_from_file(file_path: Path, course_path: Path) -> Optional[Lesson]: + """Create a lesson from a single file""" + ext = file_path.suffix.lower() + filename = file_path.name.lower() + + # Skip non-content files + if ext in {'.log', '.tmp', '.bak', '.swp', '.DS_Store', '.Thumbs.db'}: + return None + + # Determine lesson type and files + video_file = None + audio_file = None + subtitle_file = None + text_files = [] + lesson_type = 'text' + + # Create relative path for file serving - normalize to forward slashes + relative_path = str(file_path.relative_to(course_path)).replace('\\', '/') + + if ext in VIDEO_EXTENSIONS: + video_file = relative_path + lesson_type = 'video' + elif ext in AUDIO_EXTENSIONS: + audio_file = relative_path + lesson_type = 'audio' + elif ext in SUBTITLE_EXTENSIONS: + subtitle_file = relative_path + return None # Don't create lessons for subtitle files alone + elif ext in TEXT_EXTENSIONS: + text_files.append(relative_path) + if any(indicator in filename for indicator in QUIZ_INDICATORS): + lesson_type = 'quiz' + else: + # Skip unsupported file types + return None + + # Clean up lesson name for display + display_name = DynamicCourseParser._clean_lesson_name(file_path.stem) + + return Lesson( + title=display_name, + path=str(file_path), # Store the actual file path, not just parent + lesson_type=lesson_type, + video_file=video_file, + audio_file=audio_file, + subtitle_file=subtitle_file, + text_files=text_files, + order=0 + ) + + @staticmethod + def _clean_lesson_name(name: str) -> str: + """Clean up lesson name for display""" + # Remove common patterns + name = re.sub(r'^\d+[\.\-_\s]*', '', name) # Remove leading numbers + name = re.sub(r'[-_]+', ' ', name) # Replace dashes/underscores with spaces + name = ' '.join(word.capitalize() for word in name.split() if word) + return name if name.strip() else "Untitled Lesson" + + @staticmethod + def _calculate_completion_stats(node: DirectoryNode) -> Dict[str, Any]: + """Calculate completion statistics for a directory node""" + total_lessons = 0 + completed_lessons = 0 + + def count_lessons_recursive(n: DirectoryNode): + nonlocal total_lessons, completed_lessons + + # Count lessons in this node + for lesson in n.lessons: + total_lessons += 1 + if lesson.completed: + completed_lessons += 1 + + # Recursively count in children + for child in n.children.values(): + count_lessons_recursive(child) + + count_lessons_recursive(node) + + completion_percentage = (completed_lessons / total_lessons * 100) if total_lessons > 0 else 0 + + return { + 'total_lessons': total_lessons, + 'completed_lessons': completed_lessons, + 'completion_percentage': round(completion_percentage, 1) + } + + +def _has_direct_media(directory: Path) -> bool: + """Check whether a directory contains media files directly (not recursively)""" + try: + for f in directory.iterdir(): + if f.is_file() and f.suffix.lower() in VIDEO_EXTENSIONS | AUDIO_EXTENSIONS: + return True + except (PermissionError, OSError): + pass + return False + + +def _looks_like_course(directory: Path) -> bool: + """ + Heuristic for 'this folder is a course, stop recursing into it': + it has media files directly, or at least two of its immediate + subfolders do (covers the common Section 1/, Section 2/... layout). + + Requiring 2+ matching subfolders (rather than just 1) avoids mistaking + a publisher/grouping folder that holds a single course - e.g. + Pluralsight/Docker Deep Dive/lesson.mp4 - for the course itself. + """ + if _has_direct_media(directory): + return True + try: + children_with_media = [ + child for child in directory.iterdir() + if child.is_dir() and not child.name.startswith('.') and _has_direct_media(child) + ] + except (PermissionError, OSError): + return False + return len(children_with_media) >= 2 + + +def scan_course_library(library_path: str, max_depth: int = 5) -> Dict[str, Any]: + """ + Scan a base 'library' directory for course folders and group them by the + parent directory they live in, so the UI can show something like: + + Udemy/ + Python Bootcamp + Web Dev Masterclass + Pluralsight/ + Docker Deep Dive + (Library Root) + Standalone Course + + Recursion stops as soon as a directory looks like a course (see + _looks_like_course), so nested "Section" folders inside a course aren't + mistaken for more courses. + """ + library_root = Path(library_path) + groups: Dict[str, List[Dict[str, Any]]] = {} + errors: List[str] = [] + + if not library_root.exists() or not library_root.is_dir(): + return {'groups': groups, 'errors': [f"Library path not found: {library_path}"]} + + def walk(current: Path, depth: int): + if depth > max_depth: + return + try: + entries = sorted( + (p for p in current.iterdir() if p.is_dir() and not p.name.startswith('.')), + key=lambda p: p.name.lower() + ) + except (PermissionError, OSError) as e: + errors.append(f"Could not read {current}: {e}") + return + + for entry in entries: + if _looks_like_course(entry): + try: + rel_parent = entry.parent.relative_to(library_root) + except ValueError: + rel_parent = Path('.') + group_label = str(rel_parent).replace('\\', '/') if str(rel_parent) != '.' else '(Library Root)' + + media_count = len([ + f for f in entry.rglob('*') + if f.is_file() and f.suffix.lower() in VIDEO_EXTENSIONS | AUDIO_EXTENSIONS + ]) + + groups.setdefault(group_label, []).append({ + 'name': entry.name, + 'path': str(entry), + 'media_files': media_count + }) + else: + walk(entry, depth + 1) + + walk(library_root, 0) + + for courses in groups.values(): + courses.sort(key=lambda c: c['name'].lower()) + + return {'groups': groups, 'errors': errors} + + +class ProgressTracker: + """Handles progress tracking and persistence""" + + @staticmethod + def load_progress(course: Course) -> Dict[str, Any]: + """Load progress from JSON file""" + try: + with open(course.progress_file, 'r') as f: + return json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + return {} + + @staticmethod + def save_progress(course: Course, progress_data: Dict[str, Any]): + """Save progress to JSON file""" + try: + with open(course.progress_file, 'w') as f: + json.dump(progress_data, f, indent=2) + except Exception as e: + print(f"Error saving progress: {e}") + + @staticmethod + def update_lesson_progress(course: Course, lesson_path: str, completed: bool = False, progress_seconds: int = 0): + """Update progress for specific lesson by path""" + progress = ProgressTracker.load_progress(course) + + progress[lesson_path] = { + 'completed': completed, + 'progress_seconds': progress_seconds, + 'last_accessed': datetime.now().isoformat() + } + + # Update last accessed path + progress['last_accessed_path'] = lesson_path + + ProgressTracker.save_progress(course, progress) + + @staticmethod + def apply_progress_to_tree(course: Course): + """Apply saved progress to the course tree""" + progress = ProgressTracker.load_progress(course) + + def apply_to_node(node: DirectoryNode): + # Apply progress to lessons in this node + for lesson in node.lessons: + lesson_path = os.path.relpath(lesson.path, course.path) + lesson_path = lesson_path.replace('\\', '/') + if lesson_path.startswith('/'): + lesson_path = lesson_path[1:] + + # Check both the base path and path with title + lesson_path_with_title = f"{lesson_path}/{lesson.title.replace(' ', '_')}" + + if lesson_path in progress: + lesson.completed = progress[lesson_path].get('completed', False) + lesson.last_accessed = progress[lesson_path].get('last_accessed') + lesson.progress_seconds = progress[lesson_path].get('progress_seconds', 0) + elif lesson_path_with_title in progress: + lesson.completed = progress[lesson_path_with_title].get('completed', False) + lesson.last_accessed = progress[lesson_path_with_title].get('last_accessed') + lesson.progress_seconds = progress[lesson_path_with_title].get('progress_seconds', 0) + + # Recursively apply to children + for child in node.children.values(): + apply_to_node(child) + + apply_to_node(course.root_node) + course.last_accessed_path = progress.get('last_accessed_path') + + @staticmethod + def get_completion_stats(course: Course) -> Dict[str, Any]: + """Calculate completion statistics""" + return DynamicCourseParser._calculate_completion_stats(course.root_node) + + +# Global course storage +current_course = None + + +@app.route('/') +def index(): + """Main dashboard""" + global current_course + + if current_course is None: + # Show dashboard with course selection option + return render_template('course_dashboard.html', + course=None, + stats={'total_lessons': 0, 'completed_lessons': 0, 'completion_percentage': 0}) + + # Apply progress data to tree + ProgressTracker.apply_progress_to_tree(current_course) + stats = ProgressTracker.get_completion_stats(current_course) + + return render_template('course_dashboard.html', + course=current_course, + stats=stats) + + +@app.route('/browse') +def browse_directories(): + """Browse directories for course selection""" + path = request.args.get('path', '') + + try: + # If no path specified, start with available drives on Windows + if not path: + import platform + if platform.system() == 'Windows': + # Get available drives on Windows + import string + drives = [] + for letter in string.ascii_uppercase: + drive = f"{letter}:\\" + if os.path.exists(drive): + drives.append({ + 'name': f"Drive {letter}:", + 'path': drive, + 'media_files': 0, + 'is_course_candidate': False + }) + print(f"Returning {len(drives)} drives") + return jsonify({ + 'current_path': 'Select a Drive', + 'parent_path': None, + 'directories': drives + }) + else: + # On other systems, start from home directory + path = str(Path.home()) + + current_path = Path(path) + if not current_path.exists() or not current_path.is_dir(): + # Fallback to home directory if path doesn't exist + current_path = Path.home() + + print(f"Browsing directory: {current_path}") + + # Get directories and basic info + directories = [] + try: + for item in sorted(current_path.iterdir()): + if item.is_dir() and not item.name.startswith('.'): + try: + # Check if this looks like a course directory + media_count = len([f for f in item.rglob('*') + if f.suffix.lower() in VIDEO_EXTENSIONS | AUDIO_EXTENSIONS]) + + directories.append({ + 'name': item.name, + 'path': str(item), + 'media_files': media_count, + 'is_course_candidate': media_count > 0 + }) + except (PermissionError, OSError): + directories.append({ + 'name': item.name + " (Access Denied)", + 'path': str(item), + 'media_files': 0, + 'is_course_candidate': False + }) + except (PermissionError, OSError) as e: + print(f"Access denied to {current_path}: {str(e)}") + return jsonify({'error': f'Access denied to {current_path}: {str(e)}'}), 403 + + # Determine parent path + parent = None + if current_path.parent != current_path: + try: + parent = str(current_path.parent) + except (PermissionError, OSError): + pass + + print(f"Found {len(directories)} directories") + return jsonify({ + 'current_path': str(current_path), + 'parent_path': parent, + 'directories': directories + }) + + except Exception as e: + print(f"Error in browse_directories: {str(e)}") + return jsonify({'error': str(e)}), 500 + + +@app.route('/library') +def browse_library(): + """ + Scan the configured courses library and return courses grouped by + parent directory, so the UI can offer a click-through picker instead + of requiring a typed filesystem path. + """ + library_path = request.args.get('path', LIBRARY_PATH) + result = scan_course_library(library_path) + return jsonify({ + 'library_path': library_path, + 'groups': result['groups'], + 'errors': result['errors'] + }) + + +@app.route('/load_course', methods=['POST']) +def load_course(): + """Load course from selected directory""" + global current_course + + data = request.json + course_path = data.get('course_path') + + if not course_path or not os.path.exists(course_path): + return jsonify({'error': 'Invalid course path'}), 400 + + try: + current_course = DynamicCourseParser.scan_directory(course_path) + return jsonify({'success': True, 'course_name': current_course.name}) + except Exception as e: + return jsonify({'error': str(e)}), 500 + + +@app.route('/lesson/') +def view_lesson(lesson_path: str): + """View specific lesson by path""" + global current_course + + if not current_course: + return redirect(url_for('index')) + + # Find the lesson in the tree + lesson = find_lesson_in_tree(current_course.root_node, lesson_path) + + if not lesson: + return redirect(url_for('index')) + + # Get all lessons for navigation + all_lessons = get_all_lessons(current_course.root_node) + current_index = -1 + + # Find current lesson index + for i, (path, lesson_obj) in enumerate(all_lessons): + if lesson_obj == lesson: + current_index = i + break + + # Get next and previous lessons + prev_lesson = None + next_lesson = None + + if current_index > 0: + prev_lesson = all_lessons[current_index - 1][0] + + if current_index < len(all_lessons) - 1: + next_lesson = all_lessons[current_index + 1][0] + + # Update last accessed + ProgressTracker.update_lesson_progress(current_course, lesson_path) + + return render_template('lesson_view.html', + course=current_course, + lesson=lesson, + lesson_path=lesson_path, + prev_lesson=prev_lesson, + next_lesson=next_lesson) + + +def get_lesson_url(lesson: Lesson, course_path: str) -> str: + """Generate the URL for a lesson""" + # Create relative path from course root + lesson_file_path = os.path.relpath(lesson.path, course_path) + lesson_file_path = lesson_file_path.replace('\\', '/') + if lesson_file_path.startswith('/'): + lesson_file_path = lesson_file_path[1:] + + # Append lesson title for uniqueness + lesson_url = f"{lesson_file_path}/{lesson.title.replace(' ', '_')}" + return lesson_url + + +def find_lesson_in_tree(node: DirectoryNode, target_path: str) -> Optional[Lesson]: + """Find a lesson in the tree by path""" + # Check lessons in current node + for lesson in node.lessons: + lesson_url = get_lesson_url(lesson, current_course.path) + + # Check multiple possible path formats + lesson_file_path = os.path.relpath(lesson.path, current_course.path) + lesson_file_path = lesson_file_path.replace('\\', '/') + if lesson_file_path.startswith('/'): + lesson_file_path = lesson_file_path[1:] + + # Also check with lesson title appended + lesson_path_with_title = f"{lesson_file_path}/{lesson.title.replace(' ', '_')}" + + if (lesson_url == target_path or + lesson_file_path == target_path or + lesson_path_with_title == target_path): + return lesson + + # Recursively search children + for child in node.children.values(): + result = find_lesson_in_tree(child, target_path) + if result: + return result + + return None + + +def get_all_lessons(node: DirectoryNode) -> List[Tuple[str, Lesson]]: + """Get all lessons from the tree with their paths""" + lessons = [] + + def collect_lessons(n: DirectoryNode, current_path: str = ""): + # Add lessons from this node + for lesson in n.lessons: + lesson_url = get_lesson_url(lesson, current_course.path) + lessons.append((lesson_url, lesson)) + + # Recursively collect from children + for child in n.children.values(): + collect_lessons(child, current_path) + + collect_lessons(node) + return lessons + + +@app.route('/api/progress', methods=['POST']) +def update_progress(): + """API endpoint to update lesson progress""" + global current_course + + if not current_course: + return jsonify({'error': 'No course loaded'}), 400 + + data = request.json + lesson_path = data.get('lesson_path') + completed = data.get('completed', False) + progress_seconds = data.get('progress_seconds', 0) + + try: + ProgressTracker.update_lesson_progress( + current_course, lesson_path, completed, progress_seconds + ) + return jsonify({'success': True}) + except Exception as e: + return jsonify({'error': str(e)}), 500 + + +@app.route('/files/') +def serve_file(filepath): + """Serve course files""" + global current_course + + if not current_course: + return "No course loaded", 404 + + # Security: ensure file is within course directory + try: + # URL decode the filepath and normalize it + from urllib.parse import unquote + decoded_filepath = unquote(filepath) + + # Construct the full path relative to the course directory + full_path = os.path.join(current_course.path, decoded_filepath) + full_path = os.path.abspath(full_path) + course_path = os.path.abspath(current_course.path) + + print(f"File request: {filepath}") + print(f"Decoded filepath: {decoded_filepath}") + print(f"Full path: {full_path}") + print(f"Course path: {course_path}") + + # Security check: ensure file is within course directory + if not full_path.startswith(course_path): + print(f"Access denied: {full_path} not in {course_path}") + return "Access denied", 403 + + if not os.path.exists(full_path): + print(f"File not found: {full_path}") + return "File not found", 404 + + print(f"Serving file: {full_path}") + + # Determine MIME type + mime_type, _ = mimetypes.guess_type(full_path) + if mime_type is None: + mime_type = 'application/octet-stream' + + return send_file(full_path, mimetype=mime_type) + except Exception as e: + print(f"Error serving file: {str(e)}") + return f"Error serving file: {str(e)}", 500 + +@app.route('/health') +def healthcheck(): + """Healthcheck endpoint for Docker""" + return jsonify({"status": "healthy"}), 200 + +@app.route('/reset_course') +def reset_course(): + """Reset current course selection""" + global current_course + current_course = None + return redirect(url_for('index')) + + +def create_templates(): + """Create basic template files if they don't exist""" + templates_dir = Path('templates') + templates_dir.mkdir(exist_ok=True) + + # Basic select course template + select_template = ''' + + + OfflineU - Select Course + + + +

+

OfflineU - Course Selection

+
+ +
+ +''' + + # Basic course dashboard template + dashboard_template = ''' + + + OfflineU - {{ course.name }} + + + +
+

{{ course.name }}

+
+
+
+

Progress: {{ stats.completed_lessons }}/{{ stats.total_lessons }} lessons ({{ stats.completion_percentage }}%)

+ + {% for module_idx, module in course.modules|enumerate %} +
+

{{ module.title }}

+ {% for lesson_idx, lesson in module.lessons|enumerate %} + + {% endfor %} +
+ {% endfor %} + +

Select Different Course

+
+ +''' + + # Basic lesson view template + lesson_template = ''' + + + {{ lesson.title }} - {{ course.name }} + + + +
+

{{ lesson.title }}

+ + +
+ {% if lesson.video_file %} +

Video

+ + {% endif %} + + {% if lesson.audio_file %} +

Audio

+ + {% endif %} + + {% if lesson.text_files %} +

Additional Resources

+ {% for text_file in lesson.text_files %} + + 📄 {{ text_file.split('/')[-1] }} + + {% endfor %} + {% endif %} +
+ + +
+ +''' + + # Write templates to files + template_files = { + 'select_course.html': select_template, + 'course_dashboard.html': dashboard_template, + 'lesson_view.html': lesson_template + } + + for filename, content in template_files.items(): + template_path = templates_dir / filename + if not template_path.exists(): + with open(template_path, 'w', encoding='utf-8') as f: + f.write(content) + print(f"Created template: {template_path}") + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description='OfflineU Course Viewer & Tracker') + parser.add_argument('--host', default='0.0.0.0', help='Host to bind to') + parser.add_argument('--port', type=int, default=5000, help='Port to bind to') + parser.add_argument('--debug', action='store_true', help='Enable debug mode') + parser.add_argument('--create-templates', action='store_true', help='Create basic templates') + parser.add_argument('--library-path', default=None, + help='Base directory to scan for courses in the Library browser ' + '(default: $COURSES_LIBRARY_PATH or /app/courses)') + parser.add_argument('course_path', nargs='?', help='Path to course directory') + + args = parser.parse_args() + + if args.library_path: + LIBRARY_PATH = args.library_path + + # Create templates if requested + if args.create_templates: + create_templates() + print("Templates created successfully!") + if not args.course_path: + sys.exit(0) + + # Auto-load course if provided + if args.course_path or os.environ.get('AUTO_LOAD_COURSE'): + course_path = args.course_path or os.environ.get('AUTO_LOAD_COURSE') + + if not os.path.exists(course_path): + print(f"Error: Course path does not exist: {course_path}", file=sys.stderr) + sys.exit(1) + + try: + current_course = DynamicCourseParser.scan_directory(course_path) + print(f"Auto-loaded course: {current_course.name}") + print(f"Built dynamic directory tree with {len(current_course.root_node.children)} top-level items") + except Exception as e: + print(f"Error loading course: {e}", file=sys.stderr) + if args.debug: + import traceback + + traceback.print_exc() + sys.exit(1) + + # Create templates directory if it doesn't exist + if not Path('templates').exists(): + print("Templates directory not found. Creating basic templates...") + create_templates() + + print(f"Starting OfflineU on http://{args.host}:{args.port}") + print("Use --create-templates to regenerate template files") + + try: + app.run(debug=args.debug, host=args.host, port=args.port) + except KeyboardInterrupt: + print("\nShutting down OfflineU...") + except Exception as e: + print(f"Error starting server: {e}") + if args.debug: + import traceback + + traceback.print_exc() + sys.exit(1) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f4b2284 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +blinker==1.9.0 +click==8.2.1 +flask==3.1.1 +itsdangerous==2.2.0 +jinja2==3.1.6 +markupsafe==3.0.2 +werkzeug==3.1.3 diff --git a/templates/course_dashboard.html b/templates/course_dashboard.html new file mode 100644 index 0000000..af1f22e --- /dev/null +++ b/templates/course_dashboard.html @@ -0,0 +1,632 @@ + + + + + + {% if course %}{{ course.name }} - OfflineU{% else %}OfflineU{% endif %} + + + +
+
+

OfflineU

+
+
+ + {% if course %} + + +
+
+

{{ course.name }}

+
+
+ {{ stats.completed_lessons }}/{{ stats.total_lessons }} lessons completed +
+
+ {{ "%.1f"|format(stats.completion_percentage) }}% +
+
+
+
+
+ + {% if stats.last_accessed_path %} +
+ Last Accessed: {{ stats.last_accessed_path }} + + Continue + +
+ {% endif %} +
+ +
+ {% macro render_tree_node(node, depth=0) %} +
+
+
+ 📁 + {{ node.name }} + + {{ (node.children|length + node.lessons|length) }} items + +
+ {% if node.children or node.lessons %} + + {% endif %} +
+ + {% if node.children or node.lessons %} +
+ {% for child_name, child_node in node.children.items() %} + {{ render_tree_node(child_node, depth + 1) }} + {% endfor %} + + {% for lesson in node.lessons %} + {% set lesson_relative_path = lesson.path|replace('\\', '/')|replace(course.path|replace('\\', '/'), '')|replace('//', '/')|replace('/', '', 1) %} +
+
+ + {% if lesson.lesson_type == 'video' %}🎥 + {% elif lesson.lesson_type == 'audio' %}🎵 + {% elif lesson.lesson_type == 'quiz' %}📝 + {% elif lesson.lesson_type == 'mixed' %}📦 + {% else %}📄{% endif %} + + {{ lesson.title }} +
+
+ {{ lesson.lesson_type|title }} + {% if lesson.completed %} + + {% else %} + + {% endif %} +
+
+ {% endfor %} +
+ {% endif %} +
+ {% endmacro %} + + {{ render_tree_node(course.root_node) }} +
+
+ {% else %} +
+
+

Your Courses

+

+
+
+ +
+

+ + Don't see it? Enter a path manually + +

+ +
+ +
+
+

How to Use OfflineU:

+
    +
  • Prepare your course files in a directory structure
  • +
  • Copy the full path to your course directory
  • +
  • Paste the path in the input field above
  • +
  • Click "Load Course" or press Enter
  • +
  • Start learning! Your progress will be saved automatically
  • +
+
+ +
+

Supported File Types:

+
    +
  • Videos: .mp4, .mkv, .avi, .mov, .webm
  • +
  • Audio: .mp3, .wav, .m4a, .aac
  • +
  • Documents: .txt, .md, .html, .pdf
  • +
  • Subtitles: .srt, .vtt
  • +
+
+
+
+ {% endif %} + + + + \ No newline at end of file diff --git a/templates/lesson_view.html b/templates/lesson_view.html new file mode 100644 index 0000000..29c10aa --- /dev/null +++ b/templates/lesson_view.html @@ -0,0 +1,365 @@ + + + + {{ lesson.title }} - {{ course.name }} + + + +
+

{{ lesson.title }}

+ +
+ Path: {{ lesson_path }} +
+ + + +
+ {% if lesson.video_file %} +

Video

+ + {% endif %} + + {% if lesson.audio_file %} +

Audio

+ + {% endif %} + + {% if lesson.text_files %} +

Content

+ {% for text_file in lesson.text_files %} +
+

{{ text_file.split('/')[-1] }}

+
+
Loading content...
+
+ + 📄 Open {{ text_file.split('/')[-1] }} in new tab + +
+ {% endfor %} + {% endif %} +
+ + + + +
+ + \ No newline at end of file diff --git a/templates/select_course.html b/templates/select_course.html new file mode 100644 index 0000000..2f9a93d --- /dev/null +++ b/templates/select_course.html @@ -0,0 +1,153 @@ + + + + OfflineU - Select Course + + + +
+

OfflineU - Course Selection

+ +
+ + +
+ +
+
+
+
+ + + + +
+ +